diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2024-09-04 08:04:48 +0100 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2024-09-04 13:30:59 +0100 |
| commit | 949c6459aa7683453a7160423b689ceadb08954b (patch) | |
| tree | 230c26ecb11b99916e5570e548673eb09ecf0a36 /src/bin/ngit/sub_commands/send.rs | |
| parent | a825311f2c55661aaab3a163bda9109295c96044 (diff) | |
refactor: organise into lib and bin structure
the make the code more readable
this commit just moves the files, the next commit should fix the imports
Diffstat (limited to 'src/bin/ngit/sub_commands/send.rs')
| -rw-r--r-- | src/bin/ngit/sub_commands/send.rs | 1363 |
1 files changed, 1363 insertions, 0 deletions
diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs new file mode 100644 index 0000000..3c4df9d --- /dev/null +++ b/src/bin/ngit/sub_commands/send.rs | |||
| @@ -0,0 +1,1363 @@ | |||
| 1 | use std::{path::Path, str::FromStr, time::Duration}; | ||
| 2 | |||
| 3 | use anyhow::{bail, Context, Result}; | ||
| 4 | use console::Style; | ||
| 5 | use futures::future::join_all; | ||
| 6 | use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; | ||
| 7 | use nostr::{ | ||
| 8 | nips::{ | ||
| 9 | nip01::Coordinate, | ||
| 10 | nip10::Marker, | ||
| 11 | nip19::{Nip19, Nip19Event}, | ||
| 12 | }, | ||
| 13 | EventBuilder, FromBech32, Tag, TagKind, ToBech32, UncheckedUrl, | ||
| 14 | }; | ||
| 15 | use nostr_sdk::{hashes::sha1::Hash as Sha1Hash, Kind, NostrSigner, TagStandard}; | ||
| 16 | |||
| 17 | use super::list::tag_value; | ||
| 18 | #[cfg(not(test))] | ||
| 19 | use crate::client::Client; | ||
| 20 | #[cfg(test)] | ||
| 21 | use crate::client::MockConnect; | ||
| 22 | use crate::{ | ||
| 23 | cli::Cli, | ||
| 24 | cli_interactor::{ | ||
| 25 | Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms, | ||
| 26 | }, | ||
| 27 | client::{ | ||
| 28 | fetching_with_report, get_events_from_cache, get_repo_ref_from_cache, sign_event, Connect, | ||
| 29 | }, | ||
| 30 | git::{Repo, RepoActions}, | ||
| 31 | login, | ||
| 32 | repo_ref::{get_repo_coordinates, RepoRef}, | ||
| 33 | }; | ||
| 34 | |||
| 35 | #[derive(Debug, clap::Args)] | ||
| 36 | pub struct SubCommandArgs { | ||
| 37 | #[arg(default_value = "")] | ||
| 38 | /// commits to send as proposal; like in `git format-patch` eg. HEAD~2 | ||
| 39 | pub(crate) since_or_range: String, | ||
| 40 | #[clap(long, value_parser, num_args = 0.., value_delimiter = ' ')] | ||
| 41 | /// references to an existing proposal for which this is a new | ||
| 42 | /// version and/or events / npubs to tag as mentions | ||
| 43 | pub(crate) in_reply_to: Vec<String>, | ||
| 44 | /// don't prompt for a cover letter | ||
| 45 | #[arg(long, action)] | ||
| 46 | pub(crate) no_cover_letter: bool, | ||
| 47 | /// optional cover letter title | ||
| 48 | #[clap(short, long)] | ||
| 49 | pub(crate) title: Option<String>, | ||
| 50 | #[clap(short, long)] | ||
| 51 | /// optional cover letter description | ||
| 52 | pub(crate) description: Option<String>, | ||
| 53 | } | ||
| 54 | |||
| 55 | #[allow(clippy::too_many_lines)] | ||
| 56 | pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Result<()> { | ||
| 57 | let git_repo = Repo::discover().context("cannot find a git repository")?; | ||
| 58 | let git_repo_path = git_repo.get_path()?; | ||
| 59 | |||
| 60 | let (main_branch_name, main_tip) = git_repo | ||
| 61 | .get_main_or_master_branch() | ||
| 62 | .context("the default branches (main or master) do not exist")?; | ||
| 63 | |||
| 64 | #[cfg(not(test))] | ||
| 65 | let mut client = Client::default(); | ||
| 66 | #[cfg(test)] | ||
| 67 | let mut client = <MockConnect as std::default::Default>::default(); | ||
| 68 | |||
| 69 | let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; | ||
| 70 | |||
| 71 | if !no_fetch { | ||
| 72 | fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; | ||
| 73 | } | ||
| 74 | |||
| 75 | let (root_proposal_id, mention_tags) = | ||
| 76 | get_root_proposal_id_and_mentions_from_in_reply_to(git_repo.get_path()?, &args.in_reply_to) | ||
| 77 | .await?; | ||
| 78 | |||
| 79 | if let Some(root_ref) = args.in_reply_to.first() { | ||
| 80 | if root_proposal_id.is_some() { | ||
| 81 | println!("creating proposal revision for: {root_ref}"); | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | let mut commits: Vec<Sha1Hash> = { | ||
| 86 | if args.since_or_range.is_empty() { | ||
| 87 | let branch_name = git_repo.get_checked_out_branch_name()?; | ||
| 88 | let proposed_commits = if branch_name.eq(main_branch_name) { | ||
| 89 | vec![main_tip] | ||
| 90 | } else { | ||
| 91 | let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?; | ||
| 92 | ahead | ||
| 93 | }; | ||
| 94 | choose_commits(&git_repo, proposed_commits)? | ||
| 95 | } else { | ||
| 96 | git_repo | ||
| 97 | .parse_starting_commits(&args.since_or_range) | ||
| 98 | .context("cannot parse specified starting commit or range")? | ||
| 99 | } | ||
| 100 | }; | ||
| 101 | |||
| 102 | if commits.is_empty() { | ||
| 103 | bail!("no commits selected"); | ||
| 104 | } | ||
| 105 | println!("creating proposal from {} commits:", commits.len()); | ||
| 106 | |||
| 107 | let dim = Style::new().color256(247); | ||
| 108 | for commit in &commits { | ||
| 109 | println!( | ||
| 110 | "{} {}", | ||
| 111 | dim.apply_to(commit.to_string().chars().take(7).collect::<String>()), | ||
| 112 | git_repo.get_commit_message_summary(commit)? | ||
| 113 | ); | ||
| 114 | } | ||
| 115 | |||
| 116 | let (first_commit_ahead, behind) = | ||
| 117 | git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?; | ||
| 118 | |||
| 119 | // check proposal ahead of origin/main | ||
| 120 | if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm( | ||
| 121 | PromptConfirmParms::default() | ||
| 122 | .with_prompt( | ||
| 123 | format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) | ||
| 124 | ) | ||
| 125 | .with_default(false) | ||
| 126 | ).context("failed to get confirmation response from interactor confirm")? { | ||
| 127 | bail!("aborting because selected commits were ahead of origin/master"); | ||
| 128 | } | ||
| 129 | |||
| 130 | // check if a selected commit is already in origin | ||
| 131 | if commits.iter().any(|c| c.eq(&main_tip)) { | ||
| 132 | if !Interactor::default().confirm( | ||
| 133 | PromptConfirmParms::default() | ||
| 134 | .with_prompt( | ||
| 135 | format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?") | ||
| 136 | ) | ||
| 137 | .with_default(false) | ||
| 138 | ).context("failed to get confirmation response from interactor confirm")? { | ||
| 139 | bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'"); | ||
| 140 | } | ||
| 141 | } | ||
| 142 | // check proposal isn't behind origin/main | ||
| 143 | else if !behind.is_empty() && !Interactor::default().confirm( | ||
| 144 | PromptConfirmParms::default() | ||
| 145 | .with_prompt( | ||
| 146 | format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len()) | ||
| 147 | ) | ||
| 148 | .with_default(false) | ||
| 149 | ).context("failed to get confirmation response from interactor confirm")? { | ||
| 150 | bail!("aborting so commits can be rebased"); | ||
| 151 | } | ||
| 152 | |||
| 153 | let title = if args.no_cover_letter { | ||
| 154 | None | ||
| 155 | } else { | ||
| 156 | match &args.title { | ||
| 157 | Some(t) => Some(t.clone()), | ||
| 158 | None => { | ||
| 159 | if Interactor::default().confirm( | ||
| 160 | PromptConfirmParms::default() | ||
| 161 | .with_default(false) | ||
| 162 | .with_prompt("include cover letter?"), | ||
| 163 | )? { | ||
| 164 | Some( | ||
| 165 | Interactor::default() | ||
| 166 | .input(PromptInputParms::default().with_prompt("title"))? | ||
| 167 | .clone(), | ||
| 168 | ) | ||
| 169 | } else { | ||
| 170 | None | ||
| 171 | } | ||
| 172 | } | ||
| 173 | } | ||
| 174 | }; | ||
| 175 | |||
| 176 | let cover_letter_title_description = if let Some(title) = title { | ||
| 177 | Some(( | ||
| 178 | title, | ||
| 179 | if let Some(t) = &args.description { | ||
| 180 | t.clone() | ||
| 181 | } else { | ||
| 182 | Interactor::default() | ||
| 183 | .input(PromptInputParms::default().with_prompt("cover letter description"))? | ||
| 184 | .clone() | ||
| 185 | }, | ||
| 186 | )) | ||
| 187 | } else { | ||
| 188 | None | ||
| 189 | }; | ||
| 190 | let (signer, user_ref) = login::launch( | ||
| 191 | &git_repo, | ||
| 192 | &cli_args.bunker_uri, | ||
| 193 | &cli_args.bunker_app_key, | ||
| 194 | &cli_args.nsec, | ||
| 195 | &cli_args.password, | ||
| 196 | Some(&client), | ||
| 197 | false, | ||
| 198 | false, | ||
| 199 | ) | ||
| 200 | .await?; | ||
| 201 | |||
| 202 | client.set_signer(signer.clone()).await; | ||
| 203 | |||
| 204 | let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; | ||
| 205 | |||
| 206 | // oldest first | ||
| 207 | commits.reverse(); | ||
| 208 | |||
| 209 | let events = generate_cover_letter_and_patch_events( | ||
| 210 | cover_letter_title_description.clone(), | ||
| 211 | &git_repo, | ||
| 212 | &commits, | ||
| 213 | &signer, | ||
| 214 | &repo_ref, | ||
| 215 | &root_proposal_id, | ||
| 216 | &mention_tags, | ||
| 217 | ) | ||
| 218 | .await?; | ||
| 219 | |||
| 220 | println!( | ||
| 221 | "posting {} patch{} {} a covering letter...", | ||
| 222 | if cover_letter_title_description.is_none() { | ||
| 223 | events.len() | ||
| 224 | } else { | ||
| 225 | events.len() - 1 | ||
| 226 | }, | ||
| 227 | if cover_letter_title_description.is_none() && events.len().eq(&1) | ||
| 228 | || cover_letter_title_description.is_some() && events.len().eq(&2) | ||
| 229 | { | ||
| 230 | "" | ||
| 231 | } else { | ||
| 232 | "es" | ||
| 233 | }, | ||
| 234 | if cover_letter_title_description.is_none() { | ||
| 235 | "without" | ||
| 236 | } else { | ||
| 237 | "with" | ||
| 238 | } | ||
| 239 | ); | ||
| 240 | |||
| 241 | send_events( | ||
| 242 | &client, | ||
| 243 | git_repo_path, | ||
| 244 | events.clone(), | ||
| 245 | user_ref.relays.write(), | ||
| 246 | repo_ref.relays.clone(), | ||
| 247 | !cli_args.disable_cli_spinners, | ||
| 248 | false, | ||
| 249 | ) | ||
| 250 | .await?; | ||
| 251 | |||
| 252 | if root_proposal_id.is_none() { | ||
| 253 | if let Some(event) = events.first() { | ||
| 254 | let event_bech32 = if let Some(relay) = repo_ref.relays.first() { | ||
| 255 | Nip19Event::new(event.id(), vec![relay]).to_bech32()? | ||
| 256 | } else { | ||
| 257 | event.id().to_bech32()? | ||
| 258 | }; | ||
| 259 | println!( | ||
| 260 | "{}", | ||
| 261 | dim.apply_to(format!( | ||
| 262 | "view in gitworkshop.dev: https://gitworkshop.dev/repo/{}/proposal/{}", | ||
| 263 | repo_ref.coordinate_with_hint().to_bech32()?, | ||
| 264 | &event_bech32, | ||
| 265 | )) | ||
| 266 | ); | ||
| 267 | println!( | ||
| 268 | "{}", | ||
| 269 | dim.apply_to(format!( | ||
| 270 | "view in another client: https://njump.me/{}", | ||
| 271 | &event_bech32, | ||
| 272 | )) | ||
| 273 | ); | ||
| 274 | } | ||
| 275 | } | ||
| 276 | // TODO check if there is already a similarly named | ||
| 277 | Ok(()) | ||
| 278 | } | ||
| 279 | |||
| 280 | #[allow(clippy::module_name_repetitions)] | ||
| 281 | #[allow(clippy::too_many_lines)] | ||
| 282 | pub async fn send_events( | ||
| 283 | #[cfg(test)] client: &crate::client::MockConnect, | ||
| 284 | #[cfg(not(test))] client: &Client, | ||
| 285 | git_repo_path: &Path, | ||
| 286 | events: Vec<nostr::Event>, | ||
| 287 | my_write_relays: Vec<String>, | ||
| 288 | repo_read_relays: Vec<String>, | ||
| 289 | animate: bool, | ||
| 290 | silent: bool, | ||
| 291 | ) -> Result<()> { | ||
| 292 | let fallback = [ | ||
| 293 | client.get_fallback_relays().clone(), | ||
| 294 | if events | ||
| 295 | .iter() | ||
| 296 | .any(|e| e.kind().eq(&Kind::GitRepoAnnouncement)) | ||
| 297 | { | ||
| 298 | client.get_blaster_relays().clone() | ||
| 299 | } else { | ||
| 300 | vec![] | ||
| 301 | }, | ||
| 302 | ] | ||
| 303 | .concat(); | ||
| 304 | let mut relays: Vec<&String> = vec![]; | ||
| 305 | |||
| 306 | let all = &[ | ||
| 307 | repo_read_relays.clone(), | ||
| 308 | my_write_relays.clone(), | ||
| 309 | fallback.clone(), | ||
| 310 | ] | ||
| 311 | .concat(); | ||
| 312 | // add duplicates first | ||
| 313 | for r in &repo_read_relays { | ||
| 314 | let r_clean = remove_trailing_slash(r); | ||
| 315 | if !my_write_relays | ||
| 316 | .iter() | ||
| 317 | .filter(|x| r_clean.eq(&remove_trailing_slash(x))) | ||
| 318 | .count() | ||
| 319 | > 1 | ||
| 320 | && !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) | ||
| 321 | { | ||
| 322 | relays.push(r); | ||
| 323 | } | ||
| 324 | } | ||
| 325 | |||
| 326 | for r in all { | ||
| 327 | let r_clean = remove_trailing_slash(r); | ||
| 328 | if !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) { | ||
| 329 | relays.push(r); | ||
| 330 | } | ||
| 331 | } | ||
| 332 | |||
| 333 | let m = if silent { | ||
| 334 | MultiProgress::with_draw_target(ProgressDrawTarget::hidden()) | ||
| 335 | } else { | ||
| 336 | MultiProgress::new() | ||
| 337 | }; | ||
| 338 | let pb_style = ProgressStyle::with_template(if animate { | ||
| 339 | " {spinner} {prefix} {bar} {pos}/{len} {msg}" | ||
| 340 | } else { | ||
| 341 | " - {prefix} {bar} {pos}/{len} {msg}" | ||
| 342 | })? | ||
| 343 | .progress_chars("##-"); | ||
| 344 | |||
| 345 | let pb_after_style = | ||
| 346 | |symbol| ProgressStyle::with_template(format!(" {symbol} {}", "{prefix} {msg}",).as_str()); | ||
| 347 | let pb_after_style_succeeded = pb_after_style(if animate { | ||
| 348 | console::style("✔".to_string()) | ||
| 349 | .for_stderr() | ||
| 350 | .green() | ||
| 351 | .to_string() | ||
| 352 | } else { | ||
| 353 | "y".to_string() | ||
| 354 | })?; | ||
| 355 | |||
| 356 | let pb_after_style_failed = pb_after_style(if animate { | ||
| 357 | console::style("✘".to_string()) | ||
| 358 | .for_stderr() | ||
| 359 | .red() | ||
| 360 | .to_string() | ||
| 361 | } else { | ||
| 362 | "x".to_string() | ||
| 363 | })?; | ||
| 364 | |||
| 365 | #[allow(clippy::borrow_deref_ref)] | ||
| 366 | join_all(relays.iter().map(|&relay| async { | ||
| 367 | let relay_clean = remove_trailing_slash(&*relay); | ||
| 368 | let details = format!( | ||
| 369 | "{}{}{} {}", | ||
| 370 | if my_write_relays | ||
| 371 | .iter() | ||
| 372 | .any(|r| relay_clean.eq(&remove_trailing_slash(r))) | ||
| 373 | { | ||
| 374 | " [my-relay]" | ||
| 375 | } else { | ||
| 376 | "" | ||
| 377 | }, | ||
| 378 | if repo_read_relays | ||
| 379 | .iter() | ||
| 380 | .any(|r| relay_clean.eq(&remove_trailing_slash(r))) | ||
| 381 | { | ||
| 382 | " [repo-relay]" | ||
| 383 | } else { | ||
| 384 | "" | ||
| 385 | }, | ||
| 386 | if fallback | ||
| 387 | .iter() | ||
| 388 | .any(|r| relay_clean.eq(&remove_trailing_slash(r))) | ||
| 389 | { | ||
| 390 | " [default]" | ||
| 391 | } else { | ||
| 392 | "" | ||
| 393 | }, | ||
| 394 | relay_clean, | ||
| 395 | ); | ||
| 396 | let pb = m.add( | ||
| 397 | ProgressBar::new(events.len() as u64) | ||
| 398 | .with_prefix(details.to_string()) | ||
| 399 | .with_style(pb_style.clone()), | ||
| 400 | ); | ||
| 401 | if animate { | ||
| 402 | pb.enable_steady_tick(Duration::from_millis(300)); | ||
| 403 | } | ||
| 404 | pb.inc(0); // need to make pb display intially | ||
| 405 | let mut failed = false; | ||
| 406 | for event in &events { | ||
| 407 | match client | ||
| 408 | .send_event_to(git_repo_path, relay.as_str(), event.clone()) | ||
| 409 | .await | ||
| 410 | { | ||
| 411 | Ok(_) => pb.inc(1), | ||
| 412 | Err(e) => { | ||
| 413 | pb.set_style(pb_after_style_failed.clone()); | ||
| 414 | pb.finish_with_message( | ||
| 415 | console::style( | ||
| 416 | e.to_string() | ||
| 417 | .replace("relay pool error:", "error:") | ||
| 418 | .replace("event not published: ", "error: "), | ||
| 419 | ) | ||
| 420 | .for_stderr() | ||
| 421 | .red() | ||
| 422 | .to_string(), | ||
| 423 | ); | ||
| 424 | failed = true; | ||
| 425 | break; | ||
| 426 | } | ||
| 427 | }; | ||
| 428 | } | ||
| 429 | if !failed { | ||
| 430 | pb.set_style(pb_after_style_succeeded.clone()); | ||
| 431 | pb.finish_with_message(""); | ||
| 432 | } | ||
| 433 | })) | ||
| 434 | .await; | ||
| 435 | Ok(()) | ||
| 436 | } | ||
| 437 | |||
| 438 | fn remove_trailing_slash(s: &String) -> String { | ||
| 439 | match s.as_str().strip_suffix('/') { | ||
| 440 | Some(s) => s, | ||
| 441 | None => s, | ||
| 442 | } | ||
| 443 | .to_string() | ||
| 444 | } | ||
| 445 | |||
| 446 | fn choose_commits(git_repo: &Repo, proposed_commits: Vec<Sha1Hash>) -> Result<Vec<Sha1Hash>> { | ||
| 447 | let mut proposed_commits = if proposed_commits.len().gt(&10) { | ||
| 448 | vec![] | ||
| 449 | } else { | ||
| 450 | proposed_commits | ||
| 451 | }; | ||
| 452 | |||
| 453 | let tip_of_head = git_repo.get_tip_of_branch(&git_repo.get_checked_out_branch_name()?)?; | ||
| 454 | let most_recent_commit = proposed_commits.first().unwrap_or(&tip_of_head); | ||
| 455 | |||
| 456 | let mut last_15_commits = vec![*most_recent_commit]; | ||
| 457 | |||
| 458 | while last_15_commits.len().lt(&15) { | ||
| 459 | if let Ok(parent_commit) = git_repo.get_commit_parent(last_15_commits.last().unwrap()) { | ||
| 460 | last_15_commits.push(parent_commit); | ||
| 461 | } else { | ||
| 462 | break; | ||
| 463 | } | ||
| 464 | } | ||
| 465 | |||
| 466 | let term = console::Term::stderr(); | ||
| 467 | let mut printed_error_line = false; | ||
| 468 | |||
| 469 | let selected_commits = 'outer: loop { | ||
| 470 | let selected = Interactor::default().multi_choice( | ||
| 471 | PromptMultiChoiceParms::default() | ||
| 472 | .with_prompt("select commits for proposal") | ||
| 473 | .dont_report() | ||
| 474 | .with_choices( | ||
| 475 | last_15_commits | ||
| 476 | .iter() | ||
| 477 | .map(|h| summarise_commit_for_selection(git_repo, h).unwrap()) | ||
| 478 | .collect(), | ||
| 479 | ) | ||
| 480 | .with_defaults( | ||
| 481 | last_15_commits | ||
| 482 | .iter() | ||
| 483 | .map(|h| proposed_commits.iter().any(|c| c.eq(h))) | ||
| 484 | .collect(), | ||
| 485 | ), | ||
| 486 | )?; | ||
| 487 | proposed_commits = selected.iter().map(|i| last_15_commits[*i]).collect(); | ||
| 488 | |||
| 489 | if printed_error_line { | ||
| 490 | term.clear_last_lines(1)?; | ||
| 491 | } | ||
| 492 | |||
| 493 | if proposed_commits.is_empty() { | ||
| 494 | term.write_line("no commits selected")?; | ||
| 495 | printed_error_line = true; | ||
| 496 | continue; | ||
| 497 | } | ||
| 498 | for (i, selected_i) in selected.iter().enumerate() { | ||
| 499 | if i.gt(&0) && selected_i.ne(&(selected[i - 1] + 1)) { | ||
| 500 | term.write_line("commits must be consecutive. try again.")?; | ||
| 501 | printed_error_line = true; | ||
| 502 | continue 'outer; | ||
| 503 | } | ||
| 504 | } | ||
| 505 | |||
| 506 | break proposed_commits; | ||
| 507 | }; | ||
| 508 | Ok(selected_commits) | ||
| 509 | } | ||
| 510 | |||
| 511 | fn summarise_commit_for_selection(git_repo: &Repo, commit: &Sha1Hash) -> Result<String> { | ||
| 512 | let references = git_repo.get_refs(commit)?; | ||
| 513 | let dim = Style::new().color256(247); | ||
| 514 | let prefix = format!("({})", git_repo.get_commit_author(commit)?[0],); | ||
| 515 | let references_string = if references.is_empty() { | ||
| 516 | String::new() | ||
| 517 | } else { | ||
| 518 | format!( | ||
| 519 | " {}", | ||
| 520 | references | ||
| 521 | .iter() | ||
| 522 | .map(|r| format!("[{r}]")) | ||
| 523 | .collect::<Vec<String>>() | ||
| 524 | .join(" ") | ||
| 525 | ) | ||
| 526 | }; | ||
| 527 | |||
| 528 | Ok(format!( | ||
| 529 | "{} {}{} {}", | ||
| 530 | dim.apply_to(prefix), | ||
| 531 | git_repo.get_commit_message_summary(commit)?, | ||
| 532 | Style::new().magenta().apply_to(references_string), | ||
| 533 | dim.apply_to(commit.to_string().chars().take(7).collect::<String>(),), | ||
| 534 | )) | ||
| 535 | } | ||
| 536 | |||
| 537 | async fn get_root_proposal_id_and_mentions_from_in_reply_to( | ||
| 538 | git_repo_path: &Path, | ||
| 539 | in_reply_to: &[String], | ||
| 540 | ) -> Result<(Option<String>, Vec<nostr::Tag>)> { | ||
| 541 | let root_proposal_id = if let Some(first) = in_reply_to.first() { | ||
| 542 | match event_tag_from_nip19_or_hex(first, "in-reply-to", Marker::Root, true, false)? | ||
| 543 | .as_standardized() | ||
| 544 | { | ||
| 545 | Some(nostr_sdk::TagStandard::Event { | ||
| 546 | event_id, | ||
| 547 | relay_url: _, | ||
| 548 | marker: _, | ||
| 549 | public_key: _, | ||
| 550 | }) => { | ||
| 551 | let events = | ||
| 552 | get_events_from_cache(git_repo_path, vec![nostr::Filter::new().id(*event_id)]) | ||
| 553 | .await?; | ||
| 554 | |||
| 555 | if let Some(first) = events.iter().find(|e| e.id.eq(event_id)) { | ||
| 556 | if event_is_patch_set_root(first) { | ||
| 557 | Some(event_id.to_string()) | ||
| 558 | } else { | ||
| 559 | None | ||
| 560 | } | ||
| 561 | } else { | ||
| 562 | None | ||
| 563 | } | ||
| 564 | } | ||
| 565 | _ => None, | ||
| 566 | } | ||
| 567 | } else { | ||
| 568 | return Ok((None, vec![])); | ||
| 569 | }; | ||
| 570 | |||
| 571 | let mut mention_tags = vec![]; | ||
| 572 | for (i, reply_to) in in_reply_to.iter().enumerate() { | ||
| 573 | if i.ne(&0) || root_proposal_id.is_none() { | ||
| 574 | mention_tags.push( | ||
| 575 | event_tag_from_nip19_or_hex(reply_to, "in-reply-to", Marker::Mention, true, false) | ||
| 576 | .context(format!( | ||
| 577 | "{reply_to} in 'in-reply-to' not a valid nostr reference" | ||
| 578 | ))?, | ||
| 579 | ); | ||
| 580 | } | ||
| 581 | } | ||
| 582 | |||
| 583 | Ok((root_proposal_id, mention_tags)) | ||
| 584 | } | ||
| 585 | |||
| 586 | #[allow(clippy::too_many_lines)] | ||
| 587 | pub async fn generate_cover_letter_and_patch_events( | ||
| 588 | cover_letter_title_description: Option<(String, String)>, | ||
| 589 | git_repo: &Repo, | ||
| 590 | commits: &[Sha1Hash], | ||
| 591 | signer: &NostrSigner, | ||
| 592 | repo_ref: &RepoRef, | ||
| 593 | root_proposal_id: &Option<String>, | ||
| 594 | mentions: &[nostr::Tag], | ||
| 595 | ) -> Result<Vec<nostr::Event>> { | ||
| 596 | let root_commit = git_repo | ||
| 597 | .get_root_commit() | ||
| 598 | .context("failed to get root commit of the repository")?; | ||
| 599 | |||
| 600 | let mut events = vec![]; | ||
| 601 | |||
| 602 | if let Some((title, description)) = cover_letter_title_description { | ||
| 603 | events.push(sign_event(EventBuilder::new( | ||
| 604 | nostr::event::Kind::GitPatch, | ||
| 605 | format!( | ||
| 606 | "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}", | ||
| 607 | commits.last().unwrap(), | ||
| 608 | commits.len() | ||
| 609 | ), | ||
| 610 | [ | ||
| 611 | repo_ref.maintainers.iter().map(|m| Tag::coordinate(Coordinate { | ||
| 612 | kind: nostr::Kind::GitRepoAnnouncement, | ||
| 613 | public_key: *m, | ||
| 614 | identifier: repo_ref.identifier.to_string(), | ||
| 615 | relays: repo_ref.relays.clone(), | ||
| 616 | })).collect::<Vec<Tag>>(), | ||
| 617 | vec![ | ||
| 618 | Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))), | ||
| 619 | Tag::hashtag("cover-letter"), | ||
| 620 | Tag::custom( | ||
| 621 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), | ||
| 622 | vec![format!("git patch cover letter: {}", title.clone())], | ||
| 623 | ), | ||
| 624 | ], | ||
| 625 | if let Some(event_ref) = root_proposal_id.clone() { | ||
| 626 | vec![ | ||
| 627 | Tag::hashtag("root"), | ||
| 628 | Tag::hashtag("revision-root"), | ||
| 629 | // TODO check if id is for a root proposal (perhaps its for an issue?) | ||
| 630 | event_tag_from_nip19_or_hex(&event_ref,"proposal",Marker::Reply, false, false)?, | ||
| 631 | ] | ||
| 632 | } else { | ||
| 633 | vec![ | ||
| 634 | Tag::hashtag("root"), | ||
| 635 | ] | ||
| 636 | }, | ||
| 637 | mentions.to_vec(), | ||
| 638 | // this is not strictly needed but makes for prettier branch names | ||
| 639 | // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding | ||
| 640 | // a change like this, or the removal of this tag will require the actual branch name to be tracked | ||
| 641 | // so pulling and pushing still work | ||
| 642 | if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { | ||
| 643 | if !branch_name.eq("main") | ||
| 644 | && !branch_name.eq("master") | ||
| 645 | && !branch_name.eq("origin/main") | ||
| 646 | && !branch_name.eq("origin/master") | ||
| 647 | { | ||
| 648 | vec![ | ||
| 649 | Tag::custom( | ||
| 650 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), | ||
| 651 | vec![if let Some(branch_name) = branch_name.strip_prefix("pr/") { | ||
| 652 | branch_name.to_string() | ||
| 653 | } else { | ||
| 654 | branch_name | ||
| 655 | }], | ||
| 656 | ), | ||
| 657 | ] | ||
| 658 | } | ||
| 659 | else { vec![] } | ||
| 660 | } else { | ||
| 661 | vec![] | ||
| 662 | }, | ||
| 663 | repo_ref.maintainers | ||
| 664 | .iter() | ||
| 665 | .map(|pk| Tag::public_key(*pk)) | ||
| 666 | .collect(), | ||
| 667 | ].concat(), | ||
| 668 | ), signer).await | ||
| 669 | .context("failed to create cover-letter event")?); | ||
| 670 | } | ||
| 671 | |||
| 672 | for (i, commit) in commits.iter().enumerate() { | ||
| 673 | events.push( | ||
| 674 | generate_patch_event( | ||
| 675 | git_repo, | ||
| 676 | &root_commit, | ||
| 677 | commit, | ||
| 678 | events.first().map(|event| event.id), | ||
| 679 | signer, | ||
| 680 | repo_ref, | ||
| 681 | events.last().map(nostr::Event::id), | ||
| 682 | if events.is_empty() && commits.len().eq(&1) { | ||
| 683 | None | ||
| 684 | } else { | ||
| 685 | Some(((i + 1).try_into()?, commits.len().try_into()?)) | ||
| 686 | }, | ||
| 687 | if events.is_empty() { | ||
| 688 | if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { | ||
| 689 | if !branch_name.eq("main") | ||
| 690 | && !branch_name.eq("master") | ||
| 691 | && !branch_name.eq("origin/main") | ||
| 692 | && !branch_name.eq("origin/master") | ||
| 693 | { | ||
| 694 | Some(if let Some(branch_name) = branch_name.strip_prefix("pr/") { | ||
| 695 | branch_name.to_string() | ||
| 696 | } else { | ||
| 697 | branch_name | ||
| 698 | }) | ||
| 699 | } else { | ||
| 700 | None | ||
| 701 | } | ||
| 702 | } else { | ||
| 703 | None | ||
| 704 | } | ||
| 705 | } else { | ||
| 706 | None | ||
| 707 | }, | ||
| 708 | root_proposal_id, | ||
| 709 | if events.is_empty() { mentions } else { &[] }, | ||
| 710 | ) | ||
| 711 | .await | ||
| 712 | .context("failed to generate patch event")?, | ||
| 713 | ); | ||
| 714 | } | ||
| 715 | Ok(events) | ||
| 716 | } | ||
| 717 | |||
| 718 | fn event_tag_from_nip19_or_hex( | ||
| 719 | reference: &str, | ||
| 720 | reference_name: &str, | ||
| 721 | marker: Marker, | ||
| 722 | allow_npub_reference: bool, | ||
| 723 | prompt_for_correction: bool, | ||
| 724 | ) -> Result<nostr::Tag> { | ||
| 725 | let mut bech32 = reference.to_string(); | ||
| 726 | loop { | ||
| 727 | if bech32.is_empty() { | ||
| 728 | bech32 = Interactor::default().input( | ||
| 729 | PromptInputParms::default().with_prompt(&format!("{reference_name} reference")), | ||
| 730 | )?; | ||
| 731 | } | ||
| 732 | if let Ok(nip19) = Nip19::from_bech32(bech32.clone()) { | ||
| 733 | match nip19 { | ||
| 734 | Nip19::Event(n) => { | ||
| 735 | break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { | ||
| 736 | event_id: n.event_id, | ||
| 737 | relay_url: n.relays.first().map(UncheckedUrl::new), | ||
| 738 | marker: Some(marker), | ||
| 739 | public_key: None, | ||
| 740 | })); | ||
| 741 | } | ||
| 742 | Nip19::EventId(id) => { | ||
| 743 | break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { | ||
| 744 | event_id: id, | ||
| 745 | relay_url: None, | ||
| 746 | marker: Some(marker), | ||
| 747 | public_key: None, | ||
| 748 | })); | ||
| 749 | } | ||
| 750 | Nip19::Coordinate(coordinate) => { | ||
| 751 | break Ok(Tag::coordinate(coordinate)); | ||
| 752 | } | ||
| 753 | Nip19::Profile(profile) => { | ||
| 754 | if allow_npub_reference { | ||
| 755 | break Ok(Tag::public_key(profile.public_key)); | ||
| 756 | } | ||
| 757 | } | ||
| 758 | Nip19::Pubkey(public_key) => { | ||
| 759 | if allow_npub_reference { | ||
| 760 | break Ok(Tag::public_key(public_key)); | ||
| 761 | } | ||
| 762 | } | ||
| 763 | _ => {} | ||
| 764 | } | ||
| 765 | } | ||
| 766 | if let Ok(id) = nostr::EventId::from_str(&bech32) { | ||
| 767 | break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { | ||
| 768 | event_id: id, | ||
| 769 | relay_url: None, | ||
| 770 | marker: Some(marker), | ||
| 771 | public_key: None, | ||
| 772 | })); | ||
| 773 | } | ||
| 774 | if prompt_for_correction { | ||
| 775 | println!("not a valid {reference_name} event reference"); | ||
| 776 | } else { | ||
| 777 | bail!(format!("not a valid {reference_name} event reference")); | ||
| 778 | } | ||
| 779 | |||
| 780 | bech32 = String::new(); | ||
| 781 | } | ||
| 782 | } | ||
| 783 | |||
| 784 | pub struct CoverLetter { | ||
| 785 | pub title: String, | ||
| 786 | pub description: String, | ||
| 787 | pub branch_name: String, | ||
| 788 | pub event_id: Option<nostr::EventId>, | ||
| 789 | } | ||
| 790 | |||
| 791 | impl CoverLetter { | ||
| 792 | pub fn get_branch_name(&self) -> Result<String> { | ||
| 793 | Ok(format!( | ||
| 794 | "pr/{}({})", | ||
| 795 | self.branch_name, | ||
| 796 | &self | ||
| 797 | .event_id | ||
| 798 | .context("proposal root event_id must be know to get it's branch name")? | ||
| 799 | .to_hex() | ||
| 800 | .as_str()[..8], | ||
| 801 | )) | ||
| 802 | } | ||
| 803 | } | ||
| 804 | pub fn event_is_cover_letter(event: &nostr::Event) -> bool { | ||
| 805 | // TODO: look for Subject:[ PATCH 0/n ] but watch out for: | ||
| 806 | // [PATCH v1 0/n ] or | ||
| 807 | // [PATCH subsystem v2 0/n ] | ||
| 808 | event.kind.eq(&Kind::GitPatch) | ||
| 809 | && event.tags().iter().any(|t| t.as_vec()[1].eq("root")) | ||
| 810 | && event | ||
| 811 | .tags() | ||
| 812 | .iter() | ||
| 813 | .any(|t| t.as_vec()[1].eq("cover-letter")) | ||
| 814 | } | ||
| 815 | |||
| 816 | pub fn commit_msg_from_patch(patch: &nostr::Event) -> Result<String> { | ||
| 817 | if let Ok(msg) = tag_value(patch, "description") { | ||
| 818 | Ok(msg) | ||
| 819 | } else { | ||
| 820 | let start_index = patch | ||
| 821 | .content | ||
| 822 | .find("] ") | ||
| 823 | .context("event is not formatted as a patch or cover letter")? | ||
| 824 | + 2; | ||
| 825 | let end_index = patch.content[start_index..] | ||
| 826 | .find("\ndiff --git") | ||
| 827 | .unwrap_or(patch.content.len()); | ||
| 828 | Ok(patch.content[start_index..end_index].to_string()) | ||
| 829 | } | ||
| 830 | } | ||
| 831 | |||
| 832 | pub fn commit_msg_from_patch_oneliner(patch: &nostr::Event) -> Result<String> { | ||
| 833 | Ok(commit_msg_from_patch(patch)? | ||
| 834 | .split('\n') | ||
| 835 | .collect::<Vec<&str>>()[0] | ||
| 836 | .to_string()) | ||
| 837 | } | ||
| 838 | |||
| 839 | pub fn event_to_cover_letter(event: &nostr::Event) -> Result<CoverLetter> { | ||
| 840 | if !event_is_patch_set_root(event) { | ||
| 841 | bail!("event is not a patch set root event (root patch or cover letter)") | ||
| 842 | } | ||
| 843 | |||
| 844 | let title = commit_msg_from_patch_oneliner(event)?; | ||
| 845 | let full = commit_msg_from_patch(event)?; | ||
| 846 | let description = full[title.len()..].trim().to_string(); | ||
| 847 | |||
| 848 | Ok(CoverLetter { | ||
| 849 | title: title.clone(), | ||
| 850 | description, | ||
| 851 | // TODO should this be prefixed by format!("{}-"e.id.to_string()[..5]?) | ||
| 852 | branch_name: if let Ok(name) = match tag_value(event, "branch-name") { | ||
| 853 | Ok(name) => { | ||
| 854 | if !name.eq("main") && !name.eq("master") { | ||
| 855 | Ok(name) | ||
| 856 | } else { | ||
| 857 | Err(()) | ||
| 858 | } | ||
| 859 | } | ||
| 860 | _ => Err(()), | ||
| 861 | } { | ||
| 862 | name | ||
| 863 | } else { | ||
| 864 | let s = title | ||
| 865 | .replace(' ', "-") | ||
| 866 | .chars() | ||
| 867 | .map(|c| { | ||
| 868 | if c.is_ascii_alphanumeric() || c.eq(&'/') { | ||
| 869 | c | ||
| 870 | } else { | ||
| 871 | '-' | ||
| 872 | } | ||
| 873 | }) | ||
| 874 | .collect(); | ||
| 875 | s | ||
| 876 | }, | ||
| 877 | event_id: Some(event.id()), | ||
| 878 | }) | ||
| 879 | } | ||
| 880 | |||
| 881 | pub fn event_is_patch_set_root(event: &nostr::Event) -> bool { | ||
| 882 | event.kind.eq(&Kind::GitPatch) && event.tags().iter().any(|t| t.as_vec()[1].eq("root")) | ||
| 883 | } | ||
| 884 | |||
| 885 | pub fn event_is_revision_root(event: &nostr::Event) -> bool { | ||
| 886 | event.kind.eq(&Kind::GitPatch) | ||
| 887 | && event | ||
| 888 | .tags() | ||
| 889 | .iter() | ||
| 890 | .any(|t| t.as_vec()[1].eq("revision-root")) | ||
| 891 | } | ||
| 892 | |||
| 893 | pub fn patch_supports_commit_ids(event: &nostr::Event) -> bool { | ||
| 894 | event.kind.eq(&Kind::GitPatch) | ||
| 895 | && event | ||
| 896 | .tags() | ||
| 897 | .iter() | ||
| 898 | .any(|t| t.as_vec()[0].eq("commit-pgp-sig")) | ||
| 899 | } | ||
| 900 | |||
| 901 | #[allow(clippy::too_many_arguments)] | ||
| 902 | #[allow(clippy::too_many_lines)] | ||
| 903 | pub async fn generate_patch_event( | ||
| 904 | git_repo: &Repo, | ||
| 905 | root_commit: &Sha1Hash, | ||
| 906 | commit: &Sha1Hash, | ||
| 907 | thread_event_id: Option<nostr::EventId>, | ||
| 908 | signer: &nostr_sdk::NostrSigner, | ||
| 909 | repo_ref: &RepoRef, | ||
| 910 | parent_patch_event_id: Option<nostr::EventId>, | ||
| 911 | series_count: Option<(u64, u64)>, | ||
| 912 | branch_name: Option<String>, | ||
| 913 | root_proposal_id: &Option<String>, | ||
| 914 | mentions: &[nostr::Tag], | ||
| 915 | ) -> Result<nostr::Event> { | ||
| 916 | let commit_parent = git_repo | ||
| 917 | .get_commit_parent(commit) | ||
| 918 | .context("failed to get parent commit")?; | ||
| 919 | let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from); | ||
| 920 | |||
| 921 | sign_event( | ||
| 922 | EventBuilder::new( | ||
| 923 | nostr::event::Kind::GitPatch, | ||
| 924 | git_repo | ||
| 925 | .make_patch_from_commit(commit, &series_count) | ||
| 926 | .context(format!("cannot make patch for commit {commit}"))?, | ||
| 927 | [ | ||
| 928 | repo_ref | ||
| 929 | .maintainers | ||
| 930 | .iter() | ||
| 931 | .map(|m| { | ||
| 932 | Tag::coordinate(Coordinate { | ||
| 933 | kind: nostr::Kind::GitRepoAnnouncement, | ||
| 934 | public_key: *m, | ||
| 935 | identifier: repo_ref.identifier.to_string(), | ||
| 936 | relays: repo_ref.relays.clone(), | ||
| 937 | }) | ||
| 938 | }) | ||
| 939 | .collect::<Vec<Tag>>(), | ||
| 940 | vec![ | ||
| 941 | Tag::from_standardized(TagStandard::Reference(root_commit.to_string())), | ||
| 942 | // commit id reference is a trade-off. its now | ||
| 943 | // unclear which one is the root commit id but it | ||
| 944 | // enables easier location of code comments againt | ||
| 945 | // code that makes it into the main branch, assuming | ||
| 946 | // the commit id is correct | ||
| 947 | Tag::from_standardized(TagStandard::Reference(commit.to_string())), | ||
| 948 | Tag::custom( | ||
| 949 | TagKind::Custom(std::borrow::Cow::Borrowed("alt")), | ||
| 950 | vec![format!( | ||
| 951 | "git patch: {}", | ||
| 952 | git_repo | ||
| 953 | .get_commit_message_summary(commit) | ||
| 954 | .unwrap_or_default() | ||
| 955 | )], | ||
| 956 | ), | ||
| 957 | ], | ||
| 958 | if let Some(thread_event_id) = thread_event_id { | ||
| 959 | vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { | ||
| 960 | event_id: thread_event_id, | ||
| 961 | relay_url: relay_hint.clone(), | ||
| 962 | marker: Some(Marker::Root), | ||
| 963 | public_key: None, | ||
| 964 | })] | ||
| 965 | } else if let Some(event_ref) = root_proposal_id.clone() { | ||
| 966 | vec![ | ||
| 967 | Tag::hashtag("root"), | ||
| 968 | Tag::hashtag("revision-root"), | ||
| 969 | // TODO check if id is for a root proposal (perhaps its for an issue?) | ||
| 970 | event_tag_from_nip19_or_hex( | ||
| 971 | &event_ref, | ||
| 972 | "proposal", | ||
| 973 | Marker::Reply, | ||
| 974 | false, | ||
| 975 | false, | ||
| 976 | )?, | ||
| 977 | ] | ||
| 978 | } else { | ||
| 979 | vec![Tag::hashtag("root")] | ||
| 980 | }, | ||
| 981 | mentions.to_vec(), | ||
| 982 | if let Some(id) = parent_patch_event_id { | ||
| 983 | vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { | ||
| 984 | event_id: id, | ||
| 985 | relay_url: relay_hint.clone(), | ||
| 986 | marker: Some(Marker::Reply), | ||
| 987 | public_key: None, | ||
| 988 | })] | ||
| 989 | } else { | ||
| 990 | vec![] | ||
| 991 | }, | ||
| 992 | // see comment on branch names in cover letter event creation | ||
| 993 | if let Some(branch_name) = branch_name { | ||
| 994 | if thread_event_id.is_none() { | ||
| 995 | vec![Tag::custom( | ||
| 996 | TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), | ||
| 997 | vec![branch_name.to_string()], | ||
| 998 | )] | ||
| 999 | } else { | ||
| 1000 | vec![] | ||
| 1001 | } | ||
| 1002 | } else { | ||
| 1003 | vec![] | ||
| 1004 | }, | ||
| 1005 | // whilst it is in nip34 draft to tag the maintainers | ||
| 1006 | // I'm not sure it is a good idea because if they are | ||
| 1007 | // interested in all patches then their specialised | ||
| 1008 | // client should subscribe to patches tagged with the | ||
| 1009 | // repo reference. maintainers of large repos will not | ||
| 1010 | // be interested in every patch. | ||
| 1011 | repo_ref | ||
| 1012 | .maintainers | ||
| 1013 | .iter() | ||
| 1014 | .map(|pk| Tag::public_key(*pk)) | ||
| 1015 | .collect(), | ||
| 1016 | vec![ | ||
| 1017 | // a fallback is now in place to extract this from the patch | ||
| 1018 | Tag::custom( | ||
| 1019 | TagKind::Custom(std::borrow::Cow::Borrowed("commit")), | ||
| 1020 | vec![commit.to_string()], | ||
| 1021 | ), | ||
| 1022 | // this is required as patches cannot be relied upon to include the 'base | ||
| 1023 | // commit' | ||
| 1024 | Tag::custom( | ||
| 1025 | TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")), | ||
| 1026 | vec![commit_parent.to_string()], | ||
| 1027 | ), | ||
| 1028 | // this is required to ensure the commit id matches | ||
| 1029 | Tag::custom( | ||
| 1030 | TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")), | ||
| 1031 | vec![ | ||
| 1032 | git_repo | ||
| 1033 | .extract_commit_pgp_signature(commit) | ||
| 1034 | .unwrap_or_default(), | ||
| 1035 | ], | ||
| 1036 | ), | ||
| 1037 | // removing description tag will not cause anything to break | ||
| 1038 | Tag::from_standardized(nostr_sdk::TagStandard::Description( | ||
| 1039 | git_repo.get_commit_message(commit)?.to_string(), | ||
| 1040 | )), | ||
| 1041 | Tag::custom( | ||
| 1042 | TagKind::Custom(std::borrow::Cow::Borrowed("author")), | ||
| 1043 | git_repo.get_commit_author(commit)?, | ||
| 1044 | ), | ||
| 1045 | // this is required to ensure the commit id matches | ||
| 1046 | Tag::custom( | ||
| 1047 | TagKind::Custom(std::borrow::Cow::Borrowed("committer")), | ||
| 1048 | git_repo.get_commit_comitter(commit)?, | ||
| 1049 | ), | ||
| 1050 | ], | ||
| 1051 | ] | ||
| 1052 | .concat(), | ||
| 1053 | ), | ||
| 1054 | signer, | ||
| 1055 | ) | ||
| 1056 | .await | ||
| 1057 | .context("failed to sign event") | ||
| 1058 | } | ||
| 1059 | // TODO | ||
| 1060 | // - find profile | ||
| 1061 | // - file relays | ||
| 1062 | // - find repo events | ||
| 1063 | // - | ||
| 1064 | |||
| 1065 | /** | ||
| 1066 | * returns `(from_branch,to_branch,ahead,behind)` | ||
| 1067 | */ | ||
| 1068 | pub fn identify_ahead_behind( | ||
| 1069 | git_repo: &Repo, | ||
| 1070 | from_branch: &Option<String>, | ||
| 1071 | to_branch: &Option<String>, | ||
| 1072 | ) -> Result<(String, String, Vec<Sha1Hash>, Vec<Sha1Hash>)> { | ||
| 1073 | let (from_branch, from_tip) = match from_branch { | ||
| 1074 | Some(name) => ( | ||
| 1075 | name.to_string(), | ||
| 1076 | git_repo | ||
| 1077 | .get_tip_of_branch(name) | ||
| 1078 | .context(format!("cannot find from_branch '{name}'"))?, | ||
| 1079 | ), | ||
| 1080 | None => ( | ||
| 1081 | if let Ok(name) = git_repo.get_checked_out_branch_name() { | ||
| 1082 | name | ||
| 1083 | } else { | ||
| 1084 | "head".to_string() | ||
| 1085 | }, | ||
| 1086 | git_repo | ||
| 1087 | .get_head_commit() | ||
| 1088 | .context("failed to get head commit") | ||
| 1089 | .context( | ||
| 1090 | "checkout a commit or specify a from_branch. head does not reveal a commit", | ||
| 1091 | )?, | ||
| 1092 | ), | ||
| 1093 | }; | ||
| 1094 | |||
| 1095 | let (to_branch, to_tip) = match to_branch { | ||
| 1096 | Some(name) => ( | ||
| 1097 | name.to_string(), | ||
| 1098 | git_repo | ||
| 1099 | .get_tip_of_branch(name) | ||
| 1100 | .context(format!("cannot find to_branch '{name}'"))?, | ||
| 1101 | ), | ||
| 1102 | None => { | ||
| 1103 | let (name, commit) = git_repo | ||
| 1104 | .get_main_or_master_branch() | ||
| 1105 | .context("the default branches (main or master) do not exist")?; | ||
| 1106 | (name.to_string(), commit) | ||
| 1107 | } | ||
| 1108 | }; | ||
| 1109 | |||
| 1110 | match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) { | ||
| 1111 | Err(e) => { | ||
| 1112 | if e.to_string().contains("is not an ancestor of") { | ||
| 1113 | return Err(e).context(format!( | ||
| 1114 | "'{from_branch}' is not branched from '{to_branch}'" | ||
| 1115 | )); | ||
| 1116 | } | ||
| 1117 | Err(e).context(format!( | ||
| 1118 | "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'" | ||
| 1119 | )) | ||
| 1120 | } | ||
| 1121 | Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)), | ||
| 1122 | } | ||
| 1123 | } | ||
| 1124 | |||
| 1125 | #[cfg(test)] | ||
| 1126 | mod tests { | ||
| 1127 | use test_utils::git::GitTestRepo; | ||
| 1128 | |||
| 1129 | use super::*; | ||
| 1130 | mod identify_ahead_behind { | ||
| 1131 | |||
| 1132 | use super::*; | ||
| 1133 | use crate::git::oid_to_sha1; | ||
| 1134 | |||
| 1135 | #[test] | ||
| 1136 | fn when_from_branch_doesnt_exist_return_error() -> Result<()> { | ||
| 1137 | let test_repo = GitTestRepo::default(); | ||
| 1138 | let git_repo = Repo::from_path(&test_repo.dir)?; | ||
| 1139 | |||
| 1140 | test_repo.populate()?; | ||
| 1141 | let branch_name = "doesnt_exist"; | ||
| 1142 | assert_eq!( | ||
| 1143 | identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None) | ||
| 1144 | .unwrap_err() | ||
| 1145 | .to_string(), | ||
| 1146 | format!("cannot find from_branch '{}'", &branch_name), | ||
| 1147 | ); | ||
| 1148 | Ok(()) | ||
| 1149 | } | ||
| 1150 | |||
| 1151 | #[test] | ||
| 1152 | fn when_to_branch_doesnt_exist_return_error() -> Result<()> { | ||
| 1153 | let test_repo = GitTestRepo::default(); | ||
| 1154 | let git_repo = Repo::from_path(&test_repo.dir)?; | ||
| 1155 | |||
| 1156 | test_repo.populate()?; | ||
| 1157 | let branch_name = "doesnt_exist"; | ||
| 1158 | assert_eq!( | ||
| 1159 | identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string())) | ||
| 1160 | .unwrap_err() | ||
| 1161 | .to_string(), | ||
| 1162 | format!("cannot find to_branch '{}'", &branch_name), | ||
| 1163 | ); | ||
| 1164 | Ok(()) | ||
| 1165 | } | ||
| 1166 | |||
| 1167 | #[test] | ||
| 1168 | fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> { | ||
| 1169 | let test_repo = GitTestRepo::new("notmain")?; | ||
| 1170 | let git_repo = Repo::from_path(&test_repo.dir)?; | ||
| 1171 | |||
| 1172 | test_repo.populate()?; | ||
| 1173 | |||
| 1174 | assert_eq!( | ||
| 1175 | identify_ahead_behind(&git_repo, &None, &None) | ||
| 1176 | .unwrap_err() | ||
| 1177 | .to_string(), | ||
| 1178 | "the default branches (main or master) do not exist", | ||
| 1179 | ); | ||
| 1180 | Ok(()) | ||
| 1181 | } | ||
| 1182 | |||
| 1183 | #[test] | ||
| 1184 | fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> { | ||
| 1185 | let test_repo = GitTestRepo::default(); | ||
| 1186 | let git_repo = Repo::from_path(&test_repo.dir)?; | ||
| 1187 | |||
| 1188 | test_repo.populate()?; | ||
| 1189 | // create feature branch with 1 commit ahead | ||
| 1190 | test_repo.create_branch("feature")?; | ||
| 1191 | test_repo.checkout("feature")?; | ||
| 1192 | std::fs::write(test_repo.dir.join("t3.md"), "some content")?; | ||
| 1193 | let head_oid = test_repo.stage_and_commit("add t3.md")?; | ||
| 1194 | |||
| 1195 | // make feature branch 1 commit behind | ||
| 1196 | test_repo.checkout("main")?; | ||
| 1197 | std::fs::write(test_repo.dir.join("t4.md"), "some content")?; | ||
| 1198 | let main_oid = test_repo.stage_and_commit("add t4.md")?; | ||
| 1199 | |||
| 1200 | let (from_branch, to_branch, ahead, behind) = | ||
| 1201 | identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; | ||
| 1202 | |||
| 1203 | assert_eq!(from_branch, "feature"); | ||
| 1204 | assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]); | ||
| 1205 | assert_eq!(to_branch, "main"); | ||
| 1206 | assert_eq!(behind, vec![oid_to_sha1(&main_oid)]); | ||
| 1207 | Ok(()) | ||
| 1208 | } | ||
| 1209 | |||
| 1210 | #[test] | ||
| 1211 | fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> { | ||
| 1212 | let test_repo = GitTestRepo::default(); | ||
| 1213 | let git_repo = Repo::from_path(&test_repo.dir)?; | ||
| 1214 | |||
| 1215 | test_repo.populate()?; | ||
| 1216 | // create dev branch with 1 commit ahead | ||
| 1217 | test_repo.create_branch("dev")?; | ||
| 1218 | test_repo.checkout("dev")?; | ||
| 1219 | std::fs::write(test_repo.dir.join("t3.md"), "some content")?; | ||
| 1220 | let dev_oid_first = test_repo.stage_and_commit("add t3.md")?; | ||
| 1221 | |||
| 1222 | // create feature branch with 1 commit ahead of dev | ||
| 1223 | test_repo.create_branch("feature")?; | ||
| 1224 | test_repo.checkout("feature")?; | ||
| 1225 | std::fs::write(test_repo.dir.join("t4.md"), "some content")?; | ||
| 1226 | let feature_oid = test_repo.stage_and_commit("add t4.md")?; | ||
| 1227 | |||
| 1228 | // make feature branch 1 behind | ||
| 1229 | test_repo.checkout("dev")?; | ||
| 1230 | std::fs::write(test_repo.dir.join("t3.md"), "some content")?; | ||
| 1231 | let dev_oid = test_repo.stage_and_commit("add t3.md")?; | ||
| 1232 | |||
| 1233 | let (from_branch, to_branch, ahead, behind) = identify_ahead_behind( | ||
| 1234 | &git_repo, | ||
| 1235 | &Some("feature".to_string()), | ||
| 1236 | &Some("dev".to_string()), | ||
| 1237 | )?; | ||
| 1238 | |||
| 1239 | assert_eq!(from_branch, "feature"); | ||
| 1240 | assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]); | ||
| 1241 | assert_eq!(to_branch, "dev"); | ||
| 1242 | assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]); | ||
| 1243 | |||
| 1244 | let (from_branch, to_branch, ahead, behind) = | ||
| 1245 | identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; | ||
| 1246 | |||
| 1247 | assert_eq!(from_branch, "feature"); | ||
| 1248 | assert_eq!( | ||
| 1249 | ahead, | ||
| 1250 | vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)] | ||
| 1251 | ); | ||
| 1252 | assert_eq!(to_branch, "main"); | ||
| 1253 | assert_eq!(behind, vec![]); | ||
| 1254 | |||
| 1255 | Ok(()) | ||
| 1256 | } | ||
| 1257 | } | ||
| 1258 | |||
| 1259 | mod event_to_cover_letter { | ||
| 1260 | use super::*; | ||
| 1261 | |||
| 1262 | fn generate_cover_letter(title: &str, description: &str) -> Result<nostr::Event> { | ||
| 1263 | Ok(nostr::event::EventBuilder::new( | ||
| 1264 | nostr::event::Kind::GitPatch, | ||
| 1265 | format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"), | ||
| 1266 | [ | ||
| 1267 | Tag::hashtag("cover-letter"), | ||
| 1268 | Tag::hashtag("root"), | ||
| 1269 | ], | ||
| 1270 | ) | ||
| 1271 | .to_event(&nostr::Keys::generate())?) | ||
| 1272 | } | ||
| 1273 | |||
| 1274 | #[test] | ||
| 1275 | fn basic_title() -> Result<()> { | ||
| 1276 | assert_eq!( | ||
| 1277 | event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? | ||
| 1278 | .title, | ||
| 1279 | "the title", | ||
| 1280 | ); | ||
| 1281 | Ok(()) | ||
| 1282 | } | ||
| 1283 | |||
| 1284 | #[test] | ||
| 1285 | fn basic_description() -> Result<()> { | ||
| 1286 | assert_eq!( | ||
| 1287 | event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? | ||
| 1288 | .description, | ||
| 1289 | "description here", | ||
| 1290 | ); | ||
| 1291 | Ok(()) | ||
| 1292 | } | ||
| 1293 | |||
| 1294 | #[test] | ||
| 1295 | fn description_trimmed() -> Result<()> { | ||
| 1296 | assert_eq!( | ||
| 1297 | event_to_cover_letter(&generate_cover_letter( | ||
| 1298 | "the title", | ||
| 1299 | " \n \ndescription here\n\n " | ||
| 1300 | )?)? | ||
| 1301 | .description, | ||
| 1302 | "description here", | ||
| 1303 | ); | ||
| 1304 | Ok(()) | ||
| 1305 | } | ||
| 1306 | |||
| 1307 | #[test] | ||
| 1308 | fn multi_line_description() -> Result<()> { | ||
| 1309 | assert_eq!( | ||
| 1310 | event_to_cover_letter(&generate_cover_letter( | ||
| 1311 | "the title", | ||
| 1312 | "description here\n\nmore here\nmore" | ||
| 1313 | )?)? | ||
| 1314 | .description, | ||
| 1315 | "description here\n\nmore here\nmore", | ||
| 1316 | ); | ||
| 1317 | Ok(()) | ||
| 1318 | } | ||
| 1319 | |||
| 1320 | #[test] | ||
| 1321 | fn new_lines_in_title_forms_part_of_description() -> Result<()> { | ||
| 1322 | assert_eq!( | ||
| 1323 | event_to_cover_letter(&generate_cover_letter( | ||
| 1324 | "the title\nwith new line", | ||
| 1325 | "description here\n\nmore here\nmore" | ||
| 1326 | )?)? | ||
| 1327 | .title, | ||
| 1328 | "the title", | ||
| 1329 | ); | ||
| 1330 | assert_eq!( | ||
| 1331 | event_to_cover_letter(&generate_cover_letter( | ||
| 1332 | "the title\nwith new line", | ||
| 1333 | "description here\n\nmore here\nmore" | ||
| 1334 | )?)? | ||
| 1335 | .description, | ||
| 1336 | "with new line\n\ndescription here\n\nmore here\nmore", | ||
| 1337 | ); | ||
| 1338 | Ok(()) | ||
| 1339 | } | ||
| 1340 | |||
| 1341 | mod blank_description { | ||
| 1342 | use super::*; | ||
| 1343 | |||
| 1344 | #[test] | ||
| 1345 | fn title_correct() -> Result<()> { | ||
| 1346 | assert_eq!( | ||
| 1347 | event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title, | ||
| 1348 | "the title", | ||
| 1349 | ); | ||
| 1350 | Ok(()) | ||
| 1351 | } | ||
| 1352 | |||
| 1353 | #[test] | ||
| 1354 | fn description_is_empty_string() -> Result<()> { | ||
| 1355 | assert_eq!( | ||
| 1356 | event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description, | ||
| 1357 | "", | ||
| 1358 | ); | ||
| 1359 | Ok(()) | ||
| 1360 | } | ||
| 1361 | } | ||
| 1362 | } | ||
| 1363 | } | ||