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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ The Credentials are read only when `RESEND_API_KEY` is unset or empty, and only
A Run that asks for a notification, by the flag or by `email.always`, makes the same checks as `email-test` before any other work: an address is known, and a key is found, in `RESEND_API_KEY` or else the Credentials. If either fails, or the Credentials are broken, the Run stops, exits `1` naming what's wrong (with no key, the message above listing every way to give one), and sends nothing. A Run that asks for no notification never reads the Credentials, so a broken file can't stop it, and `update`, `version` and `help` never read it either. Once they pass, every way the Run ends sends exactly one notification, after its outcome is final and its cleanup done: ready for review, merged, a [Failed run](#failed-runs) (including a later preflight failure such as an origin mismatch), or interrupted by Ctrl-C, SIGTERM or a closed terminal.

- **Subject**: `[thirdshift] <owner>/<repo>#<n> <issue title>: <outcome>`, where the outcome is `ready for review`, `merged`, `failed` or `interrupted`. The title is left out if it can't be read from GitHub.
- **Body**, plain text: the pull request URL (if any), the failure cause (if failed), after an Inherited failure with no Base fix taken its [`Base check:`, `Retry with:` and `Or set:` lines](#an-inherited-failure-links-the-base-branchs-checks), the session log path (if any), the [Command log](#logs) path as a `Command log:` line, the hostname and how long the Run took.
- **Body**, plain text: a `Result:` line with the same outcome as the subject, so it is readable even when a long issue title hides the end of the subject; then the pull request URL (if any), the failure cause (if failed), after an Inherited failure with no Base fix taken its [`Base check:`, `Retry with:` and `Or set:` lines](#an-inherited-failure-links-the-base-branchs-checks), the session log path (if any), the [Command log](#logs) path as a `Command log:` line, the hostname and how long the Run took.

A [Spec run](#spec-runs) sends at most one notification for the whole Spec, under the same rules, with its checks made once before any Ticket starts. Its subject names the Spec, its outcome is the Spec run's, and its body, after what a Run's holds (the Spec PR, if any), lists each Ticket's outcome, one line per Ticket as in the summary on stderr, such as `#21 landed with https://github.com/acme/widgets/pull/1` or `#22 blocked by #21`. The Ticket Runs inside it never send a notification of their own, whatever the User config says.

Expand Down Expand Up @@ -617,7 +617,7 @@ A review session that fails or is interrupted, a final message without one of th
An Architect run asked for a [Run notification](#run-notifications), by `email` or by `email.always = true` in the [User config](#user-config) without `no-email`, sends exactly one, whatever its outcome, with or without `--plan-only`, unless it is skipped. A skipped Architect run, [however](#one-at-a-time) [it](#one-architect-plan-at-a-time) [is](#an-architect-idea-waits-for-triage) [skipped](#a-ready-issue-goes-first), sends none, even when asked, so a schedule can start one every few minutes without flooding your inbox: its line is in the log its scheduler keeps. It makes a Run's checks before any other work all the same, an address and a Resend API key, and stops with exit `1` if either is missing, so a run that would have been skipped fails on them too. The email goes after the outcome is final and printed, and a failed send is only a `warning:` line on stderr: it changes neither the exit code nor stdout.

- **Subject**: `[thirdshift] <owner>/<repo> Architect run: <outcome>`. With a dispatched run, the outcome is that run's: `ready for review`, `merged`, `failed` or `interrupted`. Without one, it is the review's: `plan published` (with `--plan-only`), `idea filed`, `idea already filed`, `review failed` (also for a plan that fails a check) or `interrupted`. The repository is left out if `origin` doesn't name one on GitHub.
- **Body**, plain text: a `Review:` line saying how the Architecture review ended, with the URL of the plan or idea issue it named (`plan published: <url>`, `idea filed: <url>`, `idea already filed: <url>`, `failed` or `interrupted`); when the plan was dispatched, a `Dispatched:` line with that run's outcome; then what a Run's notification holds, for the dispatched run or else the failed review: the pull request URL (if any), the failure cause (if failed), with the [lines after it](#an-inherited-failure-links-the-base-branchs-checks) of a dispatched run that failed on an Inherited failure, a `Base fix:` line for a dispatched run that started or waited on a [Base fix](#base-fix), the session log path (if any), the `Command log:` path (if any), the hostname and how long the Architect run took. After a dispatched Spec run, it ends with a line per Ticket, as a Spec run's notification does.
- **Body**, plain text: a `Result:` line with the same outcome as the subject; a `Review:` line saying how the Architecture review ended, with the URL of the plan or idea issue it named (`plan published: <url>`, `idea filed: <url>`, `idea already filed: <url>`, `failed` or `interrupted`); when the plan was dispatched, a `Dispatched:` line with that run's outcome; then what a Run's notification holds, for the dispatched run or else the failed review: the pull request URL (if any), the failure cause (if failed), with the [lines after it](#an-inherited-failure-links-the-base-branchs-checks) of a dispatched run that failed on an Inherited failure, a `Base fix:` line for a dispatched run that started or waited on a [Base fix](#base-fix), the session log path (if any), the `Command log:` path (if any), the hostname and how long the Architect run took. After a dispatched Spec run, it ends with a line per Ticket, as a Spec run's notification does.

### No Strong candidate

Expand Down
43 changes: 27 additions & 16 deletions src/notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,18 +186,18 @@ fn architect_subject(repo: Option<&Repo>, outcome: &str) -> String {
/// The plain-text body of the notification for a command that ended as
/// `account` tells, with the line that says what its sessions were
/// `built_with`, if known, naming `command_log`, if the command keeps one,
/// and `host`, for a command that `took` so long. An Architect run's starts
/// with how its review ended, and the outcome of the run it dispatched, if
/// any. The cause is left out of an interrupted command's, whose outcome says
/// so.
/// and `host`, for a command that `took` so long. It starts with the command's
/// outcome, as the subject gives it. An Architect run's then tells how its
/// review ended, and the outcome of the run it dispatched, if any. The cause
/// is left out of an interrupted command's, whose outcome says so.
fn body(
account: &Account,
built_with: Option<&str>,
command_log: Option<&Path>,
host: &str,
took: Duration,
) -> String {
let mut text = String::new();
let mut text = format!("Result: {}\n", account.outcome);
if let Some(review) = &account.review {
text += &format!("Review: {}\n", review.line);
if review.dispatched.is_some() {
Expand Down Expand Up @@ -330,7 +330,8 @@ mod tests {
Some((
"[thirdshift] acme/widgets#123 Add export button: merged".to_string(),
format!(
"Pull request: {PR}\n\
"Result: merged\n\
Pull request: {PR}\n\
Session log: {LOG}\n\
Host: droplet-1\n\
Took: 4s\n"
Expand All @@ -348,7 +349,8 @@ mod tests {
Some((
"[thirdshift] acme/widgets#123 Add export button: failed".to_string(),
format!(
"Cause: claude exited 1\n\
"Result: failed\n\
Cause: claude exited 1\n\
Base fix: {PLAN} not merged\n\
Session log: {LOG}\n\
Host: droplet-1\n\
Expand All @@ -369,7 +371,8 @@ mod tests {
told(&about_issue(), &account),
Some((
"[thirdshift] acme/widgets#123 Add export button: interrupted".to_string(),
"Host: droplet-1\n\
"Result: interrupted\n\
Host: droplet-1\n\
Took: 4s\n"
.to_string()
))
Expand All @@ -390,7 +393,8 @@ mod tests {
Some((
"[thirdshift] acme/widgets Architect run: merged".to_string(),
format!(
"Review: plan published: {PLAN}\n\
"Result: merged\n\
Review: plan published: {PLAN}\n\
Dispatched: merged\n\
Pull request: {PR}\n\
Session log: {LOG}\n\
Expand Down Expand Up @@ -422,7 +426,8 @@ mod tests {
Some((
"[thirdshift] acme/widgets Architect run: idea filed".to_string(),
format!(
"Review: idea filed: {PLAN}\n\
"Result: idea filed\n\
Review: idea filed: {PLAN}\n\
Host: droplet-1\n\
Took: 4s\n"
)
Expand All @@ -446,7 +451,8 @@ mod tests {
Some((
"[thirdshift] Architect run: review failed".to_string(),
format!(
"Review: failed\n\
"Result: review failed\n\
Review: failed\n\
Cause: claude exited 1\n\
Session log: {LOG}\n\
Host: droplet-1\n\
Expand Down Expand Up @@ -477,7 +483,8 @@ mod tests {
fn the_body_leaves_out_what_the_run_does_not_have() {
assert_eq!(
body_of(&failed("origin mismatch")),
"Cause: origin mismatch\n\
"Result: failed\n\
Cause: origin mismatch\n\
Host: droplet-1\n\
Took: 4s\n"
);
Expand All @@ -493,7 +500,8 @@ mod tests {
"droplet-1",
Duration::from_secs(4)
),
"Pull request: https://github.com/acme/widgets/pull/1\n\
"Result: merged\n\
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\
Built with claude · opus · high\n\
Expand All @@ -511,7 +519,8 @@ mod tests {
};
assert_eq!(
body_of(&account),
"Pull request: https://github.com/acme/widgets/pull/1\n\
"Result: failed\n\
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\
Expand Down Expand Up @@ -539,7 +548,8 @@ mod tests {
};
assert_eq!(
body_of(&account),
"Pull request: https://github.com/acme/widgets/pull/1\n\
"Result: failed\n\
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\
Expand All @@ -561,7 +571,8 @@ mod tests {
};
assert_eq!(
body_of(&account),
"Cause: Tickets not done: #21, #22\n\
"Result: failed\n\
Cause: Tickets not done: #21, #22\n\
Host: droplet-1\n\
Took: 4s\n\
\n\
Expand Down
41 changes: 29 additions & 12 deletions tests/architect_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1373,7 +1373,9 @@ fn plan_only_sends_one_notification_naming_the_plan() {
"[thirdshift] acme/widgets Architect run: plan published"
);
assert!(
text.starts_with(&format!("Review: plan published: {PLAN_URL}\n")),
text.starts_with(&format!(
"Result: plan published\nReview: plan published: {PLAN_URL}\n"
)),
"{text}"
);
assert!(!text.contains("Dispatched:"), "{text}");
Expand Down Expand Up @@ -1404,7 +1406,9 @@ printf 'Architecture review idea: %s\n' "$url" > "$FAKE_CLAUDE_FINAL_MESSAGE"
"[thirdshift] acme/widgets Architect run: idea filed"
);
assert!(
text.starts_with(&format!("Review: idea filed: {PLAN_URL}\n")),
text.starts_with(&format!(
"Result: idea filed\nReview: idea filed: {PLAN_URL}\n"
)),
"{text}"
);
assert!(!text.contains("Dispatched:"), "{text}");
Expand Down Expand Up @@ -1432,7 +1436,9 @@ fn a_review_whose_idea_is_already_filed_sends_one_notification_naming_that_issue
"[thirdshift] acme/widgets Architect run: idea already filed"
);
assert!(
text.starts_with(&format!("Review: idea already filed: {url}\n")),
text.starts_with(&format!(
"Result: idea already filed\nReview: idea already filed: {url}\n"
)),
"{text}"
);
}
Expand Down Expand Up @@ -1460,7 +1466,8 @@ fn a_failed_review_sends_one_notification_with_the_cause_and_the_session_log() {
let log = ending[1].strip_prefix("thirdshift: session log: ").unwrap();
assert!(
text.starts_with(&format!(
"Review: failed\n\
"Result: review failed\n\
Review: failed\n\
Cause: claude exited 3\n\
Session log: {log}\n\
Command log: {command_log}\n"
Expand Down Expand Up @@ -1491,7 +1498,8 @@ fn email_always_sends_one_notification_for_the_review_and_the_run_it_dispatched(
);
assert!(
text.starts_with(&format!(
"Review: plan published: {PLAN_URL}\n\
"Result: ready for review\n\
Review: plan published: {PLAN_URL}\n\
Dispatched: ready for review\n\
Pull request: {PR_URL}\n"
)),
Expand All @@ -1515,7 +1523,8 @@ fn the_one_notification_says_what_became_of_the_dispatched_runs_base_fix() {
);
assert!(
text.starts_with(&format!(
"Review: plan published: {PLAN_URL}\n\
"Result: ready for review\n\
Review: plan published: {PLAN_URL}\n\
Dispatched: ready for review\n\
Pull request: {PR_URL}\n\
Base fix: https://github.com/acme/widgets/issues/9 merged\n"
Expand All @@ -1538,7 +1547,8 @@ fn a_dispatched_spec_run_sends_no_notification_of_its_own_and_its_tickets_are_in
let spec_pr = pr_from(&scenario, "issue-8");
assert!(
text.starts_with(&format!(
"Review: plan published: {PLAN_URL}\n\
"Result: merged\n\
Review: plan published: {PLAN_URL}\n\
Dispatched: merged\n\
Pull request: {}\n",
spec_pr["url"].as_str().unwrap()
Expand Down Expand Up @@ -1570,7 +1580,8 @@ fn a_dispatched_run_that_fails_sends_one_notification_with_its_outcome_and_cause
assert_eq!(subject, "[thirdshift] acme/widgets Architect run: failed");
assert!(
text.starts_with(&format!(
"Review: plan published: {PLAN_URL}\n\
"Result: failed\n\
Review: plan published: {PLAN_URL}\n\
Dispatched: failed\n\
Pull request: {PR_URL}\n\
Cause: claude exited 3\n"
Expand Down Expand Up @@ -1635,7 +1646,10 @@ sleep 60"#,
subject,
"[thirdshift] acme/widgets Architect run: interrupted"
);
assert!(text.starts_with("Review: interrupted\n"), "{text}");
assert!(
text.starts_with("Result: interrupted\nReview: interrupted\n"),
"{text}"
);
assert!(!text.contains("Cause:"), "{text}");
}

Expand All @@ -1659,7 +1673,8 @@ fn a_plan_that_fails_its_checks_sends_one_notification_naming_it_in_the_cause()
);
assert!(
text.starts_with(&format!(
"Review: failed\n\
"Result: review failed\n\
Review: failed\n\
Cause: the plan {PLAN_URL} is closed\n"
)),
"{text}"
Expand All @@ -1682,7 +1697,8 @@ fn a_dispatched_spec_run_that_fails_sends_one_notification_with_each_tickets_out
assert_eq!(subject, "[thirdshift] acme/widgets Architect run: failed");
assert!(
text.starts_with(&format!(
"Review: plan published: {PLAN_URL}\n\
"Result: failed\n\
Review: plan published: {PLAN_URL}\n\
Dispatched: failed\n"
)),
"{text}"
Expand Down Expand Up @@ -2228,7 +2244,8 @@ fn an_idea_that_cannot_be_labelled_sends_one_notification_with_the_cause() {
);
assert!(
text.starts_with(
"Review: failed\n\
"Result: review failed\n\
Review: failed\n\
Cause: could not label the Architect idea #7: gh api --method PUT"
),
"{text}"
Expand Down
18 changes: 14 additions & 4 deletions tests/pickup_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -901,7 +901,8 @@ fn the_notification_of_a_run_left_ready_for_review_is_that_runs() {
.join(&commands[0]);
assert!(
text.starts_with(&format!(
"Pull request: {}\n\
"Result: ready for review\n\
Pull request: {}\n\
Session log: {}\n\
Command log: {}\n\
Built with claude · default model · default effort\n\
Expand Down Expand Up @@ -930,7 +931,10 @@ fn the_notification_of_a_merge_run_says_it_merged() {
subject,
"[thirdshift] acme/widgets#7 Sharpen the widgets: merged"
);
let pull_request = format!("Pull request: {}\n", pr["url"].as_str().unwrap());
let pull_request = format!(
"Result: merged\nPull request: {}\n",
pr["url"].as_str().unwrap()
);
assert!(text.starts_with(&pull_request), "{text}");
}

Expand All @@ -950,7 +954,8 @@ fn the_notification_of_a_failed_run_has_its_pull_request_and_its_cause() {
);
assert!(
text.starts_with(&format!(
"Pull request: {}\n\
"Result: failed\n\
Pull request: {}\n\
Cause: claude exited 3\n\
Session log: ",
pr_from(&scenario, "issue-7")["url"].as_str().unwrap()
Expand Down Expand Up @@ -982,6 +987,7 @@ sleep 60"#,
subject,
"[thirdshift] acme/widgets#7 Sharpen the widgets: interrupted"
);
assert!(text.starts_with("Result: interrupted\n"), "{text}");
assert!(!text.contains("Cause:"), "{text}");
}

Expand All @@ -1002,7 +1008,10 @@ fn the_notification_of_a_spec_run_is_that_spec_runs_with_a_line_per_ticket() {
subject,
"[thirdshift] acme/widgets#7 Sharpen every widget: ready for review"
);
let pull_request = format!("Pull request: {}\n", spec_pr["url"].as_str().unwrap());
let pull_request = format!(
"Result: ready for review\nPull request: {}\n",
spec_pr["url"].as_str().unwrap()
);
assert!(text.starts_with(&pull_request), "{text}");
let (_, tickets) = text.split_once("\nTickets:\n").expect(&text);
for ticket in [8, 9] {
Expand All @@ -1022,6 +1031,7 @@ fn the_notification_of_a_failed_spec_run_has_each_tickets_outcome() {
assert_eq!(result.code, Some(1), "stderr: {}", result.stderr);
let (subject, text) = the_one_notification(&resend);
assert!(subject.ends_with(": failed"), "{subject}");
assert!(text.starts_with("Result: failed\n"), "{text}");
let (_, tickets) = text.split_once("\nTickets:\n").expect(&text);
let landed = pr_from(&scenario, "issue-8");
let landed = format!("#8 landed with {}\n", landed["url"].as_str().unwrap());
Expand Down
Loading
Loading