use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::Result;
use crate::architect;
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::progress;
use crate::run::Ended;
#[derive(Debug, PartialEq, Eq)]
pub enum NotificationAsk {
Send(Option<String>),
Skip,
}
pub struct RunNotification {
resend: Resend,
issue: IssueUrl,
title: Option<String>,
started: Instant,
}
impl RunNotification {
pub fn new(to: Option<String>, settings: &EmailSettings, issue: &IssueUrl) -> Result<Self> {
let started = Instant::now();
let resend = Resend::new(to, settings)?;
Ok(RunNotification {
resend,
issue: issue.clone(),
title: github::issue_title(issue).ok(),
started,
})
}
pub fn send(self, ended: &Ended) {
let ending = Ending::of(ended);
let subject = subject(&self.issue, self.title.as_deref(), ending.outcome);
send(&self.resend, &subject, self.started, None, &ending);
}
}
pub struct ArchitectNotification {
resend: Resend,
repo: Option<Repo>,
started: Instant,
}
impl ArchitectNotification {
pub fn new(to: Option<String>, settings: &EmailSettings) -> Result<Self> {
let started = Instant::now();
let resend = Resend::new(to, settings)?;
Ok(ArchitectNotification {
resend,
repo: architect::repo().ok(),
started,
})
}
pub fn send(
self,
reviewed: &Result<architect::Outcome, FailedRun>,
dispatched: Option<&Ended>,
) {
let review = match reviewed {
Ok(outcome) => format!("{}: {}", outcome.review(), outcome.url()),
Err(failed) => failure_outcome(failed, "failed").to_string(),
};
let ending = match (reviewed, dispatched) {
(_, Some(ended)) => Ending::of(ended),
(Ok(outcome), None) => Ending {
outcome: outcome.review(),
pr_url: None,
cause: None,
base_fix: None,
log: None,
tickets: &[],
},
(Err(failed), None) => Ending {
outcome: failure_outcome(failed, "review failed"),
..Ending::of_failure(failed)
},
};
let lines = ArchitectLines {
review: &review,
dispatched: dispatched.map(|_| ending.outcome),
};
let subject = architect_subject(self.repo.as_ref(), ending.outcome);
send(&self.resend, &subject, self.started, Some(lines), &ending);
}
}
struct Ending<'a> {
outcome: &'static str,
pr_url: Option<&'a str>,
cause: Option<String>,
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,
base_fix,
log: reached.log.as_deref(),
tickets: &reached.ticket_lines,
},
Err(failed) => Ending {
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)),
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(
resend: &Resend,
subject: &str,
started: Instant,
architect: Option<ArchitectLines>,
ending: &Ending,
) {
let host = host::name();
let body = Body {
architect,
pr_url: ending.pr_url,
cause: ending.cause.as_deref(),
base_fix: ending.base_fix,
log: ending.log,
host: host.as_deref().unwrap_or("unknown host"),
took: started.elapsed(),
tickets: ending.tickets,
};
if let Err(error) = 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.owner, repo.name))
.unwrap_or_default();
format!("[thirdshift]{repo} Architect run: {outcome}")
}
struct ArchitectLines<'a> {
review: &'a str,
dispatched: Option<&'a str>,
}
struct Body<'a> {
architect: Option<ArchitectLines<'a>>,
pr_url: Option<&'a str>,
cause: Option<&'a str>,
base_fix: Option<&'a str>,
log: Option<&'a Path>,
host: &'a str,
took: Duration,
tickets: &'a [String],
}
impl Body<'_> {
fn text(&self) -> String {
let mut text = String::new();
if let Some(architect) = &self.architect {
text += &format!("Review: {}\n", architect.review);
if let Some(dispatched) = architect.dispatched {
text += &format!("Dispatched: {dispatched}\n");
}
}
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");
}
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());
}
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"),
base_fix: None,
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/x.jsonl")),
..body
};
assert_eq!(
body.text(),
"Pull request: https://github.com/acme/widgets/pull/1\n\
Session log: /home/me/.thirdshift/logs/x.jsonl\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"),
base_fix: Some("https://github.com/acme/widgets/issues/8 merged"),
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 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"),
base_fix: None,
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 {
review: "plan published: https://github.com/acme/widgets/issues/8",
dispatched: Some("merged"),
}),
pr_url: Some("https://github.com/acme/widgets/pull/1"),
cause: None,
base_fix: None,
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 {
review: "idea filed: https://github.com/acme/widgets/issues/8",
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 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);
}
}
}