upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/ngit/sub_commands/label.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/bin/ngit/sub_commands/label.rs')
-rw-r--r--src/bin/ngit/sub_commands/label.rs219
1 files changed, 219 insertions, 0 deletions
diff --git a/src/bin/ngit/sub_commands/label.rs b/src/bin/ngit/sub_commands/label.rs
new file mode 100644
index 0000000..634e0b5
--- /dev/null
+++ b/src/bin/ngit/sub_commands/label.rs
@@ -0,0 +1,219 @@
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},
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 label event for `target`.
35///
36/// `labels` must be non-empty. The caller is responsible for ensuring
37/// `target` is a valid issue or PR event that belongs to the current repo.
38#[allow(clippy::too_many_lines)]
39async fn publish_label_event(
40 id: &str,
41 labels: &[String],
42 offline: bool,
43 target_kind: &str, // "issue" or "PR" — used in error messages
44) -> Result<()> {
45 if labels.is_empty() {
46 bail!("at least one --label value is required");
47 }
48
49 let event_id = parse_event_id(id)?;
50
51 let git_repo = Repo::discover().context("failed to find a git repository")?;
52 let git_repo_path = git_repo.get_path()?;
53
54 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
55 let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?;
56
57 if !offline {
58 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
59 }
60
61 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
62
63 // Resolve the target event from cache.
64 let target = if target_kind == "issue" {
65 let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?;
66 issues
67 .into_iter()
68 .find(|e| e.id == event_id)
69 .context(format!(
70 "issue with id {} not found in cache",
71 event_id.to_hex()
72 ))?
73 } else {
74 let proposals =
75 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?;
76 proposals
77 .into_iter()
78 .find(|e| e.id == event_id)
79 .context(format!(
80 "PR with id {} not found in cache",
81 event_id.to_hex()
82 ))?
83 };
84
85 // Login — we need the signer and user pubkey.
86 let (signer, user_ref, _) =
87 login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?;
88
89 let user_pubkey = signer.get_public_key().await?;
90
91 // Permission check: only the author or a maintainer may label.
92 if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) {
93 bail!(
94 "only the {target_kind} author or a repository maintainer can label a {target_kind}"
95 );
96 }
97
98 // Fetch existing label events so we can warn about duplicates.
99 let existing_label_events = get_events_from_local_cache(
100 git_repo_path,
101 vec![
102 nostr::Filter::default()
103 .event(event_id)
104 .kind(KIND_LABEL),
105 ],
106 )
107 .await?;
108
109 let existing_labels = get_labels(&target, &repo_ref, &existing_label_events);
110
111 // Deduplicate: only add labels not already present.
112 let new_labels: Vec<String> = labels
113 .iter()
114 .map(|l| l.trim().to_string())
115 .filter(|l| !l.is_empty())
116 .filter(|l| !existing_labels.iter().any(|e| e.eq_ignore_ascii_case(l)))
117 .collect();
118
119 if new_labels.is_empty() {
120 let already: Vec<String> = labels
121 .iter()
122 .map(|l| format!("#{}", l.trim()))
123 .collect();
124 println!(
125 "{target_kind} already has label{}: {}",
126 if already.len() == 1 { "" } else { "s" },
127 already.join(", ")
128 );
129 return Ok(());
130 }
131
132 // Build the kind-1985 label event.
133 //
134 // Structure (NIP-32 §hashtag namespace):
135 // ["L", "#t"] — namespace declaration
136 // ["l", "<value>", "#t"] — one tag per label
137 // ["e", <target-id>, <relay>] — reference to the labelled event
138 // ["p", <author-pubkey>] — notify the author
139 let relay_hint = repo_ref.relays.first().cloned();
140
141 let mut tags: Vec<Tag> = vec![
142 // Namespace declaration
143 Tag::custom(
144 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("L")),
145 vec!["#t".to_string()],
146 ),
147 ];
148
149 // One ["l", value, "#t"] tag per label.
150 for label in &new_labels {
151 tags.push(Tag::custom(
152 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("l")),
153 vec![label.clone(), "#t".to_string()],
154 ));
155 }
156
157 // Reference the target event.
158 tags.push(Tag::from_standardized(TagStandard::Event {
159 event_id: target.id,
160 relay_url: relay_hint.clone(),
161 marker: None,
162 public_key: None,
163 uppercase: false,
164 }));
165
166 // Notify the target event author.
167 tags.push(Tag::public_key(target.pubkey));
168
169 // Human-readable alt text.
170 let label_list = new_labels
171 .iter()
172 .map(|l| format!("#{l}"))
173 .collect::<Vec<_>>()
174 .join(", ");
175 tags.push(Tag::custom(
176 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
177 vec![format!("labelled {target_kind} with {label_list}")],
178 ));
179
180 let label_event = ngit::client::sign_event(
181 EventBuilder::new(KIND_LABEL, "").tags(tags),
182 &signer,
183 format!("label {target_kind}"),
184 )
185 .await?;
186
187 // Save to local cache immediately so subsequent reads reflect the new labels.
188 save_event_in_local_cache(git_repo_path, &label_event).await?;
189
190 let mut client = client;
191 client.set_signer(signer).await;
192
193 send_events(
194 &client,
195 Some(git_repo_path),
196 vec![label_event],
197 user_ref.relays.write(),
198 repo_ref.relays.clone(),
199 true,
200 false,
201 )
202 .await?;
203
204 println!(
205 "{} {} labelled with {}",
206 target_kind,
207 &event_id.to_hex()[..8],
208 label_list,
209 );
210 Ok(())
211}
212
213pub async fn launch_issue_label(id: &str, labels: &[String], offline: bool) -> Result<()> {
214 publish_label_event(id, labels, offline, "issue").await
215}
216
217pub async fn launch_pr_label(id: &str, labels: &[String], offline: bool) -> Result<()> {
218 publish_label_event(id, labels, offline, "PR").await
219}