Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8c8b478
refactor(input): support explicit production shutdown
lorenzoberts Jul 9, 2026
3b97662
refactor(app): surface patchset open failures
lorenzoberts Jul 9, 2026
94c8f5c
refactor(startup): propagate lore bootstrap errors
lorenzoberts Jul 9, 2026
e99c95e
refactor(startup): check dependencies before terminal init
lorenzoberts Jul 9, 2026
ae5d5fb
refactor(app): use rendered preview height for scroll bounds
lorenzoberts Jul 9, 2026
cc61ebe
style: move inline full paths into imports
lorenzoberts Jul 9, 2026
5507d3b
style: remove decorative section divider comments
lorenzoberts Jul 9, 2026
0f0406a
refactor(app): extract patch apply action service
lorenzoberts Jul 9, 2026
006aad3
refactor(app): extract reviewed-by reply action service
lorenzoberts Jul 9, 2026
bdf595f
refactor(app): simplify patchset action consolidation
lorenzoberts Jul 9, 2026
d1aa935
refactor(app): group patchset actions behind service
lorenzoberts Jul 9, 2026
bbeb5e2
test(app): add integration test module shell
lorenzoberts Jul 9, 2026
a1dbc77
test(app): add integration test helpers
lorenzoberts Jul 9, 2026
291c4fc
test(app): add navigation flow integration tests
lorenzoberts Jul 9, 2026
96c311d
test(app): add patch-open error flow integration tests
lorenzoberts Jul 9, 2026
88b8ae3
test(app): add apply action integration tests
lorenzoberts Jul 9, 2026
ed46a1a
test(app): add reviewed-by action integration tests
lorenzoberts Jul 9, 2026
41914bd
test(app): add actor lifecycle integration test
lorenzoberts Jul 9, 2026
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
468 changes: 468 additions & 0 deletions src/app/actions/apply.rs

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions src/app/actions/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
pub(crate) mod apply;
pub(crate) mod reviewed_reply;

use color_eyre::Result;

use crate::{
config::ConfigSnapshot,
infrastructure::{file_system::FileSystemTrait, shell::ShellTrait},
lore::application::handle::LoreApiHandle,
};

use apply::ApplyPatchsetRequest;
use reviewed_reply::{ReviewedReplyRequest, ReviewedReplyResult};

pub(crate) struct PatchsetActionService<'a> {
fs: &'a dyn FileSystemTrait,
shell: &'a dyn ShellTrait,
lore_api: &'a LoreApiHandle,
}

impl<'a> PatchsetActionService<'a> {
pub(crate) fn new(
fs: &'a dyn FileSystemTrait,
shell: &'a dyn ShellTrait,
lore_api: &'a LoreApiHandle,
) -> Self {
Self {
fs,
shell,
lore_api,
}
}

pub(crate) fn apply_patchset(
&self,
request: &ApplyPatchsetRequest,
config: &ConfigSnapshot,
) -> Result<String, String> {
apply::apply_patchset(request, self.fs, self.shell, config)
}

pub(crate) async fn execute_reviewed_reply(
&self,
request: ReviewedReplyRequest,
) -> Result<ReviewedReplyResult> {
reviewed_reply::execute_reviewed_reply(request, self.lore_api, self.shell).await
}
}
207 changes: 207 additions & 0 deletions src/app/actions/reviewed_reply.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
use std::{collections::HashSet, path::PathBuf, str};

use color_eyre::{eyre::eyre, Result};

use crate::{
infrastructure::shell::{ShellCommand, ShellTrait},
lore::application::handle::LoreApiHandle,
};

pub(crate) struct ReviewedReplyRequest {
pub raw_patches: Vec<String>,
pub patches_to_reply: Vec<bool>,
pub successful_indexes: HashSet<usize>,
pub git_send_email_options: String,
}

pub(crate) enum ReviewedReplyResult {
NoAction { successful_indexes: HashSet<usize> },
MissingGitIdentity { successful_indexes: HashSet<usize> },
Completed { successful_indexes: HashSet<usize> },
}

impl ReviewedReplyResult {
pub(crate) fn into_successful_indexes(self) -> HashSet<usize> {
match self {
ReviewedReplyResult::NoAction { successful_indexes }
| ReviewedReplyResult::MissingGitIdentity { successful_indexes }
| ReviewedReplyResult::Completed { successful_indexes } => successful_indexes,
}
}
}

pub(crate) async fn execute_reviewed_reply(
request: ReviewedReplyRequest,
lore_api: &LoreApiHandle,
shell: &dyn ShellTrait,
) -> Result<ReviewedReplyResult> {
if !request.patches_to_reply.contains(&true) {
return Ok(ReviewedReplyResult::NoAction {
successful_indexes: request.successful_indexes,
});
}

let (git_user_name, git_user_email) = lore_api
.get_git_signature(String::new())
.await
.map_err(|e| eyre!("{e:#?}"))?;

let mut successful_indexes = request.successful_indexes;
let Some(git_signature) = git_signature(git_user_name, git_user_email) else {
println!("`git config user.name` or `git config user.email` not set\nAborting...");
return Ok(ReviewedReplyResult::MissingGitIdentity { successful_indexes });
};

let mktemp_cmd = ShellCommand::new("mktemp").arg("--directory");
let tmp_out = shell
.execute(&mktemp_cmd)
.map_err(|e| eyre!("failed to create temp directory: {}", e))?;
let tmp_dir_str = str::from_utf8(&tmp_out.stdout)
.map_err(|e| eyre!("invalid utf-8 in temp dir path: {}", e))?
.trim()
.to_string();
let tmp_dir = PathBuf::from(tmp_dir_str);

let git_reply_commands = lore_api
.prepare_reply_commands(
tmp_dir,
"all".to_string(),
request.raw_patches,
request.patches_to_reply.clone(),
git_signature,
request.git_send_email_options,
)
.await
.map_err(|e| eyre!("{e:#?}"))?;

record_successful_reply_indexes(
shell,
&mut successful_indexes,
&request.patches_to_reply,
git_reply_commands,
);

Ok(ReviewedReplyResult::Completed { successful_indexes })
}

fn record_successful_reply_indexes(
shell: &dyn ShellTrait,
successful_indexes: &mut HashSet<usize>,
patches_to_reply: &[bool],
git_reply_commands: Vec<ShellCommand>,
) {
let reply_indexes: Vec<usize> = selected_reply_indexes(patches_to_reply);
for (i, command) in git_reply_commands.into_iter().enumerate() {
let success = shell.spawn_interactive(&command).unwrap_or(false);
if success {
successful_indexes.insert(reply_indexes[i]);
}
}
}

fn selected_reply_indexes(patches_to_reply: &[bool]) -> Vec<usize> {
patches_to_reply
.iter()
.enumerate()
.filter_map(|(i, &val)| if val { Some(i) } else { None })
.collect()
}

fn git_signature(git_user_name: String, git_user_email: String) -> Option<String> {
if git_user_name.is_empty() || git_user_email.is_empty() {
None
} else {
Some(format!("{git_user_name} <{git_user_email}>"))
}
}

#[cfg(test)]
mod tests {
use std::{collections::HashSet, io};

use crate::infrastructure::shell::{MockShellTrait, ShellError};

use super::*;

fn command(name: &str) -> ShellCommand {
ShellCommand::new("git").arg("send-email").arg(name)
}

#[test]
fn selected_reply_indexes_preserves_original_patch_indexes() {
let indexes = selected_reply_indexes(&[false, true, false, true]);

assert_eq!(vec![1, 3], indexes);
}

#[test]
fn git_signature_requires_name_and_email() {
assert_eq!(
Some("User <user@example.com>".to_string()),
git_signature("User".to_string(), "user@example.com".to_string())
);
assert_eq!(
None,
git_signature(String::new(), "user@example.com".to_string())
);
assert_eq!(None, git_signature("User".to_string(), String::new()));
}

#[test]
fn record_successful_reply_indexes_records_only_successful_commands() {
let mut shell = MockShellTrait::new();
shell
.expect_spawn_interactive()
.times(2)
.returning(|cmd| Ok(cmd.args.last().is_some_and(|arg| arg == "first")));
let mut successful_indexes = HashSet::from([0]);

record_successful_reply_indexes(
&shell,
&mut successful_indexes,
&[false, true, false, true],
vec![command("first"), command("second")],
);

assert_eq!(HashSet::from([0, 1]), successful_indexes);
}

#[test]
fn record_successful_reply_indexes_treats_shell_error_as_failure() {
let mut shell = MockShellTrait::new();
shell
.expect_spawn_interactive()
.times(1)
.returning(|_| Err(ShellError::IoError(io::Error::other("failed"))));
let mut successful_indexes = HashSet::new();

record_successful_reply_indexes(
&shell,
&mut successful_indexes,
&[true],
vec![command("first")],
);

assert!(successful_indexes.is_empty());
}

#[test]
fn reviewed_reply_result_returns_successful_indexes_for_each_status() {
let no_action = ReviewedReplyResult::NoAction {
successful_indexes: HashSet::from([1]),
};
let missing_identity = ReviewedReplyResult::MissingGitIdentity {
successful_indexes: HashSet::from([2]),
};
let completed = ReviewedReplyResult::Completed {
successful_indexes: HashSet::from([3]),
};

assert_eq!(HashSet::from([1]), no_action.into_successful_indexes());
assert_eq!(
HashSet::from([2]),
missing_identity.into_successful_indexes()
);
assert_eq!(HashSet::from([3]), completed.into_successful_indexes());
}
}
Loading
Loading