Skip to content
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
5 changes: 4 additions & 1 deletion scripts/release-notes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,7 @@ summary_section() {
$0 == "<!-- release-summary:start -->" && !done { inside = 1 }'
}

main "$@"
# Only when run, so the tests can source the script and call its functions.
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi
13 changes: 11 additions & 2 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ refuse() {
# the latest CI run on main is on <base> and succeeded.
check_can_release() {
local current short=${3:0:7} latest sha status conclusion
if ! [[ $1 =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
if ! is_plain_version "$1"; then
refuse "it is not a plain X.Y.Z version, such as 0.4.0"
fi
current=$(git show "$3:Cargo.toml" | package_field version)
Expand Down Expand Up @@ -234,6 +234,12 @@ check_can_release() {
fi
}

# is_plain_version <version>
# Whether <version> is plain semver X.Y.Z, with no leading zeros.
is_plain_version() {
[[ $1 =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
}

# is_higher <version> <other>
# Whether plain semver <version> is higher than <other>, part by part.
is_higher() {
Expand Down Expand Up @@ -427,4 +433,7 @@ merge_commit_of() {
echo "$merge"
}

main "$@"
# Only when run, so the tests can source the script and call its functions.
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi
20 changes: 20 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,24 @@ mod tests {
assert_eq!(run_args.goal, goal, "{args:?}");
}
}

#[test]
fn no_email_asks_for_no_notification_with_or_without_dashes() {
for flag in ["no-email", "--no-email"] {
assert_eq!(
run_args(&[flag, URL]).email,
Some(NotificationAsk::Skip),
"{flag}"
);
}
}

#[test]
fn parallel_takes_the_number_after_it_with_or_without_dashes() {
for flag in ["parallel", "--parallel"] {
for args in [[flag, "1", URL], [URL, flag, "1"]] {
assert_eq!(run_args(&args).parallel, NonZeroUsize::new(1), "{args:?}");
}
}
}
}
6 changes: 5 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,11 @@ mod tests {
fn email_always_asks_for_a_notification_to_email_to() {
let always = parse("[email]\nalways = true\n").unwrap();
assert_eq!(always.email.default_ask(), NotificationAsk::Send(None));
for text in ["", "[email]\nalways = false\n"] {
for text in [
"",
"[email]\nalways = false\n",
"[email]\nto = \"me@example.com\"\n",
] {
let config = parse(text).unwrap();
assert_eq!(
config.email.default_ask(),
Expand Down
19 changes: 19 additions & 0 deletions src/interrupt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,22 @@ pub fn requested() -> bool {
.get()
.is_some_and(|flag| flag.load(Ordering::SeqCst))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn sigint_sigterm_and_sighup_are_each_recorded_as_an_interrupt() {
install().unwrap();
let flag = REQUESTED.get().unwrap();
for signal in [SIGINT, SIGTERM, SIGHUP] {
flag.store(false, Ordering::SeqCst);

signal_hook::low_level::raise(signal).unwrap();

assert!(requested(), "signal {signal}");
}
flag.store(false, Ordering::SeqCst);
}
}
64 changes: 57 additions & 7 deletions src/resend_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,16 @@ impl ResendKey {

/// The key in `RESEND_API_KEY`, if it is set and not empty.
fn from_environment() -> Option<ResendKey> {
std::env::var("RESEND_API_KEY")
.ok()
.filter(|key| !key.is_empty())
.map(|secret| ResendKey {
secret,
source: Source::Environment,
})
environment_key(std::env::var("RESEND_API_KEY").ok())
}

/// The key in `RESEND_API_KEY` when it holds `value`: none if it is unset or
/// empty, so the Credentials are read instead.
fn environment_key(value: Option<String>) -> Option<ResendKey> {
value.filter(|key| !key.is_empty()).map(|secret| ResendKey {
secret,
source: Source::Environment,
})
}

/// The Credentials, as read: the file's text, if there is one, and the key
Expand Down Expand Up @@ -253,3 +256,50 @@ fn missing(path: &Path) -> String {
path.display()
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn resend_api_key_unset_or_empty_holds_no_key() {
for value in [None, Some(String::new())] {
assert!(environment_key(value.clone()).is_none(), "{value:?}");
}
let key = environment_key(Some("re_test_123".into())).unwrap();
assert_eq!(key.secret, "re_test_123");
assert_eq!(key.source, Source::Environment);
}

#[test]
fn the_credentials_key_is_read_and_an_empty_one_is_none() {
let file = Source::Credentials(PathBuf::from("/home/me/.thirdshift/credentials.toml"));

assert_eq!(
parse("[resend]\nkey = \"re_file_456\"\n", &file).unwrap(),
Some("re_file_456".to_string())
);
for text in ["", "[resend]\n", "[resend]\nkey = \"\"\n"] {
assert_eq!(parse(text, &file).unwrap(), None, "{text:?}");
}
}

#[test]
fn broken_credentials_are_an_error_naming_the_file_and_what_is_wrong() {
let path = "/home/me/.thirdshift/credentials.toml";
let file = Source::Credentials(PathBuf::from(path));
for (text, named) in [
("[resend\n", "can't parse"),
("[resend]\nkye = \"re_file_456\"\n", "resend.kye"),
("[resend]\nkey = true\n", "resend.key"),
("resend = 1\n", "resend must be the section [resend]"),
("[email]\n", "unknown section [email]"),
("key = \"re_file_456\"\n", "unknown key key"),
] {
let error = format!("{:#}", parse(text, &file).unwrap_err());
for part in [named, path] {
assert!(error.contains(part), "{text:?}: no {part:?} in {error}");
}
}
}
}
63 changes: 63 additions & 0 deletions src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,3 +557,66 @@ fn ensure_pr_ready_and_mergeable(issue: &IssueUrl, branch: &str) -> Result<()> {
),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn repairs_of_every_kind_share_one_cap_and_the_one_too_many_names_its_cause() {
let mut budgets = Budgets::default();
for n in 1..=MAX_REPAIRS {
let cause = if n % 2 == 0 { "conflict" } else { "CI red" };
assert_eq!(budgets.next_repair(cause).unwrap(), format!("repair-{n}"));
}

for cause in ["new commits on origin/issue-7 to review", "conflict"] {
let error = budgets.next_repair(cause).unwrap_err();
assert_eq!(error.to_string(), format!("repairs exhausted: {cause}"));
}
assert_eq!(budgets.repairs, MAX_REPAIRS);
}

#[test]
fn upstream_moves_of_the_base_or_the_issue_branch_share_one_budget() {
let mut budgets = Budgets::default();
for _ in 1..MAX_UPSTREAM_MOVES {
budgets.count_base_move("main", "while CI ran").unwrap();
}
budgets
.count_upstream_move("origin/issue-7", format_args!("new commits"))
.unwrap();

let error = budgets.count_base_move("main", "while CI ran").unwrap_err();
assert_eq!(
error.to_string(),
format!("origin/main kept moving: merged it again {MAX_UPSTREAM_MOVES} times")
);
let error = budgets
.count_upstream_move("origin/issue-7", format_args!("new commits"))
.unwrap_err();
assert_eq!(
error.to_string(),
format!("origin/issue-7 kept moving: merged it again {MAX_UPSTREAM_MOVES} times")
);
}

#[test]
fn the_repair_cap_and_the_upstream_move_budget_are_spent_apart() {
let mut budgets = Budgets::default();
for _ in 0..MAX_REPAIRS {
budgets.next_repair("CI red").unwrap();
}

for _ in 0..MAX_UPSTREAM_MOVES {
budgets.count_base_move("main", "while CI ran").unwrap();
}
assert_eq!(
budgets,
Budgets {
repairs: MAX_REPAIRS,
upstream_moves: MAX_UPSTREAM_MOVES
}
);
}
}
20 changes: 20 additions & 0 deletions src/spec_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,26 @@ mod tests {
assert_eq!(next_ready(&tickets, &outcomes, &HashSet::new()), Some(23));
}

#[test]
fn each_unready_label_keeps_a_ticket_from_running_and_is_named_in_its_line() {
for label in UNREADY_LABELS {
let mut unready = ticket(21, true, &[], &[]);
unready.labels = vec!["ready-for-agent".to_string(), label.to_string()];
let tickets = [unready, ticket(22, true, &[], &[])];
let outcomes = BTreeMap::new();

assert_eq!(
next_ready(&tickets, &outcomes, &HashSet::new()),
Some(22),
"{label}"
);
assert_eq!(
summarize(&tickets, &outcomes)[0],
format!("#21 unready: labelled {label}")
);
}
}

#[test]
fn the_checklist_ticks_done_tickets_and_says_where_each_other_stands() {
let mut unready = ticket(26, true, &[], &[]);
Expand Down
11 changes: 9 additions & 2 deletions tests/merge_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ fn the_merge_flag_also_starts_a_merge_run() {

#[test]
fn a_merge_run_gives_the_agent_the_same_implement_prompt_as_a_run() {
// Two Runs, one of them a Merge run: the behavior is that they agree.
let run = Scenario::new();
run.agent_does(AGENT_OPENS_PR);
let merge_run = Scenario::new();
Expand Down Expand Up @@ -668,6 +669,8 @@ echo '{"type": "system", "subtype": "task_updated", "task_id": "b1", "patch": {"

#[test]
fn foreign_commits_that_keep_coming_spend_the_upstream_move_budget() {
// Five real rounds, as the budget runs out only after them. The Run's unit
// tests take the counting.
let scenario = Scenario::new();
scenario.agent_does_in_session(
1,
Expand Down Expand Up @@ -703,7 +706,8 @@ fn foreign_commits_that_keep_coming_spend_the_upstream_move_budget() {
fn a_review_repair_counts_against_the_repair_cap() {
let scenario = Scenario::new();
// Five CI-fix Repairs leave none for reviewing the Foreign commit pushed
// while CI runs on the fifth fix.
// while CI runs on the fifth fix. They are real Repairs, as the cap runs
// out only after them; the Run's unit tests take the counting.
scenario.agent_does_in_session(1, &format!("{AGENT_OPENS_PR}{}", checks_on_head(RED)));
for session in 2..=6 {
let (checks, then) = if session == 6 {
Expand Down Expand Up @@ -897,6 +901,8 @@ fn a_merge_that_fails_then_finds_red_ci_on_a_new_head_gets_a_ci_fix_repair_then_

#[test]
fn merge_failures_that_keep_moving_the_base_branch_spend_the_base_move_budget() {
// Six real merge attempts, as the budget runs out only after them. The
// Run's unit tests take the counting.
let scenario = Scenario::new();
scenario.agent_does(&format!(
"{AGENT_OPENS_PR}{}{}",
Expand Down Expand Up @@ -931,7 +937,8 @@ fn merge_failures_that_keep_moving_the_base_branch_spend_the_base_move_budget()
fn a_repair_needed_after_a_failed_merge_counts_against_the_repair_cap() {
let scenario = Scenario::new();
// Five CI-fix Repairs before the first merge attempt leave none for the
// conflict the merge then runs into.
// conflict the merge then runs into. They are real Repairs, as the cap
// runs out only after them; the Run's unit tests take the counting.
scenario.agent_does(&format!(
"{AGENT_OPENS_PR}{}{}",
checks_on_head(RED),
Expand Down
Loading
Loading