upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/bin/ngit/cli.rs29
-rw-r--r--src/bin/ngit/main.rs3
-rw-r--r--src/bin/ngit/sub_commands/mod.rs1
-rw-r--r--src/bin/ngit/sub_commands/repo/accept.rs263
-rw-r--r--src/bin/ngit/sub_commands/repo/mod.rs358
5 files changed, 654 insertions, 0 deletions
diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs
index 037d17f..fa5d906 100644
--- a/src/bin/ngit/cli.rs
+++ b/src/bin/ngit/cli.rs
@@ -106,6 +106,11 @@ pub enum Commands {
106 /// publish a repository to nostr; signal you are its maintainer accepting 106 /// publish a repository to nostr; signal you are its maintainer accepting
107 /// PRs and issues 107 /// PRs and issues
108 Init(sub_commands::init::SubCommandArgs), 108 Init(sub_commands::init::SubCommandArgs),
109 /// manage repository metadata and maintainership
110 #[command(
111 long_about = "manage repository metadata and maintainership\n\nrun without a subcommand to show repository info"
112 )]
113 Repo(RepoSubCommandArgs),
109 /// submit PR with advanced options 114 /// submit PR with advanced options
110 #[command( 115 #[command(
111 long_about = "submit PR with advanced options\n\nfor a simpler flow, push a branch with the `pr/` prefix using native git:\n git push -o 'title=My PR' -o 'description=details here' -u origin pr/my-branch" 116 long_about = "submit PR with advanced options\n\nfor a simpler flow, push a branch with the `pr/` prefix using native git:\n git push -o 'title=My PR' -o 'description=details here' -u origin pr/my-branch"
@@ -177,3 +182,27 @@ pub struct AccountSubCommandArgs {
177 #[command(subcommand)] 182 #[command(subcommand)]
178 pub account_command: AccountCommands, 183 pub account_command: AccountCommands,
179} 184}
185
186#[derive(clap::Parser)]
187pub struct RepoSubCommandArgs {
188 #[command(subcommand)]
189 pub repo_command: Option<RepoCommands>,
190}
191
192#[derive(Subcommand)]
193pub enum RepoCommands {
194 /// publish a repository to nostr (alias for `ngit init`)
195 Init(sub_commands::init::SubCommandArgs),
196 /// update repository metadata on nostr
197 #[command(
198 long_about = "update repository metadata on nostr\n\nlike `ngit init` but makes clear you are editing an existing repository"
199 )]
200 Edit(sub_commands::init::SubCommandArgs),
201 /// accept an invitation to co-maintain a repository
202 #[command(long_about = "accept an invitation to co-maintain a repository\n\n\
203 publishes your repository announcement to nostr, confirming your co-maintainership.\n\n\
204 This is required because your signed announcement is what ties your git state events\n\
205 to a specific repository coordinate chain, preventing scammers from attributing your\n\
206 commits to a fake repository. See `ngit repo info` for details on the maintainer model.")]
207 Accept(sub_commands::repo::accept::SubCommandArgs),
208}
diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs
index c88238a..924a714 100644
--- a/src/bin/ngit/main.rs
+++ b/src/bin/ngit/main.rs
@@ -49,6 +49,9 @@ async fn main() {
49 } 49 }
50 }, 50 },
51 Commands::Init(args) => sub_commands::init::launch(&cli, args).await, 51 Commands::Init(args) => sub_commands::init::launch(&cli, args).await,
52 Commands::Repo(args) => {
53 sub_commands::repo::launch(&cli, args.repo_command.as_ref()).await
54 }
52 Commands::List { 55 Commands::List {
53 status, 56 status,
54 json, 57 json,
diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs
index 6b94248..d132240 100644
--- a/src/bin/ngit/sub_commands/mod.rs
+++ b/src/bin/ngit/sub_commands/mod.rs
@@ -6,5 +6,6 @@ pub mod init;
6pub mod list; 6pub mod list;
7pub mod login; 7pub mod login;
8pub mod logout; 8pub mod logout;
9pub mod repo;
9pub mod send; 10pub mod send;
10pub mod sync; 11pub mod sync;
diff --git a/src/bin/ngit/sub_commands/repo/accept.rs b/src/bin/ngit/sub_commands/repo/accept.rs
new file mode 100644
index 0000000..5564b77
--- /dev/null
+++ b/src/bin/ngit/sub_commands/repo/accept.rs
@@ -0,0 +1,263 @@
1use std::sync::Arc;
2
3use anyhow::{Context, Result};
4use ngit::{
5 accept_maintainership::{accept_maintainership_with_defaults, wait_for_grasp_servers},
6 cli_interactor::cli_error,
7 client::{Params, fetching_with_report, get_repo_ref_from_cache, send_events},
8 repo_ref::{RepoRef, apply_grasp_infrastructure, latest_event_repo_ref},
9};
10use nostr::{
11 ToBech32,
12 nips::{nip01::Coordinate, nip19::Nip19Coordinate},
13};
14use nostr_sdk::{Kind, NostrSigner, RelayUrl};
15
16use crate::{
17 cli::{Cli, extract_signer_cli_arguments},
18 client::{Client, Connect},
19 git::{Repo, RepoActions},
20 login,
21 repo_ref::try_and_get_repo_coordinates_when_remote_unknown,
22};
23
24#[derive(Debug, clap::Args)]
25pub struct SubCommandArgs {
26 #[clap(short, long, value_parser, num_args = 1..)]
27 /// where your git+nostr data is hosted (optional; uses your saved grasp
28 /// server list or the trusted maintainer's servers if not specified)
29 grasp_server: Vec<String>,
30}
31
32pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
33 let git_repo = Repo::discover().context("failed to find a git repository")?;
34 let git_repo_path = git_repo.get_path()?;
35 let mut client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
36
37 let (signer, user_ref, _) = login::login_or_signup(
38 &Some(&git_repo),
39 &extract_signer_cli_arguments(cli_args).unwrap_or(None),
40 &cli_args.password,
41 Some(&client),
42 false,
43 )
44 .await?;
45
46 let my_pubkey = user_ref.public_key;
47
48 let repo_coordinate = (try_and_get_repo_coordinates_when_remote_unknown(&git_repo).await).ok();
49
50 let Some(repo_coordinate) = repo_coordinate else {
51 return Err(cli_error(
52 "no nostr repository found",
53 &[],
54 &["use `ngit repo init` to publish this repository to nostr"],
55 ));
56 };
57
58 // Fetch latest data from relays
59 fetching_with_report(git_repo_path, &client, &repo_coordinate).await?;
60
61 let Some(repo_ref) =
62 (get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinate).await).ok()
63 else {
64 return Err(cli_error(
65 "no announcement found on relays for this repository",
66 &[],
67 &[
68 "if you created this repository, use `ngit repo init` to publish an announcement",
69 "if this is a relay or network issue, try again later",
70 ],
71 ));
72 };
73
74 // Validate state
75 let trusted = repo_ref.trusted_maintainer;
76
77 if trusted == my_pubkey {
78 return Err(cli_error(
79 "you are already the trusted maintainer of this repository",
80 &[],
81 &["use `ngit repo edit` to update your announcement"],
82 ));
83 }
84
85 let has_announcement = repo_ref
86 .events
87 .keys()
88 .any(|c| c.coordinate.public_key == my_pubkey);
89
90 if has_announcement {
91 return Err(cli_error(
92 "you have already published a co-maintainer announcement for this repository",
93 &[],
94 &["use `ngit repo edit` to update your announcement"],
95 ));
96 }
97
98 if !repo_ref.maintainers.contains(&my_pubkey) {
99 let trusted_npub = trusted.to_bech32().unwrap_or_else(|_| trusted.to_hex());
100 return Err(cli_error(
101 "you have not been invited as a maintainer of this repository",
102 &[("trusted maintainer", trusted_npub.as_str())],
103 &["the trusted maintainer must add your npub to their announcement first"],
104 ));
105 }
106
107 // Happy path: CoMaintainer state without an existing announcement
108 let repo_name = &repo_ref.name;
109 let trusted_npub = trusted.to_bech32().unwrap_or_else(|_| trusted.to_hex());
110 println!("accepting co-maintainership of '{repo_name}' (offered by {trusted_npub})");
111 println!("publishing your repository announcement to nostr...");
112
113 if args.grasp_server.is_empty() {
114 // Use the existing defaults logic from the library
115 accept_maintainership_with_defaults(&git_repo, &repo_ref, &user_ref, &mut client, &signer)
116 .await?;
117 } else {
118 // User specified grasp servers explicitly — use them
119 accept_with_grasp_servers(
120 &git_repo,
121 &repo_ref,
122 &signer,
123 &user_ref,
124 &mut client,
125 &args.grasp_server,
126 )
127 .await?;
128 }
129
130 println!("co-maintainership accepted.");
131 println!("your announcement has been published to nostr. you can now push updates.");
132 println!("run `ngit repo edit` at any time to update your announcement.");
133
134 Ok(())
135}
136
137/// Accept co-maintainership with explicitly specified grasp servers.
138#[allow(clippy::too_many_lines)]
139async fn accept_with_grasp_servers(
140 git_repo: &Repo,
141 repo_ref: &RepoRef,
142 signer: &Arc<dyn NostrSigner>,
143 user_ref: &ngit::login::user::UserRef,
144 client: &mut Client,
145 grasp_servers: &[String],
146) -> Result<()> {
147 let my_pubkey = &user_ref.public_key;
148 let identifier = &repo_ref.identifier;
149
150 let mut git_servers: Vec<String> = vec![];
151 let mut relay_strings: Vec<String> = vec![];
152
153 apply_grasp_infrastructure(
154 grasp_servers,
155 &mut git_servers,
156 &mut relay_strings,
157 my_pubkey,
158 identifier,
159 )?;
160
161 let relays: Vec<RelayUrl> = relay_strings
162 .iter()
163 .filter_map(|r| RelayUrl::parse(r).ok())
164 .collect();
165
166 let latest = latest_event_repo_ref(repo_ref);
167 let name = latest
168 .as_ref()
169 .map_or_else(|| identifier.clone(), |lr| lr.name.clone());
170 let description = latest
171 .as_ref()
172 .map(|lr| lr.description.clone())
173 .unwrap_or_default();
174 let web = latest.as_ref().map(|lr| lr.web.clone()).unwrap_or_default();
175 let hashtags = latest
176 .as_ref()
177 .map(|lr| lr.hashtags.clone())
178 .unwrap_or_default();
179 let blossoms = latest
180 .as_ref()
181 .map(|lr| lr.blossoms.clone())
182 .unwrap_or_default();
183 let root_commit = latest
184 .as_ref()
185 .map(|lr| lr.root_commit.clone())
186 .filter(|c| !c.is_empty())
187 .unwrap_or_else(|| repo_ref.root_commit.clone());
188
189 let mut maintainers = vec![*my_pubkey];
190 if repo_ref.trusted_maintainer != *my_pubkey {
191 maintainers.push(repo_ref.trusted_maintainer);
192 }
193
194 let my_repo_ref = RepoRef {
195 identifier: identifier.clone(),
196 name,
197 description,
198 root_commit,
199 git_server: git_servers,
200 web,
201 relays: relays.clone(),
202 blossoms,
203 hashtags,
204 trusted_maintainer: *my_pubkey,
205 maintainers_without_annoucnement: None,
206 maintainers,
207 events: std::collections::HashMap::new(),
208 nostr_git_url: None,
209 };
210
211 let repo_event = my_repo_ref.to_event(signer).await?;
212
213 client.set_signer(signer.clone()).await;
214
215 send_events(
216 client,
217 Some(git_repo.get_path()?),
218 vec![repo_event],
219 user_ref.relays.write(),
220 relays.clone(),
221 true,
222 false,
223 )
224 .await
225 .context("failed to publish co-maintainer announcement")?;
226
227 if !grasp_servers.is_empty() {
228 wait_for_grasp_servers(git_repo, grasp_servers, my_pubkey, identifier).await?;
229 }
230
231 // Update nostr.repo git config
232 git_repo
233 .save_git_config_item(
234 "nostr.repo",
235 &Nip19Coordinate {
236 coordinate: Coordinate {
237 kind: Kind::GitRepoAnnouncement,
238 public_key: *my_pubkey,
239 identifier: identifier.clone(),
240 },
241 relays: vec![],
242 }
243 .to_bech32()?,
244 false,
245 )
246 .context("failed to update nostr.repo git config")?;
247
248 // Update origin remote
249 let nostr_url = my_repo_ref.to_nostr_git_url(&Some(git_repo)).to_string();
250 if git_repo.git_repo.find_remote("origin").is_ok() {
251 git_repo
252 .git_repo
253 .remote_set_url("origin", &nostr_url)
254 .context("failed to update origin remote")?;
255 } else {
256 git_repo
257 .git_repo
258 .remote("origin", &nostr_url)
259 .context("failed to set origin remote")?;
260 }
261
262 Ok(())
263}
diff --git a/src/bin/ngit/sub_commands/repo/mod.rs b/src/bin/ngit/sub_commands/repo/mod.rs
new file mode 100644
index 0000000..62fe766
--- /dev/null
+++ b/src/bin/ngit/sub_commands/repo/mod.rs
@@ -0,0 +1,358 @@
1pub mod accept;
2
3use anyhow::{Context, Result};
4use ngit::{
5 client::{Params, fetching_with_report, get_repo_ref_from_cache},
6 repo_ref::{RepoRef, extract_npub, is_grasp_server_clone_url},
7};
8use nostr::{PublicKey, TagStandard, ToBech32, nips::nip19::Nip19Coordinate};
9
10use crate::{
11 cli::{Cli, RepoCommands, extract_signer_cli_arguments},
12 client::{Client, Connect},
13 git::{Repo, RepoActions},
14 login,
15 repo_ref::try_and_get_repo_coordinates_when_remote_unknown,
16 sub_commands::init,
17};
18
19pub async fn launch(cli_args: &Cli, repo_command: Option<&RepoCommands>) -> Result<()> {
20 match repo_command {
21 Some(RepoCommands::Init(args) | RepoCommands::Edit(args)) => {
22 init::launch(cli_args, args).await
23 }
24 Some(RepoCommands::Accept(args)) => accept::launch(cli_args, args).await,
25 None => show_info(cli_args).await,
26 }
27}
28
29// ---------------------------------------------------------------------------
30// `ngit repo` (no subcommand) — show repository info
31// ---------------------------------------------------------------------------
32
33async fn show_info(cli_args: &Cli) -> Result<()> {
34 let git_repo = Repo::discover().context("failed to find a git repository")?;
35 let git_repo_path = git_repo.get_path()?;
36 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
37
38 let (_, user_ref, _) = login::login_or_signup(
39 &Some(&git_repo),
40 &extract_signer_cli_arguments(cli_args).unwrap_or(None),
41 &cli_args.password,
42 Some(&client),
43 false,
44 )
45 .await?;
46
47 let repo_coordinate = (try_and_get_repo_coordinates_when_remote_unknown(&git_repo).await).ok();
48
49 let Some(repo_coordinate) = repo_coordinate else {
50 println!("no nostr repository found");
51 println!();
52 println!("use `ngit repo init` to publish this repository to nostr");
53 return Ok(());
54 };
55
56 // Fetch latest data from relays
57 fetching_with_report(git_repo_path, &client, &repo_coordinate).await?;
58
59 let Some(repo_ref) =
60 (get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinate).await).ok()
61 else {
62 println!(
63 "coordinate found ({}) but no announcement on relays",
64 repo_coordinate.identifier
65 );
66 println!();
67 println!("if you created this repository, run `ngit repo init` to publish an announcement");
68 println!("if you are a co-maintainer, run `ngit repo accept` to publish your announcement");
69 return Ok(());
70 };
71
72 print_repo_info(&repo_ref, &user_ref.public_key, &repo_coordinate);
73 Ok(())
74}
75
76#[allow(clippy::too_many_lines)]
77fn print_repo_info(repo_ref: &RepoRef, my_pubkey: &PublicKey, coordinate: &Nip19Coordinate) {
78 // --- Basic metadata ---
79 println!("Repository: {}", repo_ref.name);
80 if !repo_ref.description.is_empty() {
81 println!("Description: {}", repo_ref.description);
82 }
83 if !repo_ref.web.is_empty() {
84 for url in &repo_ref.web {
85 println!("Web: {url}");
86 }
87 }
88 if !repo_ref.hashtags.is_empty() {
89 println!("Hashtags: {}", repo_ref.hashtags.join(", "));
90 }
91 println!();
92
93 // --- Maintainers ---
94 let trusted = &repo_ref.trusted_maintainer;
95 let trusted_npub = trusted.to_bech32().unwrap_or_else(|_| trusted.to_hex());
96 println!("Trusted maintainer: {}", short_npub(&trusted_npub));
97
98 // Build a map: pubkey → who listed them (for recursive display)
99 // We walk the events map to find each maintainer's "lister"
100 let co_maintainers: Vec<&PublicKey> = repo_ref
101 .maintainers
102 .iter()
103 .filter(|m| *m != trusted)
104 .collect();
105
106 if !co_maintainers.is_empty() {
107 // For each co-maintainer, find who listed them by inspecting events
108 let mut listed_by: Vec<(String, Option<String>)> = Vec::new();
109 for co in &co_maintainers {
110 let co_npub = co.to_bech32().unwrap_or_else(|_| co.to_hex());
111 // Find which maintainer's event lists this co-maintainer
112 let lister = find_lister(repo_ref, co, trusted);
113 listed_by.push((co_npub, lister));
114 }
115
116 // Print directly-listed co-maintainers first, then indirectly-listed
117 let direct: Vec<_> = listed_by
118 .iter()
119 .filter(|(_, lister)| lister.is_none())
120 .collect();
121 let indirect: Vec<_> = listed_by
122 .iter()
123 .filter(|(_, lister)| lister.is_some())
124 .collect();
125
126 if !direct.is_empty() {
127 let names: Vec<String> = direct.iter().map(|(npub, _)| short_npub(npub)).collect();
128 println!("Co-maintainers: {}", names.join(", "));
129 }
130 for (npub, lister) in &indirect {
131 if let Some(lister_npub) = lister {
132 println!(
133 " └─ {} is listed by {}, not directly by the trusted maintainer",
134 short_npub(npub),
135 short_npub(lister_npub)
136 );
137 }
138 }
139 }
140
141 // Maintainers without announcements
142 if let Some(without) = &repo_ref.maintainers_without_annoucnement {
143 if !without.is_empty() {
144 let names: Vec<String> = without
145 .iter()
146 .map(|pk| {
147 let npub = pk.to_bech32().unwrap_or_else(|_| pk.to_hex());
148 short_npub(&npub)
149 })
150 .collect();
151 println!(" (invited, no announcement yet: {})", names.join(", "));
152 }
153 }
154
155 // --- My status ---
156 let my_status = if my_pubkey == trusted {
157 let has_announcement = repo_ref
158 .events
159 .keys()
160 .any(|c| c.coordinate.public_key == *my_pubkey);
161 if has_announcement {
162 "trusted maintainer [announcement published ✓]"
163 } else {
164 "trusted maintainer [no announcement — run `ngit repo init`]"
165 }
166 } else if repo_ref.maintainers.contains(my_pubkey) {
167 let has_announcement = repo_ref
168 .events
169 .keys()
170 .any(|c| c.coordinate.public_key == *my_pubkey);
171 if has_announcement {
172 "co-maintainer [announcement published ✓]"
173 } else {
174 "co-maintainer [no announcement — run `ngit repo accept`]"
175 }
176 } else {
177 "not a maintainer"
178 };
179 println!("Your status: {my_status}");
180 println!();
181
182 // --- Infrastructure (with per-maintainer attribution) ---
183 println!("Git servers (union across all maintainers — any maintainer can add a mirror):");
184 for server in &repo_ref.git_server {
185 let attribution = attribute_server_to_maintainer(repo_ref, server, coordinate);
186 println!(" {server} {attribution}");
187 }
188 println!();
189
190 println!("Relays (union across all maintainers — any maintainer can add a relay):");
191 for relay in &repo_ref.relays {
192 let attribution = attribute_relay_to_maintainer(repo_ref, relay.as_str(), coordinate);
193 println!(" {relay} {attribution}");
194 }
195 println!();
196
197 // --- Maintainer model note ---
198 println!("Note: git servers and relays are pooled from all maintainers' announcements.");
199 println!(
200 " Name, description, web, and hashtags come from the most recently updated announcement."
201 );
202 println!(" Each maintainer independently decides who they list as co-maintainers;");
203 println!(" if Alice lists Bob and Bob lists Carol, all three are in the maintainer set.");
204}
205
206/// Find which maintainer's event lists `target` as a maintainer.
207/// Returns `None` if listed directly by the trusted maintainer,
208/// or `Some(lister_npub)` if listed by a co-maintainer.
209fn find_lister(repo_ref: &RepoRef, target: &PublicKey, trusted: &PublicKey) -> Option<String> {
210 use nostr::nips::nip01::Coordinate;
211 use nostr_sdk::Kind;
212
213 // Check if the trusted maintainer's event lists this target directly
214 let trusted_coord = nostr::nips::nip19::Nip19Coordinate {
215 coordinate: Coordinate {
216 kind: Kind::GitRepoAnnouncement,
217 public_key: *trusted,
218 identifier: repo_ref.identifier.clone(),
219 },
220 relays: vec![],
221 };
222 if let Some(event) = repo_ref.events.get(&trusted_coord) {
223 // Parse the event's maintainers tag
224 let listed_in_trusted: Vec<PublicKey> = event
225 .tags
226 .iter()
227 .filter_map(|t| {
228 if let Some(TagStandard::PublicKey { public_key, .. }) = t.as_standardized() {
229 Some(*public_key)
230 } else {
231 None
232 }
233 })
234 .collect();
235 if listed_in_trusted.contains(target) {
236 return None; // directly listed by trusted maintainer
237 }
238 }
239
240 // Otherwise find which co-maintainer lists them
241 for (coord, event) in &repo_ref.events {
242 if coord.coordinate.public_key == *trusted {
243 continue;
244 }
245 let lister = coord.coordinate.public_key;
246 let maintainers_listed: Vec<PublicKey> = event
247 .tags
248 .iter()
249 .filter_map(|t| {
250 if let Some(TagStandard::PublicKey { public_key, .. }) = t.as_standardized() {
251 Some(*public_key)
252 } else {
253 None
254 }
255 })
256 .collect();
257 if maintainers_listed.contains(target) {
258 let lister_npub = lister.to_bech32().unwrap_or_else(|_| lister.to_hex());
259 return Some(lister_npub);
260 }
261 }
262
263 None
264}
265
266/// Find which maintainer(s) contribute a given git server URL.
267fn attribute_server_to_maintainer(
268 repo_ref: &RepoRef,
269 server_url: &str,
270 coordinate: &Nip19Coordinate,
271) -> String {
272 // For grasp-format URLs, the npub in the path tells us the owner
273 if is_grasp_server_clone_url(server_url) {
274 if let Ok(npub) = extract_npub(server_url) {
275 return format!("[{}]", short_npub(npub));
276 }
277 }
278
279 // For non-grasp URLs, find which maintainer's event lists it
280 let owners = find_server_owners(repo_ref, server_url, coordinate);
281 if owners.is_empty() {
282 String::new()
283 } else {
284 format!("[{}]", owners.join(", "))
285 }
286}
287
288/// Find which maintainer(s) contribute a given relay URL.
289fn attribute_relay_to_maintainer(
290 repo_ref: &RepoRef,
291 relay_url: &str,
292 coordinate: &Nip19Coordinate,
293) -> String {
294 let owners = find_relay_owners(repo_ref, relay_url, coordinate);
295 if owners.is_empty() {
296 String::new()
297 } else {
298 format!("[{}]", owners.join(", "))
299 }
300}
301
302fn find_server_owners(
303 repo_ref: &RepoRef,
304 server_url: &str,
305 _coordinate: &Nip19Coordinate,
306) -> Vec<String> {
307 let mut owners = Vec::new();
308 for (coord, event) in &repo_ref.events {
309 if let Ok(event_ref) = RepoRef::try_from((event.clone(), None)) {
310 if event_ref
311 .git_server
312 .iter()
313 .any(|s| s.trim_end_matches('/') == server_url.trim_end_matches('/'))
314 {
315 let npub = coord
316 .coordinate
317 .public_key
318 .to_bech32()
319 .unwrap_or_else(|_| coord.coordinate.public_key.to_hex());
320 owners.push(short_npub(&npub));
321 }
322 }
323 }
324 owners
325}
326
327fn find_relay_owners(
328 repo_ref: &RepoRef,
329 relay_url: &str,
330 _coordinate: &Nip19Coordinate,
331) -> Vec<String> {
332 let mut owners = Vec::new();
333 for (coord, event) in &repo_ref.events {
334 if let Ok(event_ref) = RepoRef::try_from((event.clone(), None)) {
335 if event_ref
336 .relays
337 .iter()
338 .any(|r| r.as_str().trim_end_matches('/') == relay_url.trim_end_matches('/'))
339 {
340 let npub = coord
341 .coordinate
342 .public_key
343 .to_bech32()
344 .unwrap_or_else(|_| coord.coordinate.public_key.to_hex());
345 owners.push(short_npub(&npub));
346 }
347 }
348 }
349 owners
350}
351
352/// Shorten an npub for display: show first 8 + "..." + last 4 chars.
353fn short_npub(npub: &str) -> String {
354 if npub.len() <= 16 {
355 return npub.to_string();
356 }
357 format!("{}...{}", &npub[..12], &npub[npub.len() - 4..])
358}