From 949c6459aa7683453a7160423b689ceadb08954b Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 4 Sep 2024 08:04:48 +0100 Subject: 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 --- src/bin/ngit/sub_commands/list.rs | 906 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 906 insertions(+) create mode 100644 src/bin/ngit/sub_commands/list.rs (limited to 'src/bin/ngit/sub_commands/list.rs') diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs new file mode 100644 index 0000000..ac1f4ab --- /dev/null +++ b/src/bin/ngit/sub_commands/list.rs @@ -0,0 +1,906 @@ +use std::{collections::HashSet, io::Write, ops::Add, path::Path}; + +use anyhow::{bail, Context, Result}; +use nostr::nips::nip01::Coordinate; +use nostr_sdk::{Kind, PublicKey}; + +use super::send::event_is_patch_set_root; +#[cfg(test)] +use crate::client::MockConnect; +#[cfg(not(test))] +use crate::client::{Client, Connect}; +use crate::{ + cli_interactor::{Interactor, InteractorPrompt, PromptChoiceParms, PromptConfirmParms}, + client::{fetching_with_report, get_events_from_cache, get_repo_ref_from_cache}, + git::{str_to_sha1, Repo, RepoActions}, + repo_ref::{get_repo_coordinates, RepoRef}, + sub_commands::send::{ + commit_msg_from_patch_oneliner, event_is_cover_letter, event_is_revision_root, + event_to_cover_letter, patch_supports_commit_ids, + }, +}; + +#[allow(clippy::too_many_lines)] +pub async fn launch() -> Result<()> { + let git_repo = Repo::discover().context("cannot find a git repository")?; + let git_repo_path = git_repo.get_path()?; + + // TODO: check for empty repo + // TODO: check for existing maintaiers file + // TODO: check for other claims + + #[cfg(not(test))] + let client = Client::default(); + #[cfg(test)] + let client = ::default(); + + let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; + + fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; + + let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?; + + let proposals_and_revisions: Vec = + get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?; + if proposals_and_revisions.is_empty() { + println!("no proposals found... create one? try `ngit send`"); + return Ok(()); + } + + let statuses: Vec = { + let mut statuses = get_events_from_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .kinds(status_kinds().clone()) + .events(proposals_and_revisions.iter().map(nostr::Event::id)), + ], + ) + .await?; + statuses.sort_by_key(|e| e.created_at); + statuses.reverse(); + statuses + }; + + let mut open_proposals: Vec<&nostr::Event> = vec![]; + let mut draft_proposals: Vec<&nostr::Event> = vec![]; + let mut closed_proposals: Vec<&nostr::Event> = vec![]; + let mut applied_proposals: Vec<&nostr::Event> = vec![]; + + let proposals: Vec = proposals_and_revisions + .iter() + .filter(|e| !event_is_revision_root(e)) + .cloned() + .collect(); + + for proposal in &proposals { + let status = if let Some(e) = statuses + .iter() + .filter(|e| { + status_kinds().contains(&e.kind()) + && e.tags() + .iter() + .any(|t| t.as_vec()[1].eq(&proposal.id.to_string())) + }) + .collect::>() + .first() + { + e.kind() + } else { + Kind::GitStatusOpen + }; + if status.eq(&Kind::GitStatusOpen) { + open_proposals.push(proposal); + } else if status.eq(&Kind::GitStatusClosed) { + closed_proposals.push(proposal); + } else if status.eq(&Kind::GitStatusDraft) { + draft_proposals.push(proposal); + } else if status.eq(&Kind::GitStatusApplied) { + applied_proposals.push(proposal); + } + } + + let mut selected_status = Kind::GitStatusOpen; + + loop { + let proposals_for_status = if selected_status == Kind::GitStatusOpen { + &open_proposals + } else if selected_status == Kind::GitStatusDraft { + &draft_proposals + } else if selected_status == Kind::GitStatusClosed { + &closed_proposals + } else if selected_status == Kind::GitStatusApplied { + &applied_proposals + } else { + &open_proposals + }; + + let prompt = if proposals.len().eq(&open_proposals.len()) { + "all proposals" + } else if selected_status == Kind::GitStatusOpen { + if open_proposals.is_empty() { + "proposals menu" + } else { + "open proposals" + } + } else if selected_status == Kind::GitStatusDraft { + "draft proposals" + } else if selected_status == Kind::GitStatusClosed { + "closed proposals" + } else { + "applied proposals" + }; + + let mut choices: Vec = proposals_for_status + .iter() + .map(|e| { + if let Ok(cl) = event_to_cover_letter(e) { + cl.title + } else if let Ok(msg) = tag_value(e, "description") { + msg.split('\n').collect::>()[0].to_string() + } else { + e.id.to_string() + } + }) + .collect(); + + if !selected_status.eq(&Kind::GitStatusOpen) && open_proposals.len().gt(&0) { + choices.push(format!("({}) Open proposals...", open_proposals.len())); + } + if !selected_status.eq(&Kind::GitStatusDraft) && draft_proposals.len().gt(&0) { + choices.push(format!("({}) Draft proposals...", draft_proposals.len())); + } + if !selected_status.eq(&Kind::GitStatusClosed) && closed_proposals.len().gt(&0) { + choices.push(format!("({}) Closed proposals...", closed_proposals.len())); + } + if !selected_status.eq(&Kind::GitStatusApplied) && applied_proposals.len().gt(&0) { + choices.push(format!( + "({}) Applied proposals...", + applied_proposals.len() + )); + } + + let selected_index = Interactor::default().choice( + PromptChoiceParms::default() + .with_prompt(prompt) + .with_choices(choices.clone()), + )?; + + if (selected_index + 1).gt(&proposals_for_status.len()) { + if choices[selected_index].contains("Open") { + selected_status = Kind::GitStatusOpen; + } else if choices[selected_index].contains("Draft") { + selected_status = Kind::GitStatusDraft; + } else if choices[selected_index].contains("Closed") { + selected_status = Kind::GitStatusClosed; + } else if choices[selected_index].contains("Applied") { + selected_status = Kind::GitStatusApplied; + } + continue; + } + + let cover_letter = event_to_cover_letter(proposals_for_status[selected_index]) + .context("cannot extract proposal details from proposal root event")?; + + let commits_events: Vec = get_all_proposal_patch_events_from_cache( + git_repo_path, + &repo_ref, + &proposals_for_status[selected_index].id(), + ) + .await?; + + let Ok(most_recent_proposal_patch_chain) = + get_most_recent_patch_with_ancestors(commits_events.clone()) + else { + if Interactor::default().confirm( + PromptConfirmParms::default() + .with_default(true) + .with_prompt( + "cannot find any patches on this proposal. choose another proposal?", + ), + )? { + continue; + } + return Ok(()); + }; + // for commit in &most_recent_proposal_patch_chain { + // println!("recent_event: {:?}", commit.as_json()); + // } + + let binding_patch_text_ref = format!("{} commits", most_recent_proposal_patch_chain.len()); + let patch_text_ref = if most_recent_proposal_patch_chain.len().gt(&1) { + binding_patch_text_ref.as_str() + } else { + "1 commit" + }; + + let no_support_for_patches_as_branch = most_recent_proposal_patch_chain + .iter() + .any(|event| !patch_supports_commit_ids(event)); + + if no_support_for_patches_as_branch { + println!("{patch_text_ref}"); + return match Interactor::default().choice( + PromptChoiceParms::default() + .with_default(0) + .with_choices(vec![ + "learn why 'patch only' proposals can't be checked out".to_string(), + format!("apply to current branch with `git am`"), + format!("download to ./patches"), + "back".to_string(), + ]), + )? { + 0 => { + println!("Some proposals are posted as 'patch only'\n"); + println!( + "they are not anchored against a particular state of the code base like a standard proposal or a GitHub Pull Request can be\n" + ); + println!( + "they are designed to reviewed by studying the diff (in a tool like gitworkshop.dev) and if acceptable by a maintainer, applied to the latest version of master with any conflicts resolved as the do so\n" + ); + println!( + "this has proven to be a smoother workflow for large scale projects with a high frequency of changes, even when patches are exchanged via email\n" + ); + println!( + "by default ngit posts proposals that support both the branch and patch model so either workflow can be used" + ); + Interactor::default().choice( + PromptChoiceParms::default() + .with_default(0) + .with_choices(vec!["back".to_string()]), + )?; + continue; + } + 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), + 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), + 3 => continue, + _ => { + bail!("unexpected choice") + } + }; + } + + let branch_exists = git_repo + .get_local_branch_names() + .context("gitlib2 will not show a list of local branch names")? + .iter() + .any(|n| n.eq(&cover_letter.get_branch_name().unwrap())); + + let checked_out_proposal_branch = git_repo + .get_checked_out_branch_name()? + .eq(&cover_letter.get_branch_name()?); + + let proposal_base_commit = str_to_sha1(&tag_value( + most_recent_proposal_patch_chain.last().context( + "there should be at least one patch as we have already checked for this", + )?, + "parent-commit", + )?) + .context("cannot get valid parent commit id from patch")?; + + let (main_branch_name, master_tip) = git_repo.get_main_or_master_branch()?; + + if !git_repo.does_commit_exist(&proposal_base_commit.to_string())? { + println!("your '{main_branch_name}' branch may not be up-to-date."); + println!("the proposal parent commit doesnt exist in your local repository."); + return match Interactor::default().choice(PromptChoiceParms::default().with_default(0).with_choices( + vec![ + format!( + "manually run `git pull` on '{main_branch_name}' and select proposal again" + ), + format!("apply to current branch with `git am`"), + format!("download to ./patches"), + "back".to_string(), + ], + ))? { + 0 | 3 => continue, + 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), + 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), + _ => { + bail!("unexpected choice") + } + }; + } + + let proposal_tip = str_to_sha1( + &get_commit_id_from_patch(most_recent_proposal_patch_chain.first().context( + "there should be at least one patch as we have already checked for this", + )?) + .context("cannot get valid commit_id from patch")?, + ) + .context("cannot get valid commit_id from patch")?; + + let (_, proposal_behind_main) = + git_repo.get_commits_ahead_behind(&master_tip, &proposal_base_commit)?; + + // branch doesnt exist + if !branch_exists { + return match Interactor::default() + .choice(PromptChoiceParms::default().with_default(0).with_choices(vec![ + format!( + "create and checkout proposal branch ({} ahead {} behind '{main_branch_name}')", + most_recent_proposal_patch_chain.len(), + proposal_behind_main.len(), + ), + format!("apply to current branch with `git am`"), + format!("download to ./patches"), + "back".to_string(), + ]))? { + 0 => { + check_clean(&git_repo)?; + let _ = git_repo + .apply_patch_chain( + &cover_letter.get_branch_name()?, + most_recent_proposal_patch_chain, + ) + .context("cannot apply patch chain")?; + + println!( + "checked out proposal as '{}' branch", + cover_letter.get_branch_name()? + ); + Ok(()) + } + 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), + 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), + 3 => continue, + _ => { + bail!("unexpected choice") + } + }; + } + + let local_branch_tip = git_repo.get_tip_of_branch(&cover_letter.get_branch_name()?)?; + + // up-to-date + if proposal_tip.eq(&local_branch_tip) { + if checked_out_proposal_branch { + println!("branch checked out and up-to-date"); + return match Interactor::default().choice( + PromptChoiceParms::default() + .with_default(0) + .with_choices(vec!["exit".to_string(), "back".to_string()]), + )? { + 0 => Ok(()), + 1 => continue, + _ => { + bail!("unexpected choice") + } + }; + } + + return match Interactor::default().choice( + PromptChoiceParms::default() + .with_default(0) + .with_choices(vec![ + format!( + "checkout proposal branch ({} ahead {} behind '{main_branch_name}')", + most_recent_proposal_patch_chain.len(), + proposal_behind_main.len(), + ), + format!("apply to current branch with `git am`"), + format!("download to ./patches"), + "back".to_string(), + ]), + )? { + 0 => { + check_clean(&git_repo)?; + git_repo.checkout(&cover_letter.get_branch_name()?)?; + println!( + "checked out proposal as '{}' branch", + cover_letter.get_branch_name()? + ); + Ok(()) + } + 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), + 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), + 3 => continue, + _ => { + bail!("unexpected choice") + } + }; + } + + let (local_ahead_of_main, local_beind_main) = + git_repo.get_commits_ahead_behind(&master_tip, &local_branch_tip)?; + + // new appendments to proposal + if let Some(index) = most_recent_proposal_patch_chain.iter().position(|patch| { + get_commit_id_from_patch(patch) + .unwrap_or_default() + .eq(&local_branch_tip.to_string()) + }) { + return match Interactor::default().choice( + PromptChoiceParms::default() + .with_default(0) + .with_choices(vec![ + format!("checkout proposal branch and apply {} appendments", &index,), + format!("apply to current branch with `git am`"), + format!("download to ./patches"), + "back".to_string(), + ]), + )? { + 0 => { + check_clean(&git_repo)?; + git_repo.checkout(&cover_letter.get_branch_name()?)?; + let _ = git_repo + .apply_patch_chain( + &cover_letter.get_branch_name()?, + most_recent_proposal_patch_chain, + ) + .context("cannot apply patch chain")?; + println!( + "checked out proposal branch and applied {} appendments ({} ahead {} behind '{main_branch_name}')", + &index, + local_ahead_of_main.len().add(&index), + local_beind_main.len(), + ); + Ok(()) + } + 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), + 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), + 3 => continue, + _ => { + bail!("unexpected choice") + } + }; + } + + // new proposal revision / rebase + // tip of local in proposal history (new, amended or rebased version but no + // local changes) + if commits_events.iter().any(|patch| { + get_commit_id_from_patch(patch) + .unwrap_or_default() + .eq(&local_branch_tip.to_string()) + }) { + println!( + "updated proposal available ({} ahead {} behind '{main_branch_name}'). existing version is {} ahead {} behind '{main_branch_name}'", + most_recent_proposal_patch_chain.len(), + proposal_behind_main.len(), + local_ahead_of_main.len(), + local_beind_main.len(), + ); + return match Interactor::default().choice( + PromptChoiceParms::default() + .with_default(0) + .with_choices(vec![ + format!("checkout and overwrite existing proposal branch"), + format!("checkout existing outdated proposal branch"), + format!("apply to current branch with `git am`"), + format!("download to ./patches"), + "back".to_string(), + ]), + )? { + 0 => { + check_clean(&git_repo)?; + git_repo.create_branch_at_commit( + &cover_letter.get_branch_name()?, + &proposal_base_commit.to_string(), + )?; + git_repo.checkout(&cover_letter.get_branch_name()?)?; + let chain_length = most_recent_proposal_patch_chain.len(); + let _ = git_repo + .apply_patch_chain( + &cover_letter.get_branch_name()?, + most_recent_proposal_patch_chain, + ) + .context("cannot apply patch chain")?; + println!( + "checked out new version of proposal ({} ahead {} behind '{main_branch_name}'), replacing old version ({} ahead {} behind '{main_branch_name}')", + chain_length, + proposal_behind_main.len(), + local_ahead_of_main.len(), + local_beind_main.len(), + ); + Ok(()) + } + 1 => { + check_clean(&git_repo)?; + git_repo.checkout(&cover_letter.get_branch_name()?)?; + println!( + "checked out old proposal in existing branch ({} ahead {} behind '{main_branch_name}')", + local_ahead_of_main.len(), + local_beind_main.len(), + ); + Ok(()) + } + 2 => launch_git_am_with_patches(most_recent_proposal_patch_chain), + 3 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), + 4 => continue, + _ => { + bail!("unexpected choice") + } + }; + } + // tip of proposal in branch in history (local appendments made to up-to-date + // proposal) + else if git_repo.ancestor_of(&local_branch_tip, &proposal_tip)? { + let (local_ahead_of_proposal, _) = git_repo + .get_commits_ahead_behind(&proposal_tip, &local_branch_tip) + .context("cannot get commits ahead behind for propsal_top and local_branch_tip")?; + + println!( + "local proposal branch exists with {} unpublished commits on top of the most up-to-date version of the proposal ({} ahead {} behind '{main_branch_name}')", + local_ahead_of_proposal.len(), + local_ahead_of_main.len(), + proposal_behind_main.len(), + ); + return match Interactor::default().choice( + PromptChoiceParms::default() + .with_default(0) + .with_choices(vec![ + format!( + "checkout proposal branch with {} unpublished commits", + local_ahead_of_proposal.len(), + ), + "back".to_string(), + ]), + )? { + 0 => { + git_repo.checkout(&cover_letter.get_branch_name()?)?; + println!( + "checked out proposal branch with {} unpublished commits ({} ahead {} behind '{main_branch_name}')", + local_ahead_of_proposal.len(), + local_ahead_of_main.len(), + proposal_behind_main.len(), + ); + Ok(()) + } + 1 => continue, + _ => { + bail!("unexpected choice") + } + }; + } + + println!("you have an amended/rebase version the proposal that is unpublished"); + // user probably has a unpublished amended or rebase version of the latest + // proposal version + // if tip of proposal commits exist (were once part of branch but have been + // amended and git clean up job hasn't removed them) + if git_repo.does_commit_exist(&proposal_tip.to_string())? { + println!( + "you have previously applied the latest version of the proposal ({} ahead {} behind '{main_branch_name}') but your local proposal branch has amended or rebased it ({} ahead {} behind '{main_branch_name}')", + most_recent_proposal_patch_chain.len(), + proposal_behind_main.len(), + local_ahead_of_main.len(), + local_beind_main.len(), + ); + } + // user probably has a unpublished amended or rebase version of an older + // proposal version + else { + println!( + "your local proposal branch ({} ahead {} behind '{main_branch_name}') has conflicting changes with the latest published proposal ({} ahead {} behind '{main_branch_name}')", + local_ahead_of_main.len(), + local_beind_main.len(), + most_recent_proposal_patch_chain.len(), + proposal_behind_main.len(), + ); + + println!( + "its likely that you have rebased / amended an old proposal version because git has no record of the latest proposal commit." + ); + println!( + "it is possible that you have been working off the latest version and git has delete this commit as part of a clean up" + ); + } + println!("to view the latest proposal but retain your changes:"); + println!(" 1) create a new branch off the tip commit of this one to store your changes"); + println!(" 2) run `ngit list` and checkout the latest published version of this proposal"); + + println!("if you are confident in your changes consider running `ngit push --force`"); + + return match Interactor::default().choice( + PromptChoiceParms::default() + .with_default(0) + .with_choices(vec![ + format!("checkout local branch with unpublished changes"), + format!("discard unpublished changes and checkout new revision",), + format!("apply to current branch with `git am`"), + format!("download to ./patches"), + "back".to_string(), + ]), + )? { + 0 => { + check_clean(&git_repo)?; + git_repo.checkout(&cover_letter.get_branch_name()?)?; + println!( + "checked out old proposal in existing branch ({} ahead {} behind '{main_branch_name}')", + local_ahead_of_main.len(), + local_beind_main.len(), + ); + Ok(()) + } + 1 => { + check_clean(&git_repo)?; + git_repo.create_branch_at_commit( + &cover_letter.get_branch_name()?, + &proposal_base_commit.to_string(), + )?; + let chain_length = most_recent_proposal_patch_chain.len(); + let _ = git_repo + .apply_patch_chain( + &cover_letter.get_branch_name()?, + most_recent_proposal_patch_chain, + ) + .context("cannot apply patch chain")?; + + git_repo.checkout(&cover_letter.get_branch_name()?)?; + println!( + "checked out latest version of proposal ({} ahead {} behind '{main_branch_name}'), replacing unpublished version ({} ahead {} behind '{main_branch_name}')", + chain_length, + proposal_behind_main.len(), + local_ahead_of_main.len(), + local_beind_main.len(), + ); + Ok(()) + } + 2 => launch_git_am_with_patches(most_recent_proposal_patch_chain), + 3 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), + 4 => continue, + _ => { + bail!("unexpected choice") + } + }; + } +} + +fn launch_git_am_with_patches(mut patches: Vec) -> Result<()> { + println!("applying to current branch with `git am`"); + // TODO: add PATCH x/n to appended patches + patches.reverse(); + + let mut am = std::process::Command::new("git") + .arg("am") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .context("failed to spawn git am")?; + + let stdin = am + .stdin + .as_mut() + .context("git am process failed to take stdin")?; + + for patch in patches { + stdin + .write(format!("{}\n\n", patch.content).as_bytes()) + .context("failed to write patch content into git am stdin buffer")?; + } + stdin.flush()?; + let output = am + .wait_with_output() + .context("failed to read git am stdout")?; + print!("{:?}", output.stdout); + Ok(()) +} + +fn event_id_extra_shorthand(event: &nostr::Event) -> String { + event.id.to_string()[..5].to_string() +} + +fn save_patches_to_dir(mut patches: Vec, git_repo: &Repo) -> Result<()> { + // TODO: add PATCH x/n to appended patches + patches.reverse(); + let path = git_repo.get_path()?.join("patches"); + std::fs::create_dir_all(&path)?; + let id = event_id_extra_shorthand( + patches + .first() + .context("there must be at least one patch to save")?, + ); + for (i, patch) in patches.iter().enumerate() { + let path = path.join(format!( + "{}-{:0>4}-{}.patch", + &id, + i.add(&1), + commit_msg_from_patch_oneliner(patch)? + )); + let mut file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path) + .context("open new patch file with write and truncate options")?; + file.write_all(patch.content().as_bytes())?; + file.write_all("\n\n".as_bytes())?; + file.flush()?; + } + println!("created {} patch files in ./patches/{id}-*", patches.len()); + Ok(()) +} + +fn check_clean(git_repo: &Repo) -> Result<()> { + if git_repo.has_outstanding_changes()? { + bail!( + "cannot pull proposal branch when repository is not clean. discard or stash (un)staged changes and try again." + ); + } + Ok(()) +} + +pub fn tag_value(event: &nostr::Event, tag_name: &str) -> Result { + Ok(event + .tags + .iter() + .find(|t| t.as_vec()[0].eq(tag_name)) + .context(format!("tag '{tag_name}'not present"))? + .as_vec()[1] + .clone()) +} + +pub fn get_commit_id_from_patch(event: &nostr::Event) -> Result { + let value = tag_value(event, "commit"); + + if value.is_ok() { + value + } else if event.content.starts_with("From ") && event.content.len().gt(&45) { + Ok(event.content[5..45].to_string()) + } else { + bail!("event is not a patch") + } +} + +fn get_event_parent_id(event: &nostr::Event) -> Result { + Ok(if let Some(reply_tag) = event + .tags + .iter() + .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("reply")) + { + reply_tag + } else { + event + .tags + .iter() + .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("root")) + .context("no reply or root e tag present".to_string())? + } + .as_vec()[1] + .clone()) +} + +pub fn get_most_recent_patch_with_ancestors( + mut patches: Vec, +) -> Result> { + patches.sort_by_key(|e| e.created_at); + + let youngest_patch = patches.last().context("no patches found")?; + + let patches_with_youngest_created_at: Vec<&nostr::Event> = patches + .iter() + .filter(|p| p.created_at.eq(&youngest_patch.created_at)) + .collect(); + + let mut res = vec![]; + + let mut event_id_to_search = patches_with_youngest_created_at + .clone() + .iter() + .find(|p| { + !patches_with_youngest_created_at.iter().any(|p2| { + if let Ok(reply_to) = get_event_parent_id(p2) { + reply_to.eq(&p.id.to_string()) + } else { + false + } + }) + }) + .context("cannot find patches_with_youngest_created_at")? + .id + .to_string(); + + while let Some(event) = patches + .iter() + .find(|e| e.id.to_string().eq(&event_id_to_search)) + { + res.push(event.clone()); + if event_is_patch_set_root(event) { + break; + } + event_id_to_search = get_event_parent_id(event).unwrap_or_default(); + } + Ok(res) +} + +pub fn status_kinds() -> Vec { + vec![ + nostr::Kind::GitStatusOpen, + nostr::Kind::GitStatusApplied, + nostr::Kind::GitStatusClosed, + nostr::Kind::GitStatusDraft, + ] +} + +pub async fn get_proposals_and_revisions_from_cache( + git_repo_path: &Path, + repo_coordinates: HashSet, +) -> Result> { + let mut proposals = get_events_from_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .kind(nostr::Kind::GitPatch) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), + repo_coordinates + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + ), + ], + ) + .await? + .iter() + .filter(|e| event_is_patch_set_root(e)) + .cloned() + .collect::>(); + proposals.sort_by_key(|e| e.created_at); + proposals.reverse(); + Ok(proposals) +} + +pub async fn get_all_proposal_patch_events_from_cache( + git_repo_path: &Path, + repo_ref: &RepoRef, + proposal_id: &nostr::EventId, +) -> Result> { + let mut commit_events = get_events_from_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .kind(nostr::Kind::GitPatch) + .event(*proposal_id), + nostr::Filter::default() + .kind(nostr::Kind::GitPatch) + .id(*proposal_id), + ], + ) + .await?; + + let permissioned_users: HashSet = [ + repo_ref.maintainers.clone(), + vec![ + commit_events + .iter() + .find(|e| e.id().eq(proposal_id)) + .context("proposal not in cache")? + .author(), + ], + ] + .concat() + .iter() + .copied() + .collect(); + commit_events.retain(|e| permissioned_users.contains(&e.author())); + + let revision_roots: HashSet = commit_events + .iter() + .filter(|e| event_is_revision_root(e)) + .map(nostr::Event::id) + .collect(); + + if !revision_roots.is_empty() { + for event in get_events_from_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .kind(nostr::Kind::GitPatch) + .events(revision_roots) + .authors(permissioned_users.clone()), + ], + ) + .await? + { + commit_events.push(event); + } + } + + Ok(commit_events + .iter() + .filter(|e| !event_is_cover_letter(e) && permissioned_users.contains(&e.author())) + .cloned() + .collect()) +} -- cgit v1.2.3