From f3fcf863aae000964753f574b00e9fb9f5fcd452 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 5 Mar 2026 13:03:50 +0000 Subject: 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 --subject command - New ngit issue set-subject --subject command --- src/bin/ngit/cli.rs | 26 ++++ src/bin/ngit/main.rs | 14 ++- src/bin/ngit/sub_commands/issue_list.rs | 37 +++--- src/bin/ngit/sub_commands/list.rs | 68 +++++++---- src/bin/ngit/sub_commands/mod.rs | 1 + src/bin/ngit/sub_commands/set_subject.rs | 203 +++++++++++++++++++++++++++++++ 6 files changed, 307 insertions(+), 42 deletions(-) create mode 100644 src/bin/ngit/sub_commands/set_subject.rs (limited to 'src/bin') 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 { #[arg(long)] offline: bool, }, + /// set the subject/title of a PR (author or maintainer only) + #[command(name = "set-subject")] + SetSubject { + /// Proposal event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// New subject/title for the PR + #[arg(long, alias = "title")] + subject: String, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, } // --------------------------------------------------------------------------- @@ -461,6 +474,19 @@ pub enum IssueCommands { #[arg(long)] offline: bool, }, + /// set the subject/title of an issue (author or maintainer only) + #[command(name = "set-subject")] + SetSubject { + /// Issue event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// New subject/title for the issue + #[arg(long, alias = "title")] + subject: 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 3686011..1dbf020 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, CUSTOMISE_TEMPLATE, Cli, Commands, IssueCommands, PrCommands}; +use cli::{AccountCommands, Cli, Commands, IssueCommands, PrCommands, CUSTOMISE_TEMPLATE}; mod cli; use ngit::{ @@ -138,6 +138,11 @@ async fn main() { labels, offline, } => sub_commands::label::launch_pr_label(id, labels, *offline).await, + PrCommands::SetSubject { + id, + subject, + offline, + } => sub_commands::set_subject::launch_pr_set_subject(id, subject, *offline).await, }, Commands::Issue(args) => match &args.issue_command { IssueCommands::List { @@ -210,6 +215,13 @@ async fn main() { labels, offline, } => sub_commands::label::launch_issue_label(id, labels, *offline).await, + IssueCommands::SetSubject { + id, + subject, + offline, + } => { + sub_commands::set_subject::launch_issue_set_subject(id, subject, *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 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}; 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, get_status, status_kinds, tag_value}, + git_events::{KIND_COMMENT, KIND_LABEL, get_labels_and_subject, get_status, status_kinds, tag_value}, }; use nostr::{ FromBech32, ToBech32, @@ -18,7 +18,13 @@ use crate::{ repo_ref::get_repo_coordinates_when_remote_unknown, }; -fn get_issue_title(event: &nostr::Event) -> String { +/// `(event, status_kind, labels, comment_count, subject_override)` +type IssueRow<'a> = (&'a nostr::Event, Kind, Vec, usize, Option); + +fn get_issue_title(event: &nostr::Event, subject_override: Option<&str>) -> String { + if let Some(s) = subject_override { + return s.to_string(); + } tag_value(event, "subject") .ok() .filter(|s| !s.is_empty()) @@ -196,7 +202,7 @@ pub async fn launch( // revisions, so we pass an empty slice. let empty_proposals: Vec = vec![]; - let filtered: Vec<(&nostr::Event, Kind, Vec, usize)> = issues + let filtered: Vec> = issues .iter() .filter_map(|issue| { let status_kind = get_status(issue, &repo_ref, &statuses, &empty_proposals); @@ -204,7 +210,8 @@ pub async fn launch( if !status_filter.contains(status_str) && !status_filter.contains("unknown") { return None; } - let issue_labels = get_labels(issue, &repo_ref, &label_events); + let (issue_labels, subject_override) = + get_labels_and_subject(issue, &repo_ref, &label_events); if !label_filter.is_empty() { let issue_labels_lower: HashSet = issue_labels.iter().map(|t| t.to_lowercase()).collect(); @@ -213,7 +220,7 @@ pub async fn launch( } } let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0); - Some((issue, status_kind, issue_labels, comment_count)) + Some((issue, status_kind, issue_labels, comment_count, subject_override)) }) .collect(); @@ -286,7 +293,7 @@ fn comment_reply_to(comment: &nostr::Event) -> Option { } fn show_issue_details( - issues: &[(&nostr::Event, Kind, Vec, usize)], + issues: &[IssueRow<'_>], event_id_or_nevent: &str, json: bool, show_comments: bool, @@ -303,12 +310,12 @@ fn show_issue_details( nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? }; - let (issue, status_kind, labels, comment_count) = issues + let (issue, status_kind, labels, comment_count, subject_override) = issues .iter() - .find(|(e, _, _, _)| e.id == target_id) + .find(|(e, _, _, _, _)| e.id == target_id) .context("issue not found")?; - let title = get_issue_title(issue); + let title = get_issue_title(issue, subject_override.as_deref()); let status = status_kind_to_str(*status_kind); if json { @@ -421,15 +428,15 @@ fn chrono_timestamp(unix_secs: u64) -> String { } fn output_table( - issues: &[(&nostr::Event, Kind, Vec, usize)], + issues: &[IssueRow<'_>], status_filter: &str, label_filter: &HashSet, ) { println!("{:<66} {:<8} {:<5} TITLE LABELS", "ID", "STATUS", "CMTS"); - for (issue, status_kind, labels, comment_count) in issues { + for (issue, status_kind, labels, comment_count, subject_override) in issues { let id = issue.id.to_string(); let status = status_kind_to_str(*status_kind); - let title = get_issue_title(issue); + let title = get_issue_title(issue, subject_override.as_deref()); let labels_str = if labels.is_empty() { String::new() } else { @@ -456,14 +463,14 @@ fn output_table( println!(); } -fn output_json(issues: &[(&nostr::Event, Kind, Vec, usize)]) -> Result<()> { +fn output_json(issues: &[IssueRow<'_>]) -> Result<()> { let json_output: Vec = issues .iter() - .map(|(issue, status_kind, labels, comment_count)| { + .map(|(issue, status_kind, labels, comment_count, subject_override)| { serde_json::json!({ "id": issue.id.to_string(), "status": status_kind_to_str(*status_kind), - "title": get_issue_title(issue), + "title": get_issue_title(issue, subject_override.as_deref()), "author": issue.pubkey.to_bech32().unwrap_or_default(), "labels": labels, "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::{ fetch::fetch_from_git_server, git_events::{ KIND_COMMENT, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, - get_commit_id_from_patch, get_labels, + 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, }, repo_ref::{RepoRef, is_grasp_server_in_list}, @@ -203,7 +203,7 @@ pub async fn launch( // OR filter: proposal must have at least one of the requested labels. let label_filter: HashSet = labels.iter().map(|l| l.trim().to_lowercase()).collect(); - let filtered_proposals: Vec<(&nostr::Event, Kind, Vec)> = proposals + let filtered_proposals: Vec<(&nostr::Event, Kind, Vec, Option)> = proposals .iter() .filter_map(|p| { let status_kind = get_status(p, &repo_ref, &statuses, &proposals); @@ -217,7 +217,8 @@ pub async fn launch( if !status_filter.contains(status_str) && !status_filter.contains("unknown") { return None; } - let proposal_labels = get_labels(p, &repo_ref, &label_events); + let (proposal_labels, subject_override) = + get_labels_and_subject(p, &repo_ref, &label_events); if !label_filter.is_empty() { let proposal_labels_lower: HashSet = proposal_labels.iter().map(|l| l.to_lowercase()).collect(); @@ -225,7 +226,7 @@ pub async fn launch( return None; } } - Some((p, status_kind, proposal_labels)) + Some((p, status_kind, proposal_labels, subject_override)) }) .collect(); @@ -321,8 +322,21 @@ fn status_kind_to_str(kind: Kind) -> &'static str { } } +fn proposal_title(proposal: &nostr::Event, subject_override: Option<&str>) -> String { + if let Some(s) = subject_override { + return s.to_string(); + } + if let Ok(cl) = event_to_cover_letter(proposal) { + cl.title + } else if let Ok(msg) = tag_value(proposal, "description") { + msg.split('\n').collect::>()[0].to_string() + } else { + proposal.id.to_string() + } +} + fn output_table( - proposals: &[(&nostr::Event, Kind, Vec)], + proposals: &[(&nostr::Event, Kind, Vec, Option)], status_filter: &str, label_filter: &HashSet, ) { @@ -332,16 +346,10 @@ fn output_table( } println!("{:<66} {:<8} TITLE LABELS", "ID", "STATUS"); - for (proposal, status_kind, proposal_labels) in proposals { + for (proposal, status_kind, proposal_labels, subject_override) in proposals { let id = proposal.id.to_string(); let status = status_kind_to_str(*status_kind); - let title = if let Ok(cl) = event_to_cover_letter(proposal) { - cl.title - } else if let Ok(msg) = tag_value(proposal, "description") { - msg.split('\n').collect::>()[0].to_string() - } else { - proposal.id.to_string() - }; + let title = proposal_title(proposal, subject_override.as_deref()); let labels_str: String = proposal_labels .iter() .map(|l| format!("#{l}")) @@ -376,24 +384,26 @@ fn output_table( ); } -fn output_json(proposals: &[(&nostr::Event, Kind, Vec)]) -> Result<()> { +fn output_json(proposals: &[(&nostr::Event, Kind, Vec, Option)]) -> Result<()> { let json_output: Vec = proposals .iter() - .map(|(proposal, status_kind, proposal_labels)| { + .map(|(proposal, status_kind, proposal_labels, subject_override)| { let id = proposal.id.to_string(); let status = status_kind_to_str(*status_kind).to_string(); let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) { ( - cl.title.clone(), + 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 = tag_value(proposal, "description").map_or_else( - |_| proposal.id.to_string(), - |d| d.split('\n').collect::>()[0].to_string(), - ); + 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(), @@ -444,7 +454,7 @@ fn comment_reply_to(comment: &nostr::Event) -> Option { #[allow(clippy::too_many_lines)] fn show_proposal_details( - proposals: &[(&nostr::Event, Kind, Vec)], + proposals: &[(&nostr::Event, Kind, Vec, Option)], event_id_or_nevent: &str, json: bool, show_comments: bool, @@ -455,14 +465,20 @@ fn show_proposal_details( let target_id = resolve_event_id(event_id_or_nevent)?; - let (proposal, status_kind, proposal_labels) = proposals + let (proposal, status_kind, proposal_labels, subject_override) = proposals .iter() - .find(|(p, _, _)| p.id == target_id) + .find(|(p, _, _, _)| p.id == target_id) .context("proposal not found")?; let cover_letter = event_to_cover_letter(proposal) .context("failed to extract proposal details from proposal root event")?; + // Use subject override if present, otherwise fall back to the original title. + let display_title = subject_override + .as_deref() + .unwrap_or(&cover_letter.title) + .to_string(); + if json { let json_output = if show_comments { let comments_json: Vec = comments @@ -481,7 +497,7 @@ fn show_proposal_details( serde_json::json!({ "id": proposal.id.to_string(), "status": status_kind_to_str(*status_kind), - "title": cover_letter.title, + "title": 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, @@ -493,7 +509,7 @@ fn show_proposal_details( serde_json::json!({ "id": proposal.id.to_string(), "status": status_kind_to_str(*status_kind), - "title": cover_letter.title, + "title": 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, @@ -505,7 +521,7 @@ fn show_proposal_details( return Ok(()); } - println!("Title: {}", cover_letter.title); + println!("Title: {display_title}"); println!( "Author: {}", 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; pub mod pr_status; pub mod repo; pub mod send; +pub mod set_subject; pub mod sync; pub 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 @@ +use anyhow::{Context, Result, bail}; +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_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, + }, + 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 NIP-32 kind-1985 `#subject` label event +/// for `target`, overriding its displayed title/subject. +/// +/// Only the author of the target event or a repository maintainer may set the +/// subject. The subject is not applied to the underlying git commit message — +/// it only affects how the PR/issue title is displayed. +#[allow(clippy::too_many_lines)] +async fn publish_set_subject_event( + id: &str, + subject: &str, + offline: bool, + target_kind: &str, // "issue" or "PR" — used in error messages +) -> Result<()> { + let subject = subject.trim(); + if subject.is_empty() { + bail!("--subject 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 the subject. + if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { + bail!( + "only the {target_kind} author or a repository maintainer can set the subject of a {target_kind}" + ); + } + + // Fetch existing label events so we can check whether the subject is + // 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), + ], + ) + .await?; + + let (_, existing_subject) = get_labels_and_subject(&target, &repo_ref, &existing_label_events); + + if existing_subject.as_deref() == Some(subject) { + println!( + "{target_kind} {} already has subject: {}", + &event_id.to_hex()[..8], + subject, + ); + return Ok(()); + } + + // Build the kind-1985 subject label event. + // + // Structure (NIP-32 §subject namespace): + // ["L", "#subject"] — namespace declaration + // ["l", "", "#subject"] — the new subject value + // ["e", , ] — reference to the labelled event + // ["p", ] — notify the author + let relay_hint = repo_ref.relays.first().cloned(); + + let mut tags: Vec = vec![ + // Namespace declaration + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("L")), + vec!["#subject".to_string()], + ), + // Subject value + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("l")), + vec![subject.to_string(), "#subject".to_string()], + ), + ]; + + // Reference the target event. + 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!("set {target_kind} subject to: {subject}")], + )); + + let subject_event = ngit::client::sign_event( + EventBuilder::new(KIND_LABEL, "").tags(tags), + &signer, + format!("set {target_kind} subject"), + ) + .await?; + + // Save to local cache immediately so subsequent reads reflect the new subject. + save_event_in_local_cache(git_repo_path, &subject_event).await?; + + let mut client = client; + client.set_signer(signer).await; + + send_events( + &client, + Some(git_repo_path), + vec![subject_event], + user_ref.relays.write(), + repo_ref.relays.clone(), + true, + false, + ) + .await?; + + println!( + "{} {} subject set to: {}", + target_kind, + &event_id.to_hex()[..8], + subject, + ); + Ok(()) +} + +pub async fn launch_issue_set_subject(id: &str, subject: &str, offline: bool) -> Result<()> { + publish_set_subject_event(id, subject, offline, "issue").await +} + +pub async fn launch_pr_set_subject(id: &str, subject: &str, offline: bool) -> Result<()> { + publish_set_subject_event(id, subject, offline, "PR").await +} -- cgit v1.2.3