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-05 13:03:50 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-03-05 13:03:50 +0000
commitf3fcf863aae000964753f574b00e9fb9f5fcd452 (patch)
tree522e1cff8e0b8ab9fcabcf1bc6d229076891542e
parentad6c39abdc35603f58e9b71993b5632c976deac1 (diff)
feat(subject): add pr/issue set-subject via NIP-32 kind-1985 labels
Adds the ability to update the displayed title of a PR or issue after creation using a kind-1985 label event with the #subject namespace. Only the author or a repository maintainer may set the subject. The latest authorised event wins with tiebreak by lexicographically larger event ID (NIP-1 replaceable event semantics). Branch names and commit messages are never affected. - Split get_labels() into process_labels() (additive #t) and process_subject() (replaceable-style #subject), with a shared get_labels_and_subject() entry point that processes both from a single pre-fetched slice of kind-1985 events - All list/view/JSON display paths apply the subject override silently - New ngit pr set-subject <id> --subject <text> command - New ngit issue set-subject <id> --subject <text> command
-rw-r--r--CHANGELOG.md2
-rw-r--r--skills/ngit/SKILL.md2
-rw-r--r--src/bin/ngit/cli.rs26
-rw-r--r--src/bin/ngit/main.rs14
-rw-r--r--src/bin/ngit/sub_commands/issue_list.rs37
-rw-r--r--src/bin/ngit/sub_commands/list.rs68
-rw-r--r--src/bin/ngit/sub_commands/mod.rs1
-rw-r--r--src/bin/ngit/sub_commands/set_subject.rs203
-rw-r--r--src/lib/git_events.rs133
9 files changed, 431 insertions, 55 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2d5617..a209922 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
12- NIP-32 label support: kind-1985 label events are now fetched alongside status events and merged with inline `t` tags to compute the effective label set for issues and PRs; only labels authored by the issue/PR author or a repository maintainer are applied; label counts appear in the fetch progress report 12- NIP-32 label support: kind-1985 label events are now fetched alongside status events and merged with inline `t` tags to compute the effective label set for issues and PRs; only labels authored by the issue/PR author or a repository maintainer are applied; label counts appear in the fetch progress report
13- `ngit issue label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing issue (author or maintainer only); publishes a kind-1985 event; duplicate labels already present via `t` tags or prior kind-1985 events are silently skipped; the new event is saved to the local cache before broadcasting so subsequent reads reflect the change immediately 13- `ngit issue label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing issue (author or maintainer only); publishes a kind-1985 event; duplicate labels already present via `t` tags or prior kind-1985 events are silently skipped; the new event is saved to the local cache before broadcasting so subsequent reads reflect the change immediately
14- `ngit pr label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing PR (author or maintainer only); publishes a kind-1985 event; duplicate labels already present via `t` tags or prior kind-1985 events are silently skipped; the new event is saved to the local cache before broadcasting so subsequent reads reflect the change immediately 14- `ngit pr label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing PR (author or maintainer only); publishes a kind-1985 event; duplicate labels already present via `t` tags or prior kind-1985 events are silently skipped; the new event is saved to the local cache before broadcasting so subsequent reads reflect the change immediately
15- `ngit pr set-subject <id> --subject <text>` — update the displayed title of a PR via a NIP-32 kind-1985 `#subject` label event (author or maintainer only); the latest authorised subject takes precedence with tiebreak by event ID; does not affect the underlying git commit messages or branch names
16- `ngit issue set-subject <id> --subject <text>` — update the displayed title of an issue (author or maintainer only); same semantics as PR set-subject
15- `ngit account whoami` — show the currently logged-in account(s) 17- `ngit account whoami` — show the currently logged-in account(s)
16- `ngit pr` subcommand group: `list`, `view`, `checkout`, `apply`, `send`, `close`, `reopen`, `ready`, `draft`, `comment`, `merge`; replaces the former top-level `ngit list`, `ngit checkout`, and `ngit apply` commands (hard-migrated); `ngit send` remains at the top level unchanged 18- `ngit pr` subcommand group: `list`, `view`, `checkout`, `apply`, `send`, `close`, `reopen`, `ready`, `draft`, `comment`, `merge`; replaces the former top-level `ngit list`, `ngit checkout`, and `ngit apply` commands (hard-migrated); `ngit send` remains at the top level unchanged
17- `ngit pr view <id>` — view a PR with its full details and all comments (author, timestamp, body) in chronological order 19- `ngit pr view <id>` — view a PR with its full details and all comments (author, timestamp, body) in chronological order
diff --git a/skills/ngit/SKILL.md b/skills/ngit/SKILL.md
index c316c1e..272f9ba 100644
--- a/skills/ngit/SKILL.md
+++ b/skills/ngit/SKILL.md
@@ -124,6 +124,7 @@ ngit pr reopen <ID|nevent> --reason "fix was incomplete"
124ngit pr ready <ID|nevent> --reason "addressed review feedback" 124ngit pr ready <ID|nevent> --reason "addressed review feedback"
125ngit pr draft <ID|nevent> --reason "needs more work" 125ngit pr draft <ID|nevent> --reason "needs more work"
126ngit pr label <ID|nevent> --label bug --label enhancement 126ngit pr label <ID|nevent> --label bug --label enhancement
127ngit pr set-subject <ID|nevent> --subject "New title"
127``` 128```
128 129
129## Issues 130## Issues
@@ -142,6 +143,7 @@ ngit issue close <ID|nevent> --reason "wontfix"
142ngit issue resolved <ID|nevent> --reason "fixed in abc123" 143ngit issue resolved <ID|nevent> --reason "fixed in abc123"
143ngit issue reopen <ID|nevent> --reason "regression in v2.3" 144ngit issue reopen <ID|nevent> --reason "regression in v2.3"
144ngit issue label <ID|nevent> --label bug --label enhancement 145ngit issue label <ID|nevent> --label bug --label enhancement
146ngit issue set-subject <ID|nevent> --subject "New title"
145``` 147```
146 148
147## Account management 149## Account management
diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs
index a240597..8cdbee1 100644
--- a/src/bin/ngit/cli.rs
+++ b/src/bin/ngit/cli.rs
@@ -334,6 +334,19 @@ pub enum PrCommands {
334 #[arg(long)] 334 #[arg(long)]
335 offline: bool, 335 offline: bool,
336 }, 336 },
337 /// set the subject/title of a PR (author or maintainer only)
338 #[command(name = "set-subject")]
339 SetSubject {
340 /// Proposal event-id (hex) or nevent (bech32)
341 #[arg(value_name = "ID|nevent")]
342 id: String,
343 /// New subject/title for the PR
344 #[arg(long, alias = "title")]
345 subject: String,
346 /// Use local cache only, skip network fetch
347 #[arg(long)]
348 offline: bool,
349 },
337} 350}
338 351
339// --------------------------------------------------------------------------- 352// ---------------------------------------------------------------------------
@@ -461,6 +474,19 @@ pub enum IssueCommands {
461 #[arg(long)] 474 #[arg(long)]
462 offline: bool, 475 offline: bool,
463 }, 476 },
477 /// set the subject/title of an issue (author or maintainer only)
478 #[command(name = "set-subject")]
479 SetSubject {
480 /// Issue event-id (hex) or nevent (bech32)
481 #[arg(value_name = "ID|nevent")]
482 id: String,
483 /// New subject/title for the issue
484 #[arg(long, alias = "title")]
485 subject: String,
486 /// Use local cache only, skip network fetch
487 #[arg(long)]
488 offline: bool,
489 },
464} 490}
465 491
466#[derive(Subcommand)] 492#[derive(Subcommand)]
diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs
index 3686011..1dbf020 100644
--- a/src/bin/ngit/main.rs
+++ b/src/bin/ngit/main.rs
@@ -3,7 +3,7 @@
3#![cfg_attr(not(test), warn(clippy::expect_used))] 3#![cfg_attr(not(test), warn(clippy::expect_used))]
4 4
5use clap::Parser; 5use clap::Parser;
6use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands, IssueCommands, PrCommands}; 6use cli::{AccountCommands, Cli, Commands, IssueCommands, PrCommands, CUSTOMISE_TEMPLATE};
7 7
8mod cli; 8mod cli;
9use ngit::{ 9use ngit::{
@@ -138,6 +138,11 @@ async fn main() {
138 labels, 138 labels,
139 offline, 139 offline,
140 } => sub_commands::label::launch_pr_label(id, labels, *offline).await, 140 } => sub_commands::label::launch_pr_label(id, labels, *offline).await,
141 PrCommands::SetSubject {
142 id,
143 subject,
144 offline,
145 } => sub_commands::set_subject::launch_pr_set_subject(id, subject, *offline).await,
141 }, 146 },
142 Commands::Issue(args) => match &args.issue_command { 147 Commands::Issue(args) => match &args.issue_command {
143 IssueCommands::List { 148 IssueCommands::List {
@@ -210,6 +215,13 @@ async fn main() {
210 labels, 215 labels,
211 offline, 216 offline,
212 } => sub_commands::label::launch_issue_label(id, labels, *offline).await, 217 } => sub_commands::label::launch_issue_label(id, labels, *offline).await,
218 IssueCommands::SetSubject {
219 id,
220 subject,
221 offline,
222 } => {
223 sub_commands::set_subject::launch_issue_set_subject(id, subject, *offline).await
224 }
213 }, 225 },
214 Commands::Sync(args) => sub_commands::sync::launch(args).await, 226 Commands::Sync(args) => sub_commands::sync::launch(args).await,
215 } 227 }
diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs
index 22b1b8a..8c7a7fc 100644
--- a/src/bin/ngit/sub_commands/issue_list.rs
+++ b/src/bin/ngit/sub_commands/issue_list.rs
@@ -3,7 +3,7 @@ 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, get_status, status_kinds, tag_value}, 6 git_events::{KIND_COMMENT, KIND_LABEL, get_labels_and_subject, get_status, status_kinds, tag_value},
7}; 7};
8use nostr::{ 8use nostr::{
9 FromBech32, ToBech32, 9 FromBech32, ToBech32,
@@ -18,7 +18,13 @@ use crate::{
18 repo_ref::get_repo_coordinates_when_remote_unknown, 18 repo_ref::get_repo_coordinates_when_remote_unknown,
19}; 19};
20 20
21fn get_issue_title(event: &nostr::Event) -> String { 21/// `(event, status_kind, labels, comment_count, subject_override)`
22type IssueRow<'a> = (&'a nostr::Event, Kind, Vec<String>, usize, Option<String>);
23
24fn get_issue_title(event: &nostr::Event, subject_override: Option<&str>) -> String {
25 if let Some(s) = subject_override {
26 return s.to_string();
27 }
22 tag_value(event, "subject") 28 tag_value(event, "subject")
23 .ok() 29 .ok()
24 .filter(|s| !s.is_empty()) 30 .filter(|s| !s.is_empty())
@@ -196,7 +202,7 @@ pub async fn launch(
196 // revisions, so we pass an empty slice. 202 // revisions, so we pass an empty slice.
197 let empty_proposals: Vec<nostr::Event> = vec![]; 203 let empty_proposals: Vec<nostr::Event> = vec![];
198 204
199 let filtered: Vec<(&nostr::Event, Kind, Vec<String>, usize)> = issues 205 let filtered: Vec<IssueRow<'_>> = issues
200 .iter() 206 .iter()
201 .filter_map(|issue| { 207 .filter_map(|issue| {
202 let status_kind = get_status(issue, &repo_ref, &statuses, &empty_proposals); 208 let status_kind = get_status(issue, &repo_ref, &statuses, &empty_proposals);
@@ -204,7 +210,8 @@ pub async fn launch(
204 if !status_filter.contains(status_str) && !status_filter.contains("unknown") { 210 if !status_filter.contains(status_str) && !status_filter.contains("unknown") {
205 return None; 211 return None;
206 } 212 }
207 let issue_labels = get_labels(issue, &repo_ref, &label_events); 213 let (issue_labels, subject_override) =
214 get_labels_and_subject(issue, &repo_ref, &label_events);
208 if !label_filter.is_empty() { 215 if !label_filter.is_empty() {
209 let issue_labels_lower: HashSet<String> = 216 let issue_labels_lower: HashSet<String> =
210 issue_labels.iter().map(|t| t.to_lowercase()).collect(); 217 issue_labels.iter().map(|t| t.to_lowercase()).collect();
@@ -213,7 +220,7 @@ pub async fn launch(
213 } 220 }
214 } 221 }
215 let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0); 222 let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0);
216 Some((issue, status_kind, issue_labels, comment_count)) 223 Some((issue, status_kind, issue_labels, comment_count, subject_override))
217 }) 224 })
218 .collect(); 225 .collect();
219 226
@@ -286,7 +293,7 @@ fn comment_reply_to(comment: &nostr::Event) -> Option<nostr::EventId> {
286} 293}
287 294
288fn show_issue_details( 295fn show_issue_details(
289 issues: &[(&nostr::Event, Kind, Vec<String>, usize)], 296 issues: &[IssueRow<'_>],
290 event_id_or_nevent: &str, 297 event_id_or_nevent: &str,
291 json: bool, 298 json: bool,
292 show_comments: bool, 299 show_comments: bool,
@@ -303,12 +310,12 @@ fn show_issue_details(
303 nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? 310 nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")?
304 }; 311 };
305 312
306 let (issue, status_kind, labels, comment_count) = issues 313 let (issue, status_kind, labels, comment_count, subject_override) = issues
307 .iter() 314 .iter()
308 .find(|(e, _, _, _)| e.id == target_id) 315 .find(|(e, _, _, _, _)| e.id == target_id)
309 .context("issue not found")?; 316 .context("issue not found")?;
310 317
311 let title = get_issue_title(issue); 318 let title = get_issue_title(issue, subject_override.as_deref());
312 let status = status_kind_to_str(*status_kind); 319 let status = status_kind_to_str(*status_kind);
313 320
314 if json { 321 if json {
@@ -421,15 +428,15 @@ fn chrono_timestamp(unix_secs: u64) -> String {
421} 428}
422 429
423fn output_table( 430fn output_table(
424 issues: &[(&nostr::Event, Kind, Vec<String>, usize)], 431 issues: &[IssueRow<'_>],
425 status_filter: &str, 432 status_filter: &str,
426 label_filter: &HashSet<String>, 433 label_filter: &HashSet<String>,
427) { 434) {
428 println!("{:<66} {:<8} {:<5} TITLE LABELS", "ID", "STATUS", "CMTS"); 435 println!("{:<66} {:<8} {:<5} TITLE LABELS", "ID", "STATUS", "CMTS");
429 for (issue, status_kind, labels, comment_count) in issues { 436 for (issue, status_kind, labels, comment_count, subject_override) in issues {
430 let id = issue.id.to_string(); 437 let id = issue.id.to_string();
431 let status = status_kind_to_str(*status_kind); 438 let status = status_kind_to_str(*status_kind);
432 let title = get_issue_title(issue); 439 let title = get_issue_title(issue, subject_override.as_deref());
433 let labels_str = if labels.is_empty() { 440 let labels_str = if labels.is_empty() {
434 String::new() 441 String::new()
435 } else { 442 } else {
@@ -456,14 +463,14 @@ fn output_table(
456 println!(); 463 println!();
457} 464}
458 465
459fn output_json(issues: &[(&nostr::Event, Kind, Vec<String>, usize)]) -> Result<()> { 466fn output_json(issues: &[IssueRow<'_>]) -> Result<()> {
460 let json_output: Vec<serde_json::Value> = issues 467 let json_output: Vec<serde_json::Value> = issues
461 .iter() 468 .iter()
462 .map(|(issue, status_kind, labels, comment_count)| { 469 .map(|(issue, status_kind, labels, comment_count, subject_override)| {
463 serde_json::json!({ 470 serde_json::json!({
464 "id": issue.id.to_string(), 471 "id": issue.id.to_string(),
465 "status": status_kind_to_str(*status_kind), 472 "status": status_kind_to_str(*status_kind),
466 "title": get_issue_title(issue), 473 "title": get_issue_title(issue, subject_override.as_deref()),
467 "author": issue.pubkey.to_bech32().unwrap_or_default(), 474 "author": issue.pubkey.to_bech32().unwrap_or_default(),
468 "labels": labels, 475 "labels": labels,
469 "comment_count": comment_count, 476 "comment_count": comment_count,
diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs
index 404b25e..ab4f0f7 100644
--- a/src/bin/ngit/sub_commands/list.rs
+++ b/src/bin/ngit/sub_commands/list.rs
@@ -16,7 +16,7 @@ use ngit::{
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_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE,
19 get_commit_id_from_patch, get_labels, 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, status_kinds, tag_value,
21 }, 21 },
22 repo_ref::{RepoRef, is_grasp_server_in_list}, 22 repo_ref::{RepoRef, is_grasp_server_in_list},
@@ -203,7 +203,7 @@ pub async fn launch(
203 // OR filter: proposal must have at least one of the requested labels. 203 // OR filter: proposal must have at least one of the requested labels.
204 let label_filter: HashSet<String> = labels.iter().map(|l| l.trim().to_lowercase()).collect(); 204 let label_filter: HashSet<String> = labels.iter().map(|l| l.trim().to_lowercase()).collect();
205 205
206 let filtered_proposals: Vec<(&nostr::Event, Kind, Vec<String>)> = proposals 206 let filtered_proposals: Vec<(&nostr::Event, Kind, Vec<String>, Option<String>)> = proposals
207 .iter() 207 .iter()
208 .filter_map(|p| { 208 .filter_map(|p| {
209 let status_kind = get_status(p, &repo_ref, &statuses, &proposals); 209 let status_kind = get_status(p, &repo_ref, &statuses, &proposals);
@@ -217,7 +217,8 @@ pub async fn launch(
217 if !status_filter.contains(status_str) && !status_filter.contains("unknown") { 217 if !status_filter.contains(status_str) && !status_filter.contains("unknown") {
218 return None; 218 return None;
219 } 219 }
220 let proposal_labels = get_labels(p, &repo_ref, &label_events); 220 let (proposal_labels, subject_override) =
221 get_labels_and_subject(p, &repo_ref, &label_events);
221 if !label_filter.is_empty() { 222 if !label_filter.is_empty() {
222 let proposal_labels_lower: HashSet<String> = 223 let proposal_labels_lower: HashSet<String> =
223 proposal_labels.iter().map(|l| l.to_lowercase()).collect(); 224 proposal_labels.iter().map(|l| l.to_lowercase()).collect();
@@ -225,7 +226,7 @@ pub async fn launch(
225 return None; 226 return None;
226 } 227 }
227 } 228 }
228 Some((p, status_kind, proposal_labels)) 229 Some((p, status_kind, proposal_labels, subject_override))
229 }) 230 })
230 .collect(); 231 .collect();
231 232
@@ -321,8 +322,21 @@ fn status_kind_to_str(kind: Kind) -> &'static str {
321 } 322 }
322} 323}
323 324
325fn proposal_title(proposal: &nostr::Event, subject_override: Option<&str>) -> String {
326 if let Some(s) = subject_override {
327 return s.to_string();
328 }
329 if let Ok(cl) = event_to_cover_letter(proposal) {
330 cl.title
331 } else if let Ok(msg) = tag_value(proposal, "description") {
332 msg.split('\n').collect::<Vec<&str>>()[0].to_string()
333 } else {
334 proposal.id.to_string()
335 }
336}
337
324fn output_table( 338fn output_table(
325 proposals: &[(&nostr::Event, Kind, Vec<String>)], 339 proposals: &[(&nostr::Event, Kind, Vec<String>, Option<String>)],
326 status_filter: &str, 340 status_filter: &str,
327 label_filter: &HashSet<String>, 341 label_filter: &HashSet<String>,
328) { 342) {
@@ -332,16 +346,10 @@ fn output_table(
332 } 346 }
333 347
334 println!("{:<66} {:<8} TITLE LABELS", "ID", "STATUS"); 348 println!("{:<66} {:<8} TITLE LABELS", "ID", "STATUS");
335 for (proposal, status_kind, proposal_labels) in proposals { 349 for (proposal, status_kind, proposal_labels, subject_override) in proposals {
336 let id = proposal.id.to_string(); 350 let id = proposal.id.to_string();
337 let status = status_kind_to_str(*status_kind); 351 let status = status_kind_to_str(*status_kind);
338 let title = if let Ok(cl) = event_to_cover_letter(proposal) { 352 let title = proposal_title(proposal, subject_override.as_deref());
339 cl.title
340 } else if let Ok(msg) = tag_value(proposal, "description") {
341 msg.split('\n').collect::<Vec<&str>>()[0].to_string()
342 } else {
343 proposal.id.to_string()
344 };
345 let labels_str: String = proposal_labels 353 let labels_str: String = proposal_labels
346 .iter() 354 .iter()
347 .map(|l| format!("#{l}")) 355 .map(|l| format!("#{l}"))
@@ -376,24 +384,26 @@ fn output_table(
376 ); 384 );
377} 385}
378 386
379fn output_json(proposals: &[(&nostr::Event, Kind, Vec<String>)]) -> Result<()> { 387fn output_json(proposals: &[(&nostr::Event, Kind, Vec<String>, Option<String>)]) -> Result<()> {
380 let json_output: Vec<serde_json::Value> = proposals 388 let json_output: Vec<serde_json::Value> = proposals
381 .iter() 389 .iter()
382 .map(|(proposal, status_kind, proposal_labels)| { 390 .map(|(proposal, status_kind, proposal_labels, subject_override)| {
383 let id = proposal.id.to_string(); 391 let id = proposal.id.to_string();
384 let status = status_kind_to_str(*status_kind).to_string(); 392 let status = status_kind_to_str(*status_kind).to_string();
385 let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) { 393 let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) {
386 ( 394 (
387 cl.title.clone(), 395 subject_override.clone().unwrap_or(cl.title.clone()),
388 proposal.pubkey.to_bech32().unwrap_or_default(), 396 proposal.pubkey.to_bech32().unwrap_or_default(),
389 cl.get_branch_name_with_pr_prefix_and_shorthand_id() 397 cl.get_branch_name_with_pr_prefix_and_shorthand_id()
390 .unwrap_or_default(), 398 .unwrap_or_default(),
391 ) 399 )
392 } else { 400 } else {
393 let title = tag_value(proposal, "description").map_or_else( 401 let title = subject_override.clone().unwrap_or_else(|| {
394 |_| proposal.id.to_string(), 402 tag_value(proposal, "description").map_or_else(
395 |d| d.split('\n').collect::<Vec<&str>>()[0].to_string(), 403 |_| proposal.id.to_string(),
396 ); 404 |d| d.split('\n').collect::<Vec<&str>>()[0].to_string(),
405 )
406 });
397 ( 407 (
398 title, 408 title,
399 proposal.pubkey.to_bech32().unwrap_or_default(), 409 proposal.pubkey.to_bech32().unwrap_or_default(),
@@ -444,7 +454,7 @@ fn comment_reply_to(comment: &nostr::Event) -> Option<nostr::EventId> {
444 454
445#[allow(clippy::too_many_lines)] 455#[allow(clippy::too_many_lines)]
446fn show_proposal_details( 456fn show_proposal_details(
447 proposals: &[(&nostr::Event, Kind, Vec<String>)], 457 proposals: &[(&nostr::Event, Kind, Vec<String>, Option<String>)],
448 event_id_or_nevent: &str, 458 event_id_or_nevent: &str,
449 json: bool, 459 json: bool,
450 show_comments: bool, 460 show_comments: bool,
@@ -455,14 +465,20 @@ fn show_proposal_details(
455 465
456 let target_id = resolve_event_id(event_id_or_nevent)?; 466 let target_id = resolve_event_id(event_id_or_nevent)?;
457 467
458 let (proposal, status_kind, proposal_labels) = proposals 468 let (proposal, status_kind, proposal_labels, subject_override) = proposals
459 .iter() 469 .iter()
460 .find(|(p, _, _)| p.id == target_id) 470 .find(|(p, _, _, _)| p.id == target_id)
461 .context("proposal not found")?; 471 .context("proposal not found")?;
462 472
463 let cover_letter = event_to_cover_letter(proposal) 473 let cover_letter = event_to_cover_letter(proposal)
464 .context("failed to extract proposal details from proposal root event")?; 474 .context("failed to extract proposal details from proposal root event")?;
465 475
476 // Use subject override if present, otherwise fall back to the original title.
477 let display_title = subject_override
478 .as_deref()
479 .unwrap_or(&cover_letter.title)
480 .to_string();
481
466 if json { 482 if json {
467 let json_output = if show_comments { 483 let json_output = if show_comments {
468 let comments_json: Vec<serde_json::Value> = comments 484 let comments_json: Vec<serde_json::Value> = comments
@@ -481,7 +497,7 @@ fn show_proposal_details(
481 serde_json::json!({ 497 serde_json::json!({
482 "id": proposal.id.to_string(), 498 "id": proposal.id.to_string(),
483 "status": status_kind_to_str(*status_kind), 499 "status": status_kind_to_str(*status_kind),
484 "title": cover_letter.title, 500 "title": display_title,
485 "author": proposal.pubkey.to_bech32().unwrap_or_default(), 501 "author": proposal.pubkey.to_bech32().unwrap_or_default(),
486 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 502 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
487 "labels": proposal_labels, 503 "labels": proposal_labels,
@@ -493,7 +509,7 @@ fn show_proposal_details(
493 serde_json::json!({ 509 serde_json::json!({
494 "id": proposal.id.to_string(), 510 "id": proposal.id.to_string(),
495 "status": status_kind_to_str(*status_kind), 511 "status": status_kind_to_str(*status_kind),
496 "title": cover_letter.title, 512 "title": display_title,
497 "author": proposal.pubkey.to_bech32().unwrap_or_default(), 513 "author": proposal.pubkey.to_bech32().unwrap_or_default(),
498 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 514 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
499 "labels": proposal_labels, 515 "labels": proposal_labels,
@@ -505,7 +521,7 @@ fn show_proposal_details(
505 return Ok(()); 521 return Ok(());
506 } 522 }
507 523
508 println!("Title: {}", cover_letter.title); 524 println!("Title: {display_title}");
509 println!( 525 println!(
510 "Author: {}", 526 "Author: {}",
511 proposal.pubkey.to_bech32().unwrap_or_default() 527 proposal.pubkey.to_bech32().unwrap_or_default()
diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs
index 7a3c2b5..db8ea54 100644
--- a/src/bin/ngit/sub_commands/mod.rs
+++ b/src/bin/ngit/sub_commands/mod.rs
@@ -15,5 +15,6 @@ 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_subject;
18pub mod sync; 19pub mod sync;
19pub mod whoami; 20pub mod whoami;
diff --git a/src/bin/ngit/sub_commands/set_subject.rs b/src/bin/ngit/sub_commands/set_subject.rs
new file mode 100644
index 0000000..65ff1d3
--- /dev/null
+++ b/src/bin/ngit/sub_commands/set_subject.rs
@@ -0,0 +1,203 @@
1use anyhow::{Context, Result, bail};
2use ngit::{
3 client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events},
4 git_events::{KIND_LABEL, get_labels_and_subject},
5};
6use nostr::{EventBuilder, Tag, TagStandard};
7use nostr_sdk::{EventId, FromBech32};
8use nostr::nips::nip19::Nip19;
9
10use crate::{
11 client::{
12 Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache,
13 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 NIP-32 kind-1985 `#subject` label event
35/// for `target`, overriding its displayed title/subject.
36///
37/// Only the author of the target event or a repository maintainer may set the
38/// subject. The subject is not applied to the underlying git commit message —
39/// it only affects how the PR/issue title is displayed.
40#[allow(clippy::too_many_lines)]
41async fn publish_set_subject_event(
42 id: &str,
43 subject: &str,
44 offline: bool,
45 target_kind: &str, // "issue" or "PR" — used in error messages
46) -> Result<()> {
47 let subject = subject.trim();
48 if subject.is_empty() {
49 bail!("--subject value must not be empty");
50 }
51
52 let event_id = parse_event_id(id)?;
53
54 let git_repo = Repo::discover().context("failed to find a git repository")?;
55 let git_repo_path = git_repo.get_path()?;
56
57 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
58 let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?;
59
60 if !offline {
61 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
62 }
63
64 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
65
66 // Resolve the target event from cache.
67 let target = if target_kind == "issue" {
68 let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?;
69 issues
70 .into_iter()
71 .find(|e| e.id == event_id)
72 .context(format!(
73 "issue with id {} not found in cache",
74 event_id.to_hex()
75 ))?
76 } else {
77 let proposals =
78 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?;
79 proposals
80 .into_iter()
81 .find(|e| e.id == event_id)
82 .context(format!(
83 "PR with id {} not found in cache",
84 event_id.to_hex()
85 ))?
86 };
87
88 // Login — we need the signer and user pubkey.
89 let (signer, user_ref, _) =
90 login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?;
91
92 let user_pubkey = signer.get_public_key().await?;
93
94 // Permission check: only the author or a maintainer may set the subject.
95 if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) {
96 bail!(
97 "only the {target_kind} author or a repository maintainer can set the subject of a {target_kind}"
98 );
99 }
100
101 // Fetch existing label events so we can check whether the subject is
102 // already set to the requested value.
103 let existing_label_events = get_events_from_local_cache(
104 git_repo_path,
105 vec![
106 nostr::Filter::default()
107 .event(event_id)
108 .kind(KIND_LABEL),
109 ],
110 )
111 .await?;
112
113 let (_, existing_subject) = get_labels_and_subject(&target, &repo_ref, &existing_label_events);
114
115 if existing_subject.as_deref() == Some(subject) {
116 println!(
117 "{target_kind} {} already has subject: {}",
118 &event_id.to_hex()[..8],
119 subject,
120 );
121 return Ok(());
122 }
123
124 // Build the kind-1985 subject label event.
125 //
126 // Structure (NIP-32 §subject namespace):
127 // ["L", "#subject"] — namespace declaration
128 // ["l", "<new title>", "#subject"] — the new subject value
129 // ["e", <target-id>, <relay>] — reference to the labelled event
130 // ["p", <author-pubkey>] — notify the author
131 let relay_hint = repo_ref.relays.first().cloned();
132
133 let mut tags: Vec<Tag> = vec![
134 // Namespace declaration
135 Tag::custom(
136 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("L")),
137 vec!["#subject".to_string()],
138 ),
139 // Subject value
140 Tag::custom(
141 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("l")),
142 vec![subject.to_string(), "#subject".to_string()],
143 ),
144 ];
145
146 // Reference the target event.
147 tags.push(Tag::from_standardized(TagStandard::Event {
148 event_id: target.id,
149 relay_url: relay_hint.clone(),
150 marker: None,
151 public_key: None,
152 uppercase: false,
153 }));
154
155 // Notify the target event author.
156 tags.push(Tag::public_key(target.pubkey));
157
158 // Human-readable alt text.
159 tags.push(Tag::custom(
160 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
161 vec![format!("set {target_kind} subject to: {subject}")],
162 ));
163
164 let subject_event = ngit::client::sign_event(
165 EventBuilder::new(KIND_LABEL, "").tags(tags),
166 &signer,
167 format!("set {target_kind} subject"),
168 )
169 .await?;
170
171 // Save to local cache immediately so subsequent reads reflect the new subject.
172 save_event_in_local_cache(git_repo_path, &subject_event).await?;
173
174 let mut client = client;
175 client.set_signer(signer).await;
176
177 send_events(
178 &client,
179 Some(git_repo_path),
180 vec![subject_event],
181 user_ref.relays.write(),
182 repo_ref.relays.clone(),
183 true,
184 false,
185 )
186 .await?;
187
188 println!(
189 "{} {} subject set to: {}",
190 target_kind,
191 &event_id.to_hex()[..8],
192 subject,
193 );
194 Ok(())
195}
196
197pub async fn launch_issue_set_subject(id: &str, subject: &str, offline: bool) -> Result<()> {
198 publish_set_subject_event(id, subject, offline, "issue").await
199}
200
201pub async fn launch_pr_set_subject(id: &str, subject: &str, offline: bool) -> Result<()> {
202 publish_set_subject_event(id, subject, offline, "PR").await
203}
diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs
index a5aef12..b512e44 100644
--- a/src/lib/git_events.rs
+++ b/src/lib/git_events.rs
@@ -978,30 +978,31 @@ pub fn is_event_proposal_root_for_branch(
978 )) 978 ))
979} 979}
980 980
981/// Compute the effective set of labels for `event`. 981/// Process hashtag labels (`#t` namespace) from a pre-fetched set of kind-1985
982/// events.
982/// 983///
983/// Labels come from two sources, both subject to the same permission check: 984/// Labels come from two sources, both subject to the same permission check:
984/// 985///
985/// 1. `t` tags on the event itself (self-reported by the event author). 986/// 1. `t` tags on the event itself (self-reported by the event author).
986/// 2. NIP-32 kind-1985 label events in `all_label_events` that reference 987/// 2. NIP-32 kind-1985 label events in `label_events` that reference `event`
987/// `event` via a lowercase `e` tag and carry `["L", "#t"]` + 988/// via a lowercase `e` tag and carry `["L", "#t"]` +
988/// `["l", "<value>", "#t"]` tags. 989/// `["l", "<value>", "#t"]` tags.
989/// 990///
990/// A label is only applied when the author of the source event (the original 991/// A label is only applied when the author of the source event is either the
991/// event for inline `t` tags, or the kind-1985 event for external labels) is 992/// author of `event` itself or one of the repository maintainers.
992/// either the author of `event` itself or one of the repository maintainers. 993///
993pub fn get_labels( 994/// Labels are additive — all valid label events contribute; there is no
995/// "latest wins" replacement semantics.
996pub fn process_labels(
994 event: &Event, 997 event: &Event,
995 repo_ref: &RepoRef, 998 repo_ref: &RepoRef,
996 all_label_events: &[Event], 999 label_events: &[Event],
997) -> Vec<String> { 1000) -> Vec<String> {
998 let is_permitted = |pubkey: &PublicKey| -> bool { 1001 let is_permitted = |pubkey: &PublicKey| -> bool {
999 pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) 1002 pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey)
1000 }; 1003 };
1001 1004
1002 // 1. Inline `t` tags on the event itself — only if the event author is 1005 // 1. Inline `t` tags on the event itself.
1003 // permitted (they always are, since they authored the event, but we
1004 // keep the check symmetric with the external-label path).
1005 let mut labels: Vec<String> = if is_permitted(&event.pubkey) { 1006 let mut labels: Vec<String> = if is_permitted(&event.pubkey) {
1006 event 1007 event
1007 .tags 1008 .tags
@@ -1016,7 +1017,7 @@ pub fn get_labels(
1016 vec![] 1017 vec![]
1017 }; 1018 };
1018 1019
1019 // 2. External NIP-32 kind-1985 label events. 1020 // 2. External NIP-32 kind-1985 label events (`#t` namespace).
1020 // 1021 //
1021 // A valid label event must: 1022 // A valid label event must:
1022 // - be kind 1985 1023 // - be kind 1985
@@ -1025,7 +1026,7 @@ pub fn get_labels(
1025 // - have at least one `["l", "<value>", "#t"]` tag 1026 // - have at least one `["l", "<value>", "#t"]` tag
1026 // - be authored by a permitted pubkey 1027 // - be authored by a permitted pubkey
1027 let event_id_str = event.id.to_string(); 1028 let event_id_str = event.id.to_string();
1028 for label_event in all_label_events { 1029 for label_event in label_events {
1029 if !label_event.kind.eq(&KIND_LABEL) { 1030 if !label_event.kind.eq(&KIND_LABEL) {
1030 continue; 1031 continue;
1031 } 1032 }
@@ -1063,6 +1064,112 @@ pub fn get_labels(
1063 labels 1064 labels
1064} 1065}
1065 1066
1067/// Process the effective subject/title override for `event` from a pre-fetched
1068/// set of kind-1985 events.
1069///
1070/// Subject overrides use the `#subject` namespace:
1071/// `["L", "#subject"]` + `["l", "<new title>", "#subject"]`
1072///
1073/// Unlike hashtag labels, subject overrides are replaceable-style: only the
1074/// latest authorised event wins, with tiebreak by lexicographically larger
1075/// event ID (consistent with NIP-1 replaceable event semantics).
1076///
1077/// Only the author of `event` or a repository maintainer may set the subject.
1078/// Returns `None` when no valid subject override exists.
1079pub fn process_subject(
1080 event: &Event,
1081 repo_ref: &RepoRef,
1082 label_events: &[Event],
1083) -> Option<String> {
1084 let is_permitted = |pubkey: &PublicKey| -> bool {
1085 pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey)
1086 };
1087
1088 let event_id_str = event.id.to_string();
1089
1090 // Find the winning subject label event: latest created_at, tiebreak by
1091 // lexicographically larger event ID (NIP-1 replaceable event semantics).
1092 let winner = label_events
1093 .iter()
1094 .filter(|le| {
1095 if !le.kind.eq(&KIND_LABEL) {
1096 return false;
1097 }
1098 if !is_permitted(&le.pubkey) {
1099 return false;
1100 }
1101 // Must reference our event via a lowercase `e` tag.
1102 let references_event = le.tags.iter().any(|t| {
1103 let s = t.as_slice();
1104 s.len() >= 2 && s[0].eq("e") && s[1].eq(&event_id_str)
1105 });
1106 if !references_event {
1107 return false;
1108 }
1109 // Must declare the `#subject` namespace.
1110 let has_namespace = le.tags.iter().any(|t| {
1111 let s = t.as_slice();
1112 s.len() >= 2 && s[0].eq("L") && s[1].eq("#subject")
1113 });
1114 if !has_namespace {
1115 return false;
1116 }
1117 // Must have at least one non-empty `["l", "<value>", "#subject"]` tag.
1118 le.tags.iter().any(|t| {
1119 let s = t.as_slice();
1120 s.len() >= 3 && s[0].eq("l") && s[2].eq("#subject") && !s[1].is_empty()
1121 })
1122 })
1123 .max_by(|a, b| {
1124 // Primary: newer created_at wins.
1125 // Tiebreak: lexicographically larger event ID wins (NIP-1).
1126 a.created_at
1127 .cmp(&b.created_at)
1128 .then_with(|| a.id.to_string().cmp(&b.id.to_string()))
1129 })?;
1130
1131 // Extract the subject value from the winning event.
1132 winner.tags.iter().find_map(|t| {
1133 let s = t.as_slice();
1134 if s.len() >= 3 && s[0].eq("l") && s[2].eq("#subject") && !s[1].is_empty() {
1135 Some(s[1].clone())
1136 } else {
1137 None
1138 }
1139 })
1140}
1141
1142/// Compute both the effective hashtag labels and the subject/title override for
1143/// `event` from a pre-fetched set of kind-1985 events.
1144///
1145/// This is the primary entry point: callers should fetch label events once
1146/// (covering both `#t` and `#subject` namespaces) and pass them here to get
1147/// both results in a single pass.
1148///
1149/// Returns `(labels, subject_override)` where `subject_override` is `None`
1150/// when no authorised `#subject` label exists.
1151pub fn get_labels_and_subject(
1152 event: &Event,
1153 repo_ref: &RepoRef,
1154 label_events: &[Event],
1155) -> (Vec<String>, Option<String>) {
1156 (
1157 process_labels(event, repo_ref, label_events),
1158 process_subject(event, repo_ref, label_events),
1159 )
1160}
1161
1162/// Compatibility wrapper — returns only the hashtag labels.
1163///
1164/// Prefer [`get_labels_and_subject`] when the subject override is also needed.
1165pub fn get_labels(
1166 event: &Event,
1167 repo_ref: &RepoRef,
1168 label_events: &[Event],
1169) -> Vec<String> {
1170 process_labels(event, repo_ref, label_events)
1171}
1172
1066pub fn get_status( 1173pub fn get_status(
1067 proposal: &Event, 1174 proposal: &Event,
1068 repo_ref: &RepoRef, 1175 repo_ref: &RepoRef,