use std::num::NonZeroUsize;
use crate::args;
use crate::base_fix::BaseFixAsk;
use crate::child_run::Given;
use crate::config::UserConfig;
use crate::harness::{self, Choice};
use crate::issue::IssueUrl;
use crate::notification::NotificationAsk;
use crate::run::Goal;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Flags {
pub goal: Option<Goal>,
pub email: Option<NotificationAsk>,
pub parallel: Option<NonZeroUsize>,
pub base_fix: Option<BaseFixAsk>,
pub security_fix: Option<crate::security::FixAsk>,
pub security_review: Option<crate::security::review::Ask>,
pub harness: harness::Asked,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Asks {
pub goal: Goal,
pub notification: NotificationAsk,
pub tickets_at_once: NonZeroUsize,
pub parallel_asked: bool,
pub base_fix: BaseFixAsk,
pub security_fix: bool,
pub security_review: bool,
pub launch_pull: bool,
pub harness: Choice,
}
impl Asks {
pub fn of_run(issue: &IssueUrl, flags: &Flags, config: &UserConfig) -> Asks {
let goal = flags.goal.unwrap_or(if config.merge_always {
Goal::Merged
} else {
Goal::ReadyForReview
});
let base_fix = flags.base_fix.clone().unwrap_or_else(|| {
if config.base_fix {
BaseFixAsk::Allow
} else {
let retry = args::retry_with_base_fix(issue, flags);
BaseFixAsk::Undecided { retry }
}
});
Asks {
goal,
notification: flags.notification(config),
tickets_at_once: flags.parallel.unwrap_or(config.spec_parallel),
parallel_asked: flags.parallel.is_some(),
base_fix,
security_fix: flags.security_fix_allowed(config),
security_review: flags
.security_review
.map(|ask| ask == crate::security::review::Ask::Allow)
.unwrap_or(config.security_review),
launch_pull: config.launch_pull,
harness: flags.harness(config),
}
}
pub fn of_child_run(given: &Given, config: &UserConfig) -> Asks {
Asks {
goal: Goal::Merged,
notification: NotificationAsk::Skip,
tickets_at_once: config.spec_parallel,
parallel_asked: false,
base_fix: given.base_fix.clone(),
security_fix: given.asks.security_fix,
security_review: given.asks.security_review,
launch_pull: false,
harness: given.asks.harness.clone(),
}
}
pub fn of_architect_plan(plan: &IssueUrl, flags: &Flags, config: &UserConfig) -> Asks {
Asks::dispatched(plan, flags, config)
}
pub fn of_ready_issue(
issue: &IssueUrl,
is_spec: bool,
flags: &Flags,
config: &UserConfig,
) -> Asks {
let flags = Flags {
parallel: flags.parallel.filter(|_| is_spec),
..flags.clone()
};
Asks::dispatched(issue, &flags, config)
}
fn dispatched(issue: &IssueUrl, flags: &Flags, config: &UserConfig) -> Asks {
Asks {
notification: NotificationAsk::Skip,
..Asks::of_run(issue, flags, config)
}
}
}
impl Flags {
pub fn security_fix_allowed(&self, config: &UserConfig) -> bool {
match self.security_fix {
Some(crate::security::FixAsk::Allow) => true,
Some(crate::security::FixAsk::Forbid) => false,
None => config.security_fix.unwrap_or(false),
}
}
pub fn any_for_dispatched_run(&self) -> bool {
let for_dispatched_run = Flags {
email: None,
..self.clone()
};
for_dispatched_run != Flags::default()
}
pub fn notification(&self, config: &UserConfig) -> NotificationAsk {
self.email.clone().unwrap_or(if config.email.always {
NotificationAsk::Send(None)
} else {
NotificationAsk::Skip
})
}
pub fn harness(&self, config: &UserConfig) -> Choice {
Choice::of(&self.harness, &config.harness)
}
pub fn security_harness(&self, config: &UserConfig) -> Choice {
let settings = harness::Settings {
default: config.security_harness.or(config.harness.default),
..config.harness.clone()
};
let mut choice = Choice::of(&self.harness, &settings);
if self.harness.harness.is_none() && config.security_harness.is_some() {
choice.chosen_by = harness::ChosenBy::SecurityConfig;
}
choice
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::child_run::Kind;
use crate::config::EmailSettings;
use crate::harness::{ChosenBy, Harness, ModelAndEffort};
const URL: &str = "https://github.com/acme/widgets/issues/7";
fn issue() -> IssueUrl {
IssueUrl::parse(URL).unwrap()
}
fn n(n: usize) -> NonZeroUsize {
NonZeroUsize::new(n).unwrap()
}
fn to(address: &str) -> NotificationAsk {
NotificationAsk::Send(Some(address.to_string()))
}
fn no_settings() -> UserConfig {
UserConfig {
merge_always: false,
base_fix: false,
launch_pull: false,
logs_dir: PathBuf::from("/home/me/.thirdshift/logs"),
quiet_skips: false,
email: EmailSettings::default(),
spec_parallel: n(3),
pickup_limit: n(3),
pickup_wait: chrono::TimeDelta::minutes(30),
harness: harness::Settings::default(),
security_harness: None,
security_fix: None,
security_review: false,
}
}
fn every_setting() -> UserConfig {
UserConfig {
merge_always: true,
base_fix: true,
launch_pull: true,
email: EmailSettings {
always: true,
to: Some("config@example.com".to_string()),
from: None,
},
spec_parallel: n(5),
harness: harness::Settings {
default: Some(Harness::Codex),
claude: model_and_effort("opus", "high"),
codex: model_and_effort("gpt-6.1-sol", "max"),
..harness::Settings::default()
},
..no_settings()
}
}
fn model_and_effort(model: &str, effort: &str) -> ModelAndEffort {
ModelAndEffort {
model: Some(model.to_string()),
effort: Some(effort.to_string()),
}
}
fn configured_codex() -> Choice {
Choice {
harness: Harness::Codex,
model: Some("gpt-6.1-sol".to_string()),
effort: Some("max".to_string()),
chosen_by: ChosenBy::UserConfig,
}
}
fn sonnet(effort: Option<&str>) -> Choice {
Choice {
harness: Harness::Claude,
model: Some("sonnet".to_string()),
effort: effort.map(str::to_string),
chosen_by: ChosenBy::Command,
}
}
fn claude_on_sonnet() -> harness::Asked {
harness::Asked {
harness: Some(Harness::Claude),
model_and_effort: ModelAndEffort {
model: Some("sonnet".to_string()),
effort: None,
},
}
}
fn undecided(flags: &str) -> BaseFixAsk {
BaseFixAsk::Undecided {
retry: format!("thirdshift {URL}{flags} base-fix"),
}
}
#[test]
fn a_run_with_neither_a_flag_nor_a_setting_does_only_what_a_run_does() {
let asks = Asks::of_run(&issue(), &Flags::default(), &no_settings());
assert_eq!(
asks,
Asks {
security_fix: false,
security_review: false,
goal: Goal::ReadyForReview,
notification: NotificationAsk::Skip,
tickets_at_once: n(3),
parallel_asked: false,
base_fix: undecided(""),
launch_pull: false,
harness: Choice::default(),
}
);
}
#[test]
fn the_user_config_decides_each_ask_the_command_says_nothing_about() {
let asks = Asks::of_run(&issue(), &Flags::default(), &every_setting());
assert_eq!(
asks,
Asks {
security_fix: false,
security_review: false,
goal: Goal::Merged,
notification: NotificationAsk::Send(None),
tickets_at_once: n(5),
parallel_asked: false,
base_fix: BaseFixAsk::Allow,
launch_pull: true,
harness: configured_codex(),
}
);
}
#[test]
fn each_flag_decides_its_ask_whatever_the_user_config_says() {
let against = Flags {
security_fix: None,
security_review: None,
goal: Some(Goal::ReadyForReview),
email: Some(NotificationAsk::Skip),
parallel: Some(n(2)),
base_fix: Some(BaseFixAsk::Forbid),
harness: claude_on_sonnet(),
};
let asks = Asks::of_run(&issue(), &against, &every_setting());
assert_eq!(
asks,
Asks {
security_fix: false,
security_review: false,
goal: Goal::ReadyForReview,
notification: NotificationAsk::Skip,
tickets_at_once: n(2),
parallel_asked: true,
base_fix: BaseFixAsk::Forbid,
launch_pull: true,
harness: sonnet(Some("high")),
}
);
let for_it = Flags {
security_fix: None,
security_review: None,
goal: Some(Goal::Merged),
email: Some(to("flag@example.com")),
parallel: Some(n(2)),
base_fix: Some(BaseFixAsk::Allow),
harness: claude_on_sonnet(),
};
let asks = Asks::of_run(&issue(), &for_it, &no_settings());
assert_eq!(
asks,
Asks {
security_fix: false,
security_review: false,
goal: Goal::Merged,
notification: to("flag@example.com"),
tickets_at_once: n(2),
parallel_asked: true,
base_fix: BaseFixAsk::Allow,
launch_pull: false,
harness: sonnet(None),
}
);
}
fn given(kind: Kind, base_fix: BaseFixAsk) -> Given {
Given {
command: crate::logs::CommandKind::Issue,
kind,
stamp: "20261003T120000-0400".to_string(),
base_fix,
asks: crate::child_run::ChildAsks {
harness: sonnet(None),
..crate::child_run::ChildAsks::default()
},
}
}
fn ticket() -> Kind {
Kind::Ticket {
spec_branch: "issue-20".to_string(),
}
}
fn base_fix() -> Kind {
Kind::BaseFix {
base: "main".to_string(),
}
}
#[test]
fn a_child_run_is_a_merge_run_that_sends_nothing_pulls_nothing_and_starts_no_base_fix() {
for child in [ticket(), base_fix()] {
for config in [no_settings(), every_setting()] {
let given = given(child.clone(), BaseFixAsk::Forbid);
let asks = Asks::of_child_run(&given, &config);
assert_eq!(asks.goal, Goal::Merged, "{child:?}");
assert_eq!(asks.notification, NotificationAsk::Skip, "{child:?}");
assert!(!asks.launch_pull, "{child:?}");
assert!(!asks.parallel_asked, "{child:?}");
assert_eq!(asks.base_fix, BaseFixAsk::Forbid, "{child:?}");
}
}
}
#[test]
fn a_child_run_runs_its_sessions_on_what_it_was_given_whatever_the_user_config_says() {
for config in [no_settings(), every_setting()] {
let asks = Asks::of_child_run(&given(ticket(), BaseFixAsk::Forbid), &config);
assert_eq!(asks.harness, sonnet(None));
}
}
#[test]
fn a_child_run_asks_about_a_base_fix_what_it_was_given_whatever_the_user_config_says() {
let offer = BaseFixAsk::Undecided {
retry: format!("thirdshift {URL} base-fix"),
};
for asked in [BaseFixAsk::Allow, offer] {
for config in [no_settings(), every_setting()] {
let asks = Asks::of_child_run(&given(ticket(), asked.clone()), &config);
assert_eq!(asks.base_fix, asked);
}
}
}
fn dispatch_flags() -> Flags {
Flags {
security_fix: None,
security_review: None,
goal: Some(Goal::Merged),
email: Some(to("flag@example.com")),
parallel: Some(n(2)),
base_fix: None,
harness: claude_on_sonnet(),
}
}
#[test]
fn a_dispatched_architect_plan_sends_no_notification_whoever_decides_its_asks() {
let asks = Asks::of_architect_plan(&issue(), &dispatch_flags(), &no_settings());
assert_eq!(
asks,
Asks {
security_fix: false,
security_review: false,
goal: Goal::Merged,
notification: NotificationAsk::Skip,
tickets_at_once: n(2),
parallel_asked: true,
base_fix: undecided(
" merge --email flag@example.com parallel 2 harness claude model sonnet"
),
launch_pull: false,
harness: sonnet(None),
}
);
let asks = Asks::of_architect_plan(&issue(), &Flags::default(), &every_setting());
assert_eq!(
asks,
Asks {
security_fix: false,
security_review: false,
goal: Goal::Merged,
notification: NotificationAsk::Skip,
tickets_at_once: n(5),
parallel_asked: false,
base_fix: BaseFixAsk::Allow,
launch_pull: true,
harness: configured_codex(),
}
);
}
#[test]
fn a_dispatched_ready_issue_that_is_a_spec_sends_no_notification_whoever_decides_its_asks() {
let asks = Asks::of_ready_issue(&issue(), true, &dispatch_flags(), &no_settings());
assert_eq!(
asks,
Asks {
security_fix: false,
security_review: false,
goal: Goal::Merged,
notification: NotificationAsk::Skip,
tickets_at_once: n(2),
parallel_asked: true,
base_fix: undecided(
" merge --email flag@example.com parallel 2 harness claude model sonnet"
),
launch_pull: false,
harness: sonnet(None),
}
);
let asks = Asks::of_ready_issue(&issue(), true, &Flags::default(), &every_setting());
assert_eq!(
asks,
Asks {
security_fix: false,
security_review: false,
goal: Goal::Merged,
notification: NotificationAsk::Skip,
tickets_at_once: n(5),
parallel_asked: false,
base_fix: BaseFixAsk::Allow,
launch_pull: true,
harness: configured_codex(),
}
);
}
#[test]
fn only_the_email_flags_are_not_for_a_dispatched_run() {
let email_only = Flags {
email: Some(to("flag@example.com")),
..Flags::default()
};
assert!(!Flags::default().any_for_dispatched_run());
assert!(!email_only.any_for_dispatched_run());
for flags in [
Flags {
goal: Some(Goal::ReadyForReview),
..email_only.clone()
},
Flags {
parallel: Some(n(1)),
..Flags::default()
},
Flags {
base_fix: Some(BaseFixAsk::Forbid),
..Flags::default()
},
Flags {
harness: claude_on_sonnet(),
..Flags::default()
},
] {
assert!(flags.any_for_dispatched_run(), "{flags:?}");
}
}
#[test]
fn parallel_is_ignored_for_a_dispatched_ready_issue_that_is_not_a_spec() {
let asks = Asks::of_ready_issue(&issue(), false, &dispatch_flags(), &every_setting());
assert!(!asks.parallel_asked);
assert_eq!(asks.tickets_at_once, n(5));
let asks = Asks::of_ready_issue(&issue(), false, &dispatch_flags(), &no_settings());
assert_eq!(
asks.base_fix,
undecided(" merge --email flag@example.com harness claude model sonnet")
);
let asks = Asks::of_ready_issue(&issue(), true, &dispatch_flags(), &no_settings());
assert_eq!(
asks.base_fix,
undecided(" merge --email flag@example.com parallel 2 harness claude model sonnet")
);
}
#[test]
fn all_dispatches_offer_the_original_issue_with_only_non_spec_parallel_removed() {
let flags = dispatch_flags();
let expected = BaseFixAsk::Undecided {
retry: args::retry_with_base_fix(&issue(), &flags),
};
for asks in [
Asks::of_run(&issue(), &flags, &no_settings()),
Asks::of_architect_plan(&issue(), &flags, &no_settings()),
Asks::of_ready_issue(&issue(), true, &flags, &no_settings()),
] {
assert_eq!(asks.base_fix, expected);
}
let without_parallel = Flags {
parallel: None,
..flags.clone()
};
assert_eq!(
Asks::of_ready_issue(&issue(), false, &flags, &no_settings()).base_fix,
BaseFixAsk::Undecided {
retry: args::retry_with_base_fix(&issue(), &without_parallel),
}
);
}
#[test]
fn an_architect_run_or_a_pickup_run_is_asked_for_its_own_notification_as_a_run_is() {
for (email, config, asked) in [
(None, no_settings(), NotificationAsk::Skip),
(None, every_setting(), NotificationAsk::Send(None)),
(
Some(NotificationAsk::Skip),
every_setting(),
NotificationAsk::Skip,
),
(
Some(NotificationAsk::Send(None)),
no_settings(),
NotificationAsk::Send(None),
),
(
Some(to("flag@example.com")),
every_setting(),
to("flag@example.com"),
),
] {
let flags = Flags {
email,
..dispatch_flags()
};
assert_eq!(flags.notification(&config), asked, "{flags:?}");
}
}
#[test]
fn an_address_after_the_email_flag_wins_over_email_always() {
let flags = Flags {
email: Some(to("flag@example.com")),
..Flags::default()
};
let asks = Asks::of_run(&issue(), &flags, &every_setting());
assert_eq!(asks.notification, to("flag@example.com"));
}
#[test]
fn email_to_alone_asks_for_no_notification() {
let mut config = no_settings();
config.email.to = Some("config@example.com".to_string());
let asks = Asks::of_run(&issue(), &Flags::default(), &config);
assert_eq!(asks.notification, NotificationAsk::Skip);
}
}