use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::Result;
use crate::architect::Outcome;
use crate::base_fix::Advice;
use crate::command_log;
use crate::config::EmailSettings;
use crate::email::Resend;
use crate::failed_run::FailedRun;
use crate::github;
use crate::host;
use crate::issue::{IssueUrl, Repo};
use crate::launch;
use crate::progress;
use crate::run::Ended;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NotificationAsk {
Send(Option<String>),
Skip,
}
struct Checked {
resend: Resend,
started: Instant,
}
impl Checked {
fn new(to: Option<String>, settings: &EmailSettings) -> Result<Self> {
let started = Instant::now();
let resend = Resend::new(to, settings)?;
Ok(Checked { resend, started })
}
}
pub struct RunNotification {
checked: Checked,
issue: IssueUrl,
title: Option<String>,
}
impl RunNotification {
pub fn new(to: Option<String>, settings: &EmailSettings, issue: &IssueUrl) -> Result<Self> {
let checked = Checked::new(to, settings)?;
Ok(RunNotification {
checked,
issue: issue.clone(),
title: github::issue_title(issue).ok(),
})
}
pub fn send(self, ended: &Ended) {
let ending = Ending::of(ended);
let subject = subject(&self.issue, self.title.as_deref(), ending.outcome);
send(&self.checked, &subject, None, &ending);
}
}
pub struct PickupNotification(Checked);
impl PickupNotification {
pub fn new(to: Option<String>, settings: &EmailSettings) -> Result<Self> {
Checked::new(to, settings).map(PickupNotification)
}
pub fn of_taken(self, issue: &IssueUrl, title: String) -> RunNotification {
RunNotification {
checked: self.0,
issue: issue.clone(),
title: Some(title),
}
}
}
pub struct ArchitectNotification {
checked: Checked,
repo: Option<Repo>,
}
impl ArchitectNotification {
pub fn new(to: Option<String>, settings: &EmailSettings) -> Result<Self> {
let checked = Checked::new(to, settings)?;
Ok(ArchitectNotification {
checked,
repo: launch::repo().ok(),
})
}
pub fn send(self, ended: &Result<Outcome, FailedRun>, dispatched: Option<&Ended>) {
let ending = match (ended, dispatched) {
(_, Some(dispatched)) => Ending::of(dispatched),
(Ok(outcome), None) => Ending {
outcome: outcome.name(),
pr_url: None,
cause: None,
advice: &[],
base_fix: None,
log: None,
tickets: &[],
},
(Err(failed), None) => Ending {
outcome: failure_outcome(failed, "review failed"),
..Ending::of_failure(failed)
},
};
let reviewed = |review: String| ArchitectLines::Reviewed {
review,
dispatched: dispatched.map(|_| ending.outcome),
};
let lines = match ended {
Ok(Outcome::Skipped(skipped)) => ArchitectLines::Skipped(skipped.to_string()),
Ok(Outcome::Reviewed(review)) => {
reviewed(format!("{}: {}", review.review(), review.url()))
}
Err(failed) => reviewed(failure_outcome(failed, "failed").to_string()),
};
let subject = architect_subject(self.repo.as_ref(), ending.outcome);
send(&self.checked, &subject, Some(lines), &ending);
}
}
struct Ending<'a> {
outcome: &'static str,
pr_url: Option<&'a str>,
cause: Option<String>,
advice: &'a [Advice],
base_fix: Option<&'a str>,
log: Option<&'a Path>,
tickets: &'a [String],
}
impl<'a> Ending<'a> {
fn of(ended: &'a Ended) -> Self {
let base_fix = ended.base_fix.as_deref();
match &ended.outcome {
Ok(reached) => Ending {
outcome: reached.goal.outcome(),
pr_url: Some(&reached.pr_url),
cause: None,
advice: &[],
base_fix,
log: reached.log.as_deref(),
tickets: &reached.ticket_lines,
},
Err(failed) => Ending {
advice: &ended.advice,
base_fix,
..Ending::of_failure(failed)
},
}
}
fn of_failure(failed: &'a FailedRun) -> Self {
Ending {
outcome: failure_outcome(failed, "failed"),
pr_url: failed.pr_url.as_deref(),
cause: (!failed.interrupted).then(|| format!("{:#}", failed.error)),
advice: &[],
base_fix: None,
log: failed.log.as_deref(),
tickets: &failed.ticket_lines,
}
}
}
fn failure_outcome(failed: &FailedRun, failure: &'static str) -> &'static str {
if failed.interrupted {
"interrupted"
} else {
failure
}
}
fn send(checked: &Checked, subject: &str, architect: Option<ArchitectLines>, ending: &Ending) {
let host = host::name();
let command_log = command_log::path();
let body = Body {
architect,
pr_url: ending.pr_url,
cause: ending.cause.as_deref(),
advice: ending.advice,
base_fix: ending.base_fix,
log: ending.log,
command_log: command_log.as_deref(),
host: host.as_deref().unwrap_or("unknown host"),
took: checked.started.elapsed(),
tickets: ending.tickets,
};
if let Err(error) = checked.resend.send(subject, &body.text()) {
progress::step(format_args!(
"warning: could not send the Run notification: {error:#}"
));
}
}
fn subject(issue: &IssueUrl, title: Option<&str>, outcome: &str) -> String {
let title = title.map(|title| format!(" {title}")).unwrap_or_default();
format!(
"[thirdshift] {}#{}{title}: {outcome}",
issue.repo_slug(),
issue.number
)
}
fn architect_subject(repo: Option<&Repo>, outcome: &str) -> String {
let repo = repo
.map(|repo| format!(" {}", repo.slug()))
.unwrap_or_default();
format!("[thirdshift]{repo} Architect run: {outcome}")
}
enum ArchitectLines {
Skipped(String),
Reviewed {
review: String,
dispatched: Option<&'static str>,
},
}
struct Body<'a> {
architect: Option<ArchitectLines>,
pr_url: Option<&'a str>,
cause: Option<&'a str>,
advice: &'a [Advice],
base_fix: Option<&'a str>,
log: Option<&'a Path>,
command_log: Option<&'a Path>,
host: &'a str,
took: Duration,
tickets: &'a [String],
}
impl Body<'_> {
fn text(&self) -> String {
let mut text = String::new();
match &self.architect {
Some(ArchitectLines::Skipped(reason)) => {
text += &format!("Skipped: {reason}\n");
}
Some(ArchitectLines::Reviewed { review, dispatched }) => {
text += &format!("Review: {review}\n");
if let Some(dispatched) = dispatched {
text += &format!("Dispatched: {dispatched}\n");
}
}
None => {}
}
if let Some(pr_url) = self.pr_url {
text += &format!("Pull request: {pr_url}\n");
}
if let Some(cause) = self.cause {
text += &format!("Cause: {cause}\n");
}
for line in self.advice {
text += &format!("{:<14}{}\n", format!("{}:", line.label), line.value);
}
if let Some(base_fix) = self.base_fix {
text += &format!("Base fix: {base_fix}\n");
}
if let Some(log) = self.log {
text += &format!("Session log: {}\n", log.display());
}
if let Some(log) = self.command_log {
text += &format!("Command log: {}\n", log.display());
}
text += &format!("Host: {}\n", self.host);
text += &format!("Took: {}\n", took(self.took));
if !self.tickets.is_empty() {
text += "\nTickets:\n";
for line in self.tickets {
text += &format!("{line}\n");
}
}
text
}
}
fn took(duration: Duration) -> String {
let seconds = duration.as_secs();
let (hours, minutes, seconds) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
if hours > 0 {
format!("{hours}h {minutes}m {seconds}s")
} else if minutes > 0 {
format!("{minutes}m {seconds}s")
} else {
format!("{seconds}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn issue() -> IssueUrl {
IssueUrl::parse("https://github.com/acme/widgets/issues/123").unwrap()
}
#[test]
fn the_subject_names_the_issue_its_title_if_known_and_the_outcome() {
assert_eq!(
subject(&issue(), Some("Add export button"), "ready for review"),
"[thirdshift] acme/widgets#123 Add export button: ready for review"
);
assert_eq!(
subject(&issue(), None, "failed"),
"[thirdshift] acme/widgets#123: failed"
);
}
#[test]
fn the_body_leaves_out_what_the_run_does_not_have() {
let body = Body {
architect: None,
pr_url: None,
cause: Some("origin mismatch"),
advice: &[],
base_fix: None,
log: None,
command_log: None,
host: "droplet-1",
took: Duration::from_secs(4),
tickets: &[],
};
assert_eq!(
body.text(),
"Cause: origin mismatch\n\
Host: droplet-1\n\
Took: 4s\n"
);
let body = Body {
pr_url: Some("https://github.com/acme/widgets/pull/1"),
cause: None,
log: Some(Path::new("/home/me/.thirdshift/logs/sessions/x.jsonl")),
command_log: Some(Path::new("/home/me/.thirdshift/logs/commands/issue/x.log")),
..body
};
assert_eq!(
body.text(),
"Pull request: https://github.com/acme/widgets/pull/1\n\
Session log: /home/me/.thirdshift/logs/sessions/x.jsonl\n\
Command log: /home/me/.thirdshift/logs/commands/issue/x.log\n\
Host: droplet-1\n\
Took: 4s\n"
);
}
#[test]
fn the_body_reports_the_base_fix_after_the_cause() {
let body = Body {
architect: None,
pr_url: Some("https://github.com/acme/widgets/pull/1"),
cause: Some("claude exited 1"),
advice: &[],
base_fix: Some("https://github.com/acme/widgets/issues/8 merged"),
log: None,
command_log: None,
host: "droplet-1",
took: Duration::from_secs(4),
tickets: &[],
};
assert_eq!(
body.text(),
"Pull request: https://github.com/acme/widgets/pull/1\n\
Cause: claude exited 1\n\
Base fix: https://github.com/acme/widgets/issues/8 merged\n\
Host: droplet-1\n\
Took: 4s\n"
);
}
#[test]
fn the_body_gives_the_advice_after_the_cause_laid_out_like_its_other_lines() {
let advice = [
Advice {
label: "Base check",
value: "test: https://ci.example/main/test".to_string(),
},
Advice {
label: "Or set",
value: "base.fix = true".to_string(),
},
];
let body = Body {
architect: None,
pr_url: Some("https://github.com/acme/widgets/pull/1"),
cause: Some("CI red on test, which also fails on main at 362b9ca; fix main first"),
advice: &advice,
base_fix: None,
log: Some(Path::new("/home/me/.thirdshift/logs/x.jsonl")),
command_log: None,
host: "droplet-1",
took: Duration::from_secs(4),
tickets: &[],
};
assert_eq!(
body.text(),
"Pull request: https://github.com/acme/widgets/pull/1\n\
Cause: CI red on test, which also fails on main at 362b9ca; fix main first\n\
Base check: test: https://ci.example/main/test\n\
Or set: base.fix = true\n\
Session log: /home/me/.thirdshift/logs/x.jsonl\n\
Host: droplet-1\n\
Took: 4s\n"
);
}
#[test]
fn a_spec_runs_body_ends_with_a_line_per_ticket() {
let tickets = [
"#21 failed: claude exited 1".to_string(),
"#22 blocked by #21".to_string(),
];
let body = Body {
architect: None,
pr_url: None,
cause: Some("Tickets not done: #21, #22"),
advice: &[],
base_fix: None,
log: None,
command_log: None,
host: "droplet-1",
took: Duration::from_secs(4),
tickets: &tickets,
};
assert_eq!(
body.text(),
"Cause: Tickets not done: #21, #22\n\
Host: droplet-1\n\
Took: 4s\n\
\n\
Tickets:\n\
#21 failed: claude exited 1\n\
#22 blocked by #21\n"
);
}
#[test]
fn an_architect_runs_subject_names_the_repository_if_known_and_the_outcome() {
let repo = Repo::of_origin("https://github.com/acme/widgets.git").unwrap();
assert_eq!(
architect_subject(Some(&repo), "plan published"),
"[thirdshift] acme/widgets Architect run: plan published"
);
assert_eq!(
architect_subject(None, "review failed"),
"[thirdshift] Architect run: review failed"
);
}
#[test]
fn an_architect_runs_body_starts_with_the_review_and_the_dispatched_runs_outcome() {
let body = Body {
architect: Some(ArchitectLines::Reviewed {
review: "plan published: https://github.com/acme/widgets/issues/8".to_string(),
dispatched: Some("merged"),
}),
pr_url: Some("https://github.com/acme/widgets/pull/1"),
cause: None,
advice: &[],
base_fix: None,
log: None,
command_log: None,
host: "droplet-1",
took: Duration::from_secs(4),
tickets: &[],
};
assert_eq!(
body.text(),
"Review: plan published: https://github.com/acme/widgets/issues/8\n\
Dispatched: merged\n\
Pull request: https://github.com/acme/widgets/pull/1\n\
Host: droplet-1\n\
Took: 4s\n"
);
let body = Body {
architect: Some(ArchitectLines::Reviewed {
review: "idea filed: https://github.com/acme/widgets/issues/8".to_string(),
dispatched: None,
}),
pr_url: None,
..body
};
assert_eq!(
body.text(),
"Review: idea filed: https://github.com/acme/widgets/issues/8\n\
Host: droplet-1\n\
Took: 4s\n"
);
}
#[test]
fn a_skipped_architect_runs_body_starts_with_why_it_was_skipped() {
let body = Body {
architect: Some(ArchitectLines::Skipped(
"an Architect run or a Pickup run is already running on acme/widgets".to_string(),
)),
pr_url: None,
cause: None,
advice: &[],
base_fix: None,
log: None,
command_log: None,
host: "droplet-1",
took: Duration::from_secs(0),
tickets: &[],
};
assert_eq!(
body.text(),
"Skipped: an Architect run or a Pickup run is already running on acme/widgets\n\
Host: droplet-1\n\
Took: 0s\n"
);
}
#[test]
fn took_is_to_the_second() {
for (seconds, text) in [
(0, "0s"),
(59, "59s"),
(60, "1m 0s"),
(3599, "59m 59s"),
(3723, "1h 2m 3s"),
] {
assert_eq!(took(Duration::from_secs(seconds)), text);
}
}
}