Skip to content

Add reply w/ Reviewed-by tag of subset of patches from a series #80

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 65 additions & 20 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ use color_eyre::eyre::bail;
use config::Config;
use cover_renderer::render_cover;
use logging::Logger;
use patch_hub::lore::{lore_api_client::BlockingLoreAPIClient, lore_session, patch::Patch};
use patch_hub::lore::{
lore_api_client::BlockingLoreAPIClient,
lore_session,
patch::{Author, Patch},
};
use patch_renderer::{render_patch_preview, PatchRenderer};
use ratatui::text::Text;
use screens::{
Expand All @@ -15,7 +19,7 @@ use screens::{
mail_list::MailingListSelection,
CurrentScreen,
};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};

use crate::utils;

Expand All @@ -41,7 +45,7 @@ pub struct App {
/// Screen to edit configurations of the app
pub edit_config: Option<EditConfig>,
/// Database to track patchsets `Reviewed-by` state
pub reviewed_patchsets: HashMap<String, Vec<usize>>,
pub reviewed_patchsets: HashMap<String, HashSet<usize>>,
/// Configurations of the app
pub config: Config,
/// Client to handle Lore API requests and responses
Expand Down Expand Up @@ -131,6 +135,9 @@ impl App {
pub fn init_details_actions(&mut self) -> color_eyre::Result<()> {
let representative_patch: Patch;
let mut is_patchset_bookmarked = true;
let mut reviewed_by = Vec::new();
let mut tested_by = Vec::new();
let mut acked_by = Vec::new();

match &self.current_screen {
CurrentScreen::BookmarkedPatchsets => {
Expand Down Expand Up @@ -169,6 +176,33 @@ impl App {

let (raw_cover, raw_patch) = lore_session::split_cover(&raw_patch);

let mut authors_reviewed_by = HashSet::new();
let mut authors_tested_by = HashSet::new();
let mut authors_acked_by = HashSet::new();

let mut map = [
("Reviewed-by:", &mut authors_reviewed_by),
("Tested-by:", &mut authors_tested_by),
("Acked-by:", &mut authors_acked_by),
];

for line in raw_cover.lines() {
for (prefix, authors) in map.iter_mut() {
if let Some(stripped) = line.trim_start().strip_prefix(*prefix) {
let parts: Vec<&str> = stripped.trim().split('<').collect();
if parts.len() == 2 {
let name = parts[0].trim().to_string();
let email = parts[1].trim_end_matches('>').trim().to_string();
authors.insert(Author { name, email });
}
break; // Avoid unnecessary checks once a match is found
}
}
}
reviewed_by.push(authors_reviewed_by);
tested_by.push(authors_tested_by);
acked_by.push(authors_acked_by);

let rendered_cover = match render_cover(raw_cover, self.config.cover_renderer())
{
Ok(render) => render,
Expand All @@ -192,10 +226,14 @@ impl App {
patches_preview
.push(format!("{}---\n{}", rendered_cover, rendered_patch).into_text()?);
}
let has_cover_letter = representative_patch.number_in_series() == 0;
let patches_to_reply = vec![false; raw_patches.len()];
self.details_actions = Some(DetailsActions {
representative_patch,
raw_patches,
patches_preview,
patches_to_reply,
has_cover_letter,
preview_index: 0,
preview_scroll_offset: 0,
preview_pan: 0,
Expand All @@ -204,6 +242,9 @@ impl App {
(PatchsetAction::Bookmark, is_patchset_bookmarked),
(PatchsetAction::ReplyWithReviewedBy, false),
]),
reviewed_by,
tested_by,
acked_by,
last_screen: self.current_screen.clone(),
lore_api_client: self.lore_api_client.clone(),
});
Expand All @@ -230,7 +271,7 @@ impl App {
let representative_patch = &details_actions.representative_patch;
let actions = &details_actions.patchset_actions;

if *actions.get(&PatchsetAction::Bookmark).unwrap() {
if let Some(true) = actions.get(&PatchsetAction::Bookmark) {
self.bookmarked_patchsets
.bookmark_selected_patch(representative_patch);
} else {
Expand All @@ -243,26 +284,30 @@ impl App {
self.config.bookmarked_patchsets_path(),
)?;

if *actions.get(&PatchsetAction::ReplyWithReviewedBy).unwrap() {
let successful_indexes = details_actions
.reply_patchset_with_reviewed_by("all", self.config.git_send_email_options())?;

if !successful_indexes.is_empty() {
self.reviewed_patchsets.insert(
representative_patch.message_id().href.clone(),
successful_indexes,
);

lore_session::save_reviewed_patchsets(
&self.reviewed_patchsets,
self.config.reviewed_patchsets_path(),
)?;
}
if let Some(true) = actions.get(&PatchsetAction::ReplyWithReviewedBy) {
let mut successful_indexes = self
.reviewed_patchsets
.remove(&representative_patch.message_id().href)
.unwrap_or_default();
details_actions.reply_patchset_with_reviewed_by(
"all",
self.config.git_send_email_options(),
&mut successful_indexes,
)?;
self.reviewed_patchsets.insert(
representative_patch.message_id().href.clone(),
successful_indexes,
);

lore_session::save_reviewed_patchsets(
&self.reviewed_patchsets,
self.config.reviewed_patchsets_path(),
)?;

self.details_actions
.as_mut()
.unwrap()
.toggle_action(PatchsetAction::ReplyWithReviewedBy);
.reset_reply_with_reviewed_by_action();
}

Ok(())
Expand Down
69 changes: 56 additions & 13 deletions src/app/screens/details_actions.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,37 @@
use super::CurrentScreen;
use ::patch_hub::lore::{lore_api_client::BlockingLoreAPIClient, lore_session, patch::Patch};
use color_eyre::eyre::bail;
use patch_hub::lore::patch::Author;
use ratatui::text::Text;
use std::{collections::HashMap, path::Path, process::Command};
use std::{
collections::{HashMap, HashSet},
path::Path,
process::Command,
};

pub struct DetailsActions {
pub representative_patch: Patch,
/// Raw patches as plain text files
pub raw_patches: Vec<String>,
/// Patches in the format to be displayed as preview
pub patches_preview: Vec<Text<'static>>,
/// Indicates if patchset has a cover letter
pub has_cover_letter: bool,
/// Which patches to reply
pub patches_to_reply: Vec<bool>,
pub preview_index: usize,
pub preview_scroll_offset: usize,
/// Horizontal offset
pub preview_pan: usize,
/// If true, display the preview in full screen
pub preview_fullscreen: bool,
pub patchset_actions: HashMap<PatchsetAction, bool>,
/// For each patch, a set of `Authors` that appear in `Reviewed-by` trailers
pub reviewed_by: Vec<HashSet<Author>>,
/// For each patch, a set of `Authors` that appear in `Tested-by` trailers
pub tested_by: Vec<HashSet<Author>>,
/// For each patch, a set of `Authors` that appear in `Acked-by` trailers
pub acked_by: Vec<HashSet<Author>>,
pub last_screen: CurrentScreen,
pub lore_api_client: BlockingLoreAPIClient,
}
Expand Down Expand Up @@ -100,8 +115,32 @@ impl DetailsActions {
self.toggle_action(PatchsetAction::Bookmark);
}

pub fn toggle_reply_with_reviewed_by_action(&mut self) {
self.toggle_action(PatchsetAction::ReplyWithReviewedBy);
pub fn toggle_reply_with_reviewed_by_action(&mut self, all: bool) {
if all {
if self.patches_to_reply.contains(&false) {
// If there is at least one patch not to be replied, set all to be
self.patches_to_reply = vec![true; self.patches_to_reply.len()];
} else {
// If all patches are set to be replied, set none to be
self.patches_to_reply = vec![false; self.patches_to_reply.len()];
}
} else if let Some(entry) = self.patches_to_reply.get_mut(self.preview_index) {
*entry = !*entry;
}

if self.patches_to_reply.contains(&true) {
self.patchset_actions
.insert(PatchsetAction::ReplyWithReviewedBy, true);
} else {
self.patchset_actions
.insert(PatchsetAction::ReplyWithReviewedBy, false);
}
}

pub fn reset_reply_with_reviewed_by_action(&mut self) {
self.patches_to_reply = vec![false; self.patches_to_reply.len()];
self.patchset_actions
.insert(PatchsetAction::ReplyWithReviewedBy, false);
}

pub fn toggle_action(&mut self, patchset_action: PatchsetAction) {
Expand All @@ -111,23 +150,20 @@ impl DetailsActions {
}

pub fn actions_require_user_io(&self) -> bool {
*self
.patchset_actions
.get(&PatchsetAction::ReplyWithReviewedBy)
.unwrap()
self.patches_to_reply.contains(&true)
}

pub fn reply_patchset_with_reviewed_by(
&self,
target_list: &str,
git_send_email_options: &str,
) -> color_eyre::Result<Vec<usize>> {
successful_indexes: &mut HashSet<usize>,
) -> color_eyre::Result<()> {
let (git_user_name, git_user_email) = lore_session::get_git_signature("");
let mut successful_indexes = Vec::new();

if git_user_name.is_empty() || git_user_email.is_empty() {
println!("`git config user.name` or `git config user.email` not set\nAborting...");
return Ok(successful_indexes);
return Ok(());
}

let tmp_dir = Command::new("mktemp").arg("--directory").output().unwrap();
Expand All @@ -138,6 +174,7 @@ impl DetailsActions {
tmp_dir,
target_list,
&self.raw_patches,
&self.patches_to_reply,
&format!("{git_user_name} <{git_user_email}>"),
git_send_email_options,
) {
Expand All @@ -147,14 +184,20 @@ impl DetailsActions {
}
};

for (index, mut command) in git_reply_commands.into_iter().enumerate() {
let reply_indexes: Vec<usize> = self
.patches_to_reply
.iter()
.enumerate()
.filter_map(|(i, &val)| if val { Some(i) } else { None })
.collect();
for (i, mut command) in git_reply_commands.into_iter().enumerate() {
let mut child = command.spawn().unwrap();
let exit_status = child.wait().unwrap();
if exit_status.success() {
successful_indexes.push(index);
successful_indexes.insert(reply_indexes[i]);
}
}

Ok(successful_indexes)
Ok(())
}
}
19 changes: 15 additions & 4 deletions src/handler/details_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::time::Duration;

use crate::{
app::{screens::CurrentScreen, App},
ui::popup::{help::HelpPopUpBuilder, PopUp},
ui::popup::{help::HelpPopUpBuilder, review_trailers::ReviewTrailersPopUp, PopUp},
utils,
};
use ratatui::{
Expand All @@ -21,8 +21,12 @@ pub fn handle_patchset_details<B: Backend>(
let patchset_details_and_actions = app.details_actions.as_mut().unwrap();

if key.modifiers.contains(KeyModifiers::SHIFT) {
if let KeyCode::Char('G') = key.code {
patchset_details_and_actions.go_to_last_line()
match key.code {
KeyCode::Char('G') => patchset_details_and_actions.go_to_last_line(),
KeyCode::Char('R') => {
patchset_details_and_actions.toggle_reply_with_reviewed_by_action(true);
}
_ => {}
}
return Ok(());
}
Expand All @@ -43,6 +47,11 @@ pub fn handle_patchset_details<B: Backend>(
KeyCode::Char('d') => {
patchset_details_and_actions.preview_scroll_down(terminal_height / 2);
}
KeyCode::Char('t') => {
let popup =
ReviewTrailersPopUp::generate_trailers_popup(patchset_details_and_actions);
app.popup = Some(popup);
}
_ => {}
}
return Ok(());
Expand Down Expand Up @@ -91,7 +100,7 @@ pub fn handle_patchset_details<B: Backend>(
patchset_details_and_actions.toggle_bookmark_action();
}
KeyCode::Char('r') => {
patchset_details_and_actions.toggle_reply_with_reviewed_by_action();
patchset_details_and_actions.toggle_reply_with_reviewed_by_action(false);
}
KeyCode::Enter => {
if patchset_details_and_actions.actions_require_user_io() {
Expand Down Expand Up @@ -135,6 +144,8 @@ pub fn generate_help_popup() -> Box<dyn PopUp> {
.keybind("p", "Preview previous patch")
.keybind("b", "Toggle bookmark action")
.keybind("r", "Toggle reply with Reviewed-by action")
.keybind("Shift+r", "Toggle reply with Reviewed-by action for all patches")
.keybind("Ctrl+t", "Show code-review trailers details")
.build();

Box::new(popup)
Expand Down
13 changes: 9 additions & 4 deletions src/lore/lore_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::lore::patch::{Patch, PatchFeed, PatchRegex};
use derive_getters::Getters;
use regex::Regex;
use serde_xml_rs::from_str;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::io::{BufRead, BufReader};
use std::mem::swap;
use std::path::Path;
Expand Down Expand Up @@ -387,6 +387,7 @@ pub fn prepare_reply_patchset_with_reviewed_by<T>(
tmp_dir: &Path,
target_list: &str,
patches: &[String],
patches_to_reply: &[bool],
git_signature: &str,
git_send_email_options: &str,
) -> Result<Vec<Command>, LoreSessionError>
Expand All @@ -398,7 +399,11 @@ where
static RE_MESSAGE_ID: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?m)^Message-Id: <(.*?)>"#).unwrap());

for patch in patches.iter() {
for (i, patch) in patches.iter().enumerate() {
if !patches_to_reply[i] {
continue;
}

let message_id = RE_MESSAGE_ID
.captures(patch)
.unwrap()
Expand Down Expand Up @@ -514,7 +519,7 @@ pub fn get_git_signature(git_repo_path: &str) -> (String, String) {
}

pub fn save_reviewed_patchsets(
reviewed_patchsets: &HashMap<String, Vec<usize>>,
reviewed_patchsets: &HashMap<String, HashSet<usize>>,
filepath: &str,
) -> io::Result<()> {
if let Some(parent) = Path::new(filepath).parent() {
Expand All @@ -530,7 +535,7 @@ pub fn save_reviewed_patchsets(
Ok(())
}

pub fn load_reviewed_patchsets(filepath: &str) -> io::Result<HashMap<String, Vec<usize>>> {
pub fn load_reviewed_patchsets(filepath: &str) -> io::Result<HashMap<String, HashSet<usize>>> {
let reviewed_patchsets_file = File::open(filepath)?;
let reviewed_patchsets = serde_json::from_reader(reviewed_patchsets_file)?;
Ok(reviewed_patchsets)
Expand Down
Loading