diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-05 13:03:50 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-05 13:03:50 +0000 |
| commit | f3fcf863aae000964753f574b00e9fb9f5fcd452 (patch) | |
| tree | 522e1cff8e0b8ab9fcabcf1bc6d229076891542e /src/bin/ngit/sub_commands/set_subject.rs | |
| parent | ad6c39abdc35603f58e9b71993b5632c976deac1 (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
Diffstat (limited to 'src/bin/ngit/sub_commands/set_subject.rs')
| -rw-r--r-- | src/bin/ngit/sub_commands/set_subject.rs | 203 |
1 files changed, 203 insertions, 0 deletions
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 @@ | |||
| 1 | use anyhow::{Context, Result, bail}; | ||
| 2 | use 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 | }; | ||
| 6 | use nostr::{EventBuilder, Tag, TagStandard}; | ||
| 7 | use nostr_sdk::{EventId, FromBech32}; | ||
| 8 | use nostr::nips::nip19::Nip19; | ||
| 9 | |||
| 10 | use 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 | |||
| 20 | fn 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)] | ||
| 41 | async 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 | |||
| 197 | pub 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 | |||
| 201 | pub async fn launch_pr_set_subject(id: &str, subject: &str, offline: bool) -> Result<()> { | ||
| 202 | publish_set_subject_event(id, subject, offline, "PR").await | ||
| 203 | } | ||