upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/lib/accept_maintainership.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-20 20:09:09 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-20 21:21:48 +0000
commit64747526c9f6ab43f9dac461d056bb42992573b4 (patch)
treec2506828ae7b188e3e4b569cd73202ec37779278 /src/lib/accept_maintainership.rs
parent365dfb9a1e986b68bc2389e2a3cd3da30b0d4636 (diff)
extract grasp/maintainership helpers to lib and auto-accept on push
move apply_grasp_infrastructure, latest_event_repo_ref to lib/repo_ref.rs and wait_for_grasp_servers + grasp_servers_from_user_or_fallback to a new lib/accept_maintainership.rs so both binaries can share them. add accept_maintainership_with_defaults which publishes the co-maintainer's own Kind:30617 announcement with defaults (user grasp servers, shared metadata from existing events) then waits for grasp server provisioning and updates nostr.repo config and origin remote. replace the push error block with a call to accept_maintainership_with_defaults so pushing now silently accepts co-maintainership instead of failing.
Diffstat (limited to 'src/lib/accept_maintainership.rs')
-rw-r--r--src/lib/accept_maintainership.rs529
1 files changed, 529 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}