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 +++++++++++++++++++++++++++++++ src/lib/git_events.rs | 133 ++++++++++++++++++-- 7 files changed, 427 insertions(+), 55 deletions(-) create mode 100644 src/bin/ngit/sub_commands/set_subject.rs (limited to 'src') 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 +} 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( )) } -/// Compute the effective set of labels for `event`. +/// Process hashtag labels (`#t` namespace) from a pre-fetched set of kind-1985 +/// events. /// /// Labels come from two sources, both subject to the same permission check: /// /// 1. `t` tags on the event itself (self-reported by the event author). -/// 2. NIP-32 kind-1985 label events in `all_label_events` that reference -/// `event` via a lowercase `e` tag and carry `["L", "#t"]` + +/// 2. NIP-32 kind-1985 label events in `label_events` that reference `event` +/// via a lowercase `e` tag and carry `["L", "#t"]` + /// `["l", "", "#t"]` tags. /// -/// A label is only applied when the author of the source event (the original -/// event for inline `t` tags, or the kind-1985 event for external labels) is -/// either the author of `event` itself or one of the repository maintainers. -pub fn get_labels( +/// A label is only applied when the author of the source event is either the +/// author of `event` itself or one of the repository maintainers. +/// +/// Labels are additive — all valid label events contribute; there is no +/// "latest wins" replacement semantics. +pub fn process_labels( event: &Event, repo_ref: &RepoRef, - all_label_events: &[Event], + label_events: &[Event], ) -> Vec { let is_permitted = |pubkey: &PublicKey| -> bool { pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) }; - // 1. Inline `t` tags on the event itself — only if the event author is - // permitted (they always are, since they authored the event, but we - // keep the check symmetric with the external-label path). + // 1. Inline `t` tags on the event itself. let mut labels: Vec = if is_permitted(&event.pubkey) { event .tags @@ -1016,7 +1017,7 @@ pub fn get_labels( vec![] }; - // 2. External NIP-32 kind-1985 label events. + // 2. External NIP-32 kind-1985 label events (`#t` namespace). // // A valid label event must: // - be kind 1985 @@ -1025,7 +1026,7 @@ pub fn get_labels( // - have at least one `["l", "", "#t"]` tag // - be authored by a permitted pubkey let event_id_str = event.id.to_string(); - for label_event in all_label_events { + for label_event in label_events { if !label_event.kind.eq(&KIND_LABEL) { continue; } @@ -1063,6 +1064,112 @@ pub fn get_labels( labels } +/// Process the effective subject/title override for `event` from a pre-fetched +/// set of kind-1985 events. +/// +/// Subject overrides use the `#subject` namespace: +/// `["L", "#subject"]` + `["l", "", "#subject"]` +/// +/// Unlike hashtag labels, subject overrides are replaceable-style: only the +/// latest authorised event wins, with tiebreak by lexicographically larger +/// event ID (consistent with NIP-1 replaceable event semantics). +/// +/// Only the author of `event` or a repository maintainer may set the subject. +/// Returns `None` when no valid subject override exists. +pub fn process_subject( + event: &Event, + repo_ref: &RepoRef, + label_events: &[Event], +) -> Option { + let is_permitted = |pubkey: &PublicKey| -> bool { + pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) + }; + + let event_id_str = event.id.to_string(); + + // Find the winning subject label event: latest created_at, tiebreak by + // lexicographically larger event ID (NIP-1 replaceable event semantics). + let winner = label_events + .iter() + .filter(|le| { + if !le.kind.eq(&KIND_LABEL) { + return false; + } + if !is_permitted(&le.pubkey) { + return false; + } + // Must reference our event via a lowercase `e` tag. + let references_event = le.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0].eq("e") && s[1].eq(&event_id_str) + }); + if !references_event { + return false; + } + // Must declare the `#subject` namespace. + let has_namespace = le.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0].eq("L") && s[1].eq("#subject") + }); + if !has_namespace { + return false; + } + // Must have at least one non-empty `["l", "", "#subject"]` tag. + le.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 3 && s[0].eq("l") && s[2].eq("#subject") && !s[1].is_empty() + }) + }) + .max_by(|a, b| { + // Primary: newer created_at wins. + // Tiebreak: lexicographically larger event ID wins (NIP-1). + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.to_string().cmp(&b.id.to_string())) + })?; + + // Extract the subject value from the winning event. + winner.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 3 && s[0].eq("l") && s[2].eq("#subject") && !s[1].is_empty() { + Some(s[1].clone()) + } else { + None + } + }) +} + +/// Compute both the effective hashtag labels and the subject/title override for +/// `event` from a pre-fetched set of kind-1985 events. +/// +/// This is the primary entry point: callers should fetch label events once +/// (covering both `#t` and `#subject` namespaces) and pass them here to get +/// both results in a single pass. +/// +/// Returns `(labels, subject_override)` where `subject_override` is `None` +/// when no authorised `#subject` label exists. +pub fn get_labels_and_subject( + event: &Event, + repo_ref: &RepoRef, + label_events: &[Event], +) -> (Vec, Option) { + ( + process_labels(event, repo_ref, label_events), + process_subject(event, repo_ref, label_events), + ) +} + +/// Compatibility wrapper — returns only the hashtag labels. +/// +/// Prefer [`get_labels_and_subject`] when the subject override is also needed. +pub fn get_labels( + event: &Event, + repo_ref: &RepoRef, + label_events: &[Event], +) -> Vec { + process_labels(event, repo_ref, label_events) +} + pub fn get_status( proposal: &Event, repo_ref: &RepoRef, -- cgit v1.2.3