upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/accept_maintainership.rs529
-rw-r--r--src/lib/mod.rs1
-rw-r--r--src/lib/repo_ref.rs65
3 files changed, 595 insertions, 0 deletions
diff --git a/src/lib/accept_maintainership.rs b/src/lib/accept_maintainership.rs
new file mode 100644
index 0000000..173d1a5
--- /dev/null
+++ b/src/lib/accept_maintainership.rs
@@ -0,0 +1,529 @@
1//! Auto-accept co-maintainership on push.
2//!
3//! When a user has been offered co-maintainership (they appear in another
4//! maintainer's `maintainers` tag but have never published their own
5//! Kind:30617 announcement), pushing would normally fail. This module
6//! provides `accept_maintainership_with_defaults`, called by the push path
7//! to silently publish the co-maintainer's announcement with sensible
8//! defaults before continuing the push.
9//!
10//! See `docs/design/co-maintainer-announcement-rationale.md` for why the
11//! announcement is required (scam-protection) even though the fetch/read side
12//! already trusts state events from all listed maintainers.
13use std::{
14 collections::HashMap,
15 sync::{
16 Arc, Mutex,
17 atomic::{AtomicBool, AtomicU64, Ordering},
18 },
19 time::Duration,
20};
21
22use anyhow::{Context, Result};
23use futures::future::join_all;
24use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
25use nostr::{
26 PublicKey, ToBech32,
27 nips::{nip01::Coordinate, nip19::Nip19Coordinate},
28};
29use nostr_sdk::{Kind, NostrSigner, RelayUrl};
30
31#[cfg(not(test))]
32use crate::client::Client;
33#[cfg(test)]
34use crate::client::MockConnect;
35use crate::{
36 client::{Connect, send_events},
37 git::{Repo, RepoActions},
38 login::user::UserRef,
39 repo_ref::{
40 RepoRef, apply_grasp_infrastructure, format_grasp_server_url_as_clone_url,
41 latest_event_repo_ref,
42 },
43};
44
45// ---------------------------------------------------------------------------
46// Public entry point
47// ---------------------------------------------------------------------------
48
49/// Publish the co-maintainer's own Kind:30617 announcement with defaults and
50/// update the local git config / origin remote to point to it.
51///
52/// This is called automatically from the push path when the pushing user is
53/// listed as a maintainer but has not yet published their own announcement.
54/// No interactive prompts are shown — all values come from the existing
55/// announcement and the user's saved grasp server / relay preferences.
56pub async fn accept_maintainership_with_defaults(
57 git_repo: &Repo,
58 repo_ref: &RepoRef,
59 user_ref: &UserRef,
60 #[cfg(test)] client: &mut MockConnect,
61 #[cfg(not(test))] client: &mut Client,
62 signer: &Arc<dyn NostrSigner>,
63) -> Result<()> {
64 let my_pubkey = &user_ref.public_key;
65 let identifier = &repo_ref.identifier;
66
67 // --- Step 1: resolve infrastructure ---
68
69 let selected_grasp_servers = grasp_servers_from_user_or_fallback(user_ref, client);
70
71 let mut git_servers: Vec<String> = vec![];
72 let mut relay_strings: Vec<String> = client
73 .get_relay_default_set()
74 .iter()
75 .map(std::string::ToString::to_string)
76 .collect();
77
78 apply_grasp_infrastructure(
79 &selected_grasp_servers,
80 &mut git_servers,
81 &mut relay_strings,
82 my_pubkey,
83 identifier,
84 )?;
85
86 let relays: Vec<RelayUrl> = relay_strings
87 .iter()
88 .filter_map(|r| RelayUrl::parse(r).ok())
89 .collect();
90
91 // --- Step 2: resolve shared metadata from latest existing event ---
92
93 let latest = latest_event_repo_ref(repo_ref);
94 let name = latest
95 .as_ref()
96 .map(|lr| lr.name.clone())
97 .unwrap_or_else(|| identifier.clone());
98 let description = latest
99 .as_ref()
100 .map(|lr| lr.description.clone())
101 .unwrap_or_default();
102 let web = latest.as_ref().map(|lr| lr.web.clone()).unwrap_or_default();
103 let hashtags = latest
104 .as_ref()
105 .map(|lr| lr.hashtags.clone())
106 .unwrap_or_default();
107 let blossoms = latest
108 .as_ref()
109 .map(|lr| lr.blossoms.clone())
110 .unwrap_or_default();
111 let root_commit = latest
112 .as_ref()
113 .map(|lr| lr.root_commit.clone())
114 .filter(|c| !c.is_empty())
115 .unwrap_or_else(|| repo_ref.root_commit.clone());
116
117 // --- Step 3: maintainers = [me, trusted_maintainer] ---
118
119 let mut maintainers = vec![*my_pubkey];
120 if repo_ref.trusted_maintainer != *my_pubkey {
121 maintainers.push(repo_ref.trusted_maintainer);
122 }
123
124 // --- Step 4: build RepoRef ---
125
126 let my_repo_ref = RepoRef {
127 identifier: identifier.clone(),
128 name: name.clone(),
129 description,
130 root_commit,
131 git_server: git_servers,
132 web,
133 relays: relays.clone(),
134 blossoms,
135 hashtags,
136 trusted_maintainer: *my_pubkey,
137 maintainers_without_annoucnement: None,
138 maintainers,
139 events: HashMap::new(),
140 nostr_git_url: None,
141 };
142
143 // --- Step 5: sign and publish the announcement ---
144
145 eprintln!(
146 "info: accepting co-maintainership of '{}' with defaults",
147 name
148 );
149 eprintln!("info: publishing your repository announcement to nostr...");
150
151 let repo_event = my_repo_ref.to_event(signer).await?;
152
153 client.set_signer(signer.clone()).await;
154
155 send_events(
156 client,
157 Some(git_repo.get_path()?),
158 vec![repo_event],
159 user_ref.relays.write(),
160 relays.clone(),
161 false, // no spinner — we are mid-push
162 true, // silent
163 )
164 .await
165 .context("failed to publish co-maintainer announcement")?;
166
167 // --- Step 6: wait for grasp server provisioning ---
168
169 if !selected_grasp_servers.is_empty() {
170 wait_for_grasp_servers(git_repo, &selected_grasp_servers, my_pubkey, identifier).await?;
171 }
172
173 // --- Step 7: update nostr.repo git config ---
174
175 git_repo
176 .save_git_config_item(
177 "nostr.repo",
178 &Nip19Coordinate {
179 coordinate: Coordinate {
180 kind: Kind::GitRepoAnnouncement,
181 public_key: *my_pubkey,
182 identifier: identifier.clone(),
183 },
184 relays: vec![],
185 }
186 .to_bech32()?,
187 false,
188 )
189 .context("failed to update nostr.repo git config")?;
190
191 // --- Step 8: update origin remote ---
192
193 let nostr_url = my_repo_ref.to_nostr_git_url(&Some(git_repo)).to_string();
194 if git_repo.git_repo.find_remote("origin").is_ok() {
195 git_repo
196 .git_repo
197 .remote_set_url("origin", &nostr_url)
198 .context("failed to update origin remote")?;
199 } else {
200 git_repo
201 .git_repo
202 .remote("origin", &nostr_url)
203 .context("failed to set origin remote")?;
204 }
205
206 eprintln!("info: co-maintainership accepted. run `ngit init` to customise your announcement.");
207
208 Ok(())
209}
210
211// ---------------------------------------------------------------------------
212// Grasp server helpers
213// ---------------------------------------------------------------------------
214
215/// Return the user's saved grasp servers, falling back to client defaults.
216pub fn grasp_servers_from_user_or_fallback(
217 user_ref: &UserRef,
218 #[cfg(test)] client: &MockConnect,
219 #[cfg(not(test))] client: &Client,
220) -> Vec<String> {
221 if user_ref.grasp_list.urls.is_empty() {
222 client
223 .get_grasp_default_set()
224 .iter()
225 .map(std::string::ToString::to_string)
226 .collect()
227 } else {
228 user_ref
229 .grasp_list
230 .urls
231 .iter()
232 .map(std::string::ToString::to_string)
233 .collect()
234 }
235}
236
237// ---------------------------------------------------------------------------
238// Grasp server provisioning poll
239// ---------------------------------------------------------------------------
240
241/// Holds the final style + message for a bar that completed before the detail
242/// view was revealed.
243struct DeferredServerFinish {
244 bar: ProgressBar,
245 style: ProgressStyle,
246 message: String,
247}
248
249struct ServerRevealState {
250 revealed: AtomicBool,
251 deferred: Mutex<Vec<DeferredServerFinish>>,
252}
253
254struct PollContext {
255 timeout_secs: u64,
256 total: u64,
257 ready_count: Arc<AtomicU64>,
258 spinner_pb: ProgressBar,
259 reveal_state: Arc<ServerRevealState>,
260}
261
262fn check_git_server_ready(git_repo_path: &std::path::Path, git_server_url: &str) -> bool {
263 let Ok(git_repo) = git2::Repository::open(git_repo_path) else {
264 return false;
265 };
266 let Ok(mut remote) = git_repo.remote_anonymous(git_server_url) else {
267 return false;
268 };
269 match remote.connect(git2::Direction::Fetch) {
270 Ok(()) => {
271 let _ = remote.disconnect();
272 true
273 }
274 Err(_) => false,
275 }
276}
277
278fn create_server_bars(clone_urls: &[String], detail_multi: &MultiProgress) -> Vec<ProgressBar> {
279 let waiting_style = ProgressStyle::with_template(" {spinner} {msg}")
280 .unwrap()
281 .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈");
282 clone_urls
283 .iter()
284 .map(|url| {
285 let name = url
286 .trim_start_matches("https://")
287 .trim_start_matches("http://")
288 .to_string();
289 detail_multi.add(
290 ProgressBar::new_spinner()
291 .with_style(waiting_style.clone())
292 .with_message(
293 console::style(format!("{name} - waiting"))
294 .for_stderr()
295 .dim()
296 .to_string(),
297 ),
298 )
299 })
300 .collect()
301}
302
303fn spawn_expand_timer(
304 expand_delay_ms: u64,
305 spinner_pb: ProgressBar,
306 detail_multi: MultiProgress,
307 heading_bar: ProgressBar,
308 reveal_state: Arc<ServerRevealState>,
309 server_bars: Vec<ProgressBar>,
310) -> tokio::task::JoinHandle<()> {
311 tokio::spawn(async move {
312 tokio::time::sleep(Duration::from_millis(expand_delay_ms)).await;
313 spinner_pb.finish_and_clear();
314 detail_multi.set_draw_target(ProgressDrawTarget::stderr());
315 heading_bar.finish_with_message("waiting for servers to create bare git repo...");
316 let mut deferred = reveal_state.deferred.lock().unwrap();
317 reveal_state.revealed.store(true, Ordering::Release);
318 for df in deferred.drain(..) {
319 df.bar.set_style(df.style);
320 df.bar.finish_with_message(df.message);
321 }
322 for bar in &server_bars {
323 if !bar.is_finished() {
324 bar.enable_steady_tick(Duration::from_millis(100));
325 }
326 }
327 })
328}
329
330fn finalize_spinner(all_ready: bool, spinner_pb: &ProgressBar, final_ready: u64, total: u64) {
331 if all_ready {
332 spinner_pb.finish_and_clear();
333 } else {
334 spinner_pb.set_style(ProgressStyle::with_template("{msg}").unwrap());
335 spinner_pb.finish_with_message(format!(
336 "timed out waiting for servers to create bare git repo ({final_ready}/{total} - complete), proceeding anyway"
337 ));
338 }
339}
340
341fn finish_server_bar(
342 bar: &ProgressBar,
343 style: ProgressStyle,
344 message: String,
345 reveal_state: &Arc<ServerRevealState>,
346) {
347 let mut deferred = reveal_state.deferred.lock().unwrap();
348 if reveal_state.revealed.load(Ordering::Acquire) {
349 drop(deferred);
350 bar.set_style(style);
351 bar.finish_with_message(message);
352 } else {
353 bar.set_style(style.clone());
354 deferred.push(DeferredServerFinish {
355 bar: bar.clone(),
356 style,
357 message,
358 });
359 }
360}
361
362async fn poll_single_server(
363 url: String,
364 git_repo_path: std::path::PathBuf,
365 bar: ProgressBar,
366 ctx: Arc<PollContext>,
367) -> bool {
368 let poll_interval = Duration::from_millis(500);
369 let deadline = tokio::time::Instant::now() + Duration::from_secs(ctx.timeout_secs);
370 let mut ready = false;
371 loop {
372 let is_ready = tokio::task::spawn_blocking({
373 let url = url.clone();
374 let path = git_repo_path.clone();
375 move || check_git_server_ready(&path, &url)
376 })
377 .await
378 .unwrap_or(false);
379
380 if is_ready {
381 ready = true;
382 break;
383 }
384
385 if tokio::time::Instant::now() >= deadline {
386 break;
387 }
388
389 tokio::time::sleep(poll_interval).await;
390 }
391
392 let count = if ready {
393 ctx.ready_count.fetch_add(1, Ordering::Relaxed) + 1
394 } else {
395 ctx.ready_count.load(Ordering::Relaxed)
396 };
397
398 ctx.spinner_pb.set_message(format!(
399 "waiting for servers to create bare git repo... ({count}/{total} - complete)",
400 total = ctx.total
401 ));
402
403 let name = url
404 .trim_start_matches("https://")
405 .trim_start_matches("http://")
406 .to_string();
407 if ready {
408 let style = ProgressStyle::with_template(&format!(
409 " {} {{msg}}",
410 console::style("✔").for_stderr().green()
411 ))
412 .unwrap();
413 let msg = console::style(format!("{name} - ready"))
414 .for_stderr()
415 .green()
416 .to_string();
417 finish_server_bar(&bar, style, msg, &ctx.reveal_state);
418 } else {
419 let style = ProgressStyle::with_template(&format!(
420 " {} {{msg}}",
421 console::style("✘").for_stderr().red()
422 ))
423 .unwrap();
424 let msg = console::style(format!("{name} - timeout"))
425 .for_stderr()
426 .red()
427 .to_string();
428 finish_server_bar(&bar, style, msg, &ctx.reveal_state);
429 }
430
431 ready
432}
433
434/// Poll grasp servers in parallel until all are ready or timeout is reached.
435///
436/// Shows a concise spinner with `x/y - complete` progress. After 5 s without
437/// all servers responding, expands to show per-server status bars (including
438/// any that already finished). Times out after 15 s (2 s in tests) and
439/// proceeds rather than failing.
440pub async fn wait_for_grasp_servers(
441 git_repo: &Repo,
442 grasp_servers: &[String],
443 public_key: &PublicKey,
444 identifier: &str,
445) -> Result<()> {
446 let clone_urls: Vec<String> = grasp_servers
447 .iter()
448 .filter_map(|gs| format_grasp_server_url_as_clone_url(gs, public_key, identifier).ok())
449 .collect();
450
451 if clone_urls.is_empty() {
452 return Ok(());
453 }
454
455 let is_test = std::env::var("NGITTEST").is_ok();
456 let timeout_secs: u64 = if is_test { 2 } else { 15 };
457 let expand_delay_ms: u64 = if is_test { 500 } else { 5000 };
458 let total = clone_urls.len() as u64;
459
460 let spinner_multi = MultiProgress::new();
461 let spinner_pb = spinner_multi.add(
462 ProgressBar::new_spinner()
463 .with_style(
464 ProgressStyle::with_template("{spinner} {msg}")
465 .unwrap()
466 .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"),
467 )
468 .with_message(format!(
469 "waiting for servers to create bare git repo... (0/{total} - complete)"
470 )),
471 );
472 spinner_pb.enable_steady_tick(Duration::from_millis(100));
473
474 let detail_multi = MultiProgress::with_draw_target(ProgressDrawTarget::hidden());
475 let heading_bar = detail_multi
476 .add(ProgressBar::new(0).with_style(ProgressStyle::with_template("{msg}").unwrap()));
477
478 let ready_count = Arc::new(AtomicU64::new(0));
479 let reveal_state = Arc::new(ServerRevealState {
480 revealed: AtomicBool::new(false),
481 deferred: Mutex::new(Vec::new()),
482 });
483
484 let server_bars = create_server_bars(&clone_urls, &detail_multi);
485
486 let timer_handle = spawn_expand_timer(
487 expand_delay_ms,
488 spinner_pb.clone(),
489 detail_multi.clone(),
490 heading_bar,
491 reveal_state.clone(),
492 server_bars.clone(),
493 );
494
495 let git_repo_path = git_repo.get_path()?.to_path_buf();
496 let poll_ctx = Arc::new(PollContext {
497 timeout_secs,
498 total,
499 ready_count: ready_count.clone(),
500 spinner_pb: spinner_pb.clone(),
501 reveal_state: reveal_state.clone(),
502 });
503 let futures: Vec<_> = clone_urls
504 .iter()
505 .enumerate()
506 .map(|(i, url)| {
507 poll_single_server(
508 url.clone(),
509 git_repo_path.clone(),
510 server_bars[i].clone(),
511 poll_ctx.clone(),
512 )
513 })
514 .collect();
515
516 let results = join_all(futures).await;
517 let final_ready = ready_count.load(Ordering::Relaxed);
518
519 timer_handle.abort();
520
521 if reveal_state.revealed.load(Ordering::Acquire) {
522 let _ = detail_multi.clear();
523 }
524
525 let all_ready = results.iter().all(|&r| r);
526 finalize_spinner(all_ready, &spinner_pb, final_ready, total);
527
528 Ok(())
529}
diff --git a/src/lib/mod.rs b/src/lib/mod.rs
index b388b23..1229e8c 100644
--- a/src/lib/mod.rs
+++ b/src/lib/mod.rs
@@ -1,3 +1,4 @@
1pub mod accept_maintainership;
1pub mod cli_interactor; 2pub mod cli_interactor;
2pub mod client; 3pub mod client;
3pub mod fetch; 4pub mod fetch;
diff --git a/src/lib/repo_ref.rs b/src/lib/repo_ref.rs
index 9573238..c0f9136 100644
--- a/src/lib/repo_ref.rs
+++ b/src/lib/repo_ref.rs
@@ -814,6 +814,71 @@ pub fn format_grasp_server_url_as_clone_url(
814 )) 814 ))
815} 815}
816 816
817/// Find the latest announcement event (by `created_at`) across all maintainer
818/// events and parse it into a `RepoRef` for shared metadata (name, description,
819/// web, etc.).
820pub fn latest_event_repo_ref(repo_ref: &RepoRef) -> Option<RepoRef> {
821 repo_ref
822 .events
823 .values()
824 .max_by_key(|e| e.created_at)
825 .and_then(|e| RepoRef::try_from((e.clone(), None)).ok())
826}
827
828/// Derive clone-URLs and relay URLs from selected grasp servers.
829///
830/// For each grasp server, adds or replaces the corresponding clone URL in
831/// `git_servers` and prepends a relay URL in `relays`. Grasp-derived
832/// infrastructure always takes priority — the other lists contain *additional*
833/// infrastructure beyond what grasp servers provide.
834pub fn apply_grasp_infrastructure(
835 grasp_servers: &[String],
836 git_servers: &mut Vec<String>,
837 relays: &mut Vec<String>,
838 public_key: &PublicKey,
839 identifier: &str,
840) -> Result<()> {
841 for (grasp_relay_insert_idx, grasp_server) in grasp_servers.iter().enumerate() {
842 // Always add grasp-derived clone URL
843 let clone_url = format_grasp_server_url_as_clone_url(grasp_server, public_key, identifier)?;
844
845 let grasp_server_clone_root = if clone_url.contains("https://") {
846 format!("https://{grasp_server}")
847 } else {
848 grasp_server.to_string()
849 };
850
851 let matching_positions: Vec<usize> = git_servers
852 .iter()
853 .enumerate()
854 .filter_map(|(idx, url)| {
855 if url.contains(&grasp_server_clone_root) {
856 Some(idx)
857 } else {
858 None
859 }
860 })
861 .collect();
862
863 if matching_positions.is_empty() {
864 git_servers.push(clone_url);
865 } else {
866 git_servers[matching_positions[0]] = clone_url;
867 for &position in matching_positions.iter().skip(1).rev() {
868 git_servers.remove(position);
869 }
870 }
871
872 // Prepend grasp-derived relay in order (for relay hint) so that the
873 // first grasp server in the list ends up at relays[0].
874 let relay_url = format_grasp_server_url_as_relay_url(grasp_server)?;
875 if !relays.contains(&relay_url) {
876 relays.insert(grasp_relay_insert_idx, relay_url);
877 }
878 }
879 Ok(())
880}
881
817#[cfg(test)] 882#[cfg(test)]
818mod tests { 883mod tests {
819 use test_utils::*; 884 use test_utils::*;