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
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,16 @@ A Merge run ends in one of three ways:
- **Policy refusal**: GitHub refused the merge and a round of merging the Base branch and watching CI found nothing left to fix. Exit `1`, the pull request's URL on stdout, and GitHub's error on stderr. The pull request stays open and ready for review, not a draft, for you to merge by hand or to change the repository's settings. thirdshift never reads GitHub's error text to decide this.
- **Any other failure**: a [Failed run](#failed-runs), as for a Run without `merge`.

To be emailed when the Run ends, add `--email` (or `email`), optionally followed by the address, before or after the URL and alongside `merge`:

```sh
thirdshift --email you@example.com https://github.com/acme/widgets/issues/7
```

It sends one [Run notification](#run-notifications), whatever the outcome.

- **stdout** carries only the pull request's URL: on success, and on a Failed run that leaves an open pull request, a draft or, after a policy refusal, one ready for review. The exit code tells the two apart, so script it as `url=$(thirdshift "$issue") && echo "ready: $url"`.
- **stderr** carries everything else: errors, cleanup problems, and progress lines while sessions run. A successful Run's last line names the pull request too: `PR <url> is ready for review`, or `PR <url> is merged` after a Merge run.
- **stderr** carries everything else: errors, cleanup problems, and progress lines while sessions run. A successful Run's last line names the pull request too: `PR <url> is ready for review`, or `PR <url> is merged` after a Merge run, followed only by a `warning:` line if a [Run notification](#run-notifications) can't be sent.
- **Exit code** `0` means the Run ended with a pull request the factory stands behind, merged in a Merge run. Once the Self-merge has merged, the Run succeeds even if deleting the Issue branch on `origin` or closing the issue then fails: the merge can't be undone, so each failed step is a `warning:` line on stderr naming the command to run by hand, and the Run still exits `0` with the URL on stdout. Ctrl-C likewise: before the merge it makes a Failed run, after it thirdshift finishes these steps and exits as merged. `2` means the Issue URL is missing or isn't a GitHub Issue URL, there is an argument other than the URL and the Run flags, or a Run flag is repeated or contradicts another; the error and the help text go to stderr, before any work. A [User config](#user-config) thirdshift can't use exits `1`, also before any work. Any other failure exits `1`.

The other commands:
Expand Down Expand Up @@ -168,6 +176,17 @@ thirdshift email-test you@example.com # or just `thirdshift email-test`, to se

It sends one test email, whose subject marks it as a test and whose body names the host, the time and the sender. Before sending, it checks that it has an address (the argument, else `email.to`) and a non-empty `RESEND_API_KEY`; if either is missing, it exits `1` naming what's missing and sends nothing. Nothing is sent to check the key itself. When Resend accepts the email, it prints `accepted by Resend; check your inbox` and exits `0`; that is all it can verify, so check that the email arrives. When Resend refuses it, for example for a bad key or a sender it won't send from, it prints Resend's error text word for word and exits `1`. It gives up after 30 seconds without an answer.

#### Run notifications

`--email` (or `email`) asks a Run for a **Run notification**: one email, sent when the Run ends, whatever the outcome. The word after the flag is the address only if it contains `@` and doesn't start with `https://`, so the Issue URL is never taken for it; otherwise the email goes to `email.to`. Giving the flag twice is an argument error.

The Run makes the same checks as `email-test` before any other work: an address is known, and `RESEND_API_KEY` is set and not empty. If either fails, the Run stops, exits `1` naming what's missing, and sends nothing. 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), the session log path (if any), the hostname and how long the Run took.

A notification that can't be sent is a `warning:` line on stderr with Resend's error. It never changes the Run's outcome, stdout or exit code.

### Logs

Each session's full transcript, as Claude Code's `stream-json` output, is written to its own file under `~/.thirdshift/logs/`, or the `logs.dir` set in the [User config](#user-config) (created if missing):
Expand Down
23 changes: 21 additions & 2 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ pub struct RunArgs {
/// The goal `merge` or `no-merge` asked for, if either was given; without
/// one, the User config decides.
pub goal: Option<Goal>,
/// `Some` if `email` was given, so the Run sends a Run notification,
/// holding the address after it, if any.
pub email: Option<Option<String>>,
}

/// Parse the arguments after the program name. `help`, `version`, `update`
/// and `email-test` are commands only as the first argument. Otherwise it is
/// a Run: one Issue URL, with each Run flag at most once, before or after it.
/// `email` may be followed by the address to send the Run notification to.
pub fn parse(args: &[String]) -> Result<Command> {
match args.first().map(String::as_str) {
Some("help" | "--help" | "-h") => return Ok(Command::Help),
Expand All @@ -42,10 +46,19 @@ pub fn parse(args: &[String]) -> Result<Command> {
}
let mut issue = None;
let mut goal = None;
for arg in args {
let mut email = None;
let mut args = args.iter().peekable();
while let Some(arg) = args.next() {
let asked = match arg.as_str() {
"merge" | "--merge" => Goal::Merged,
"no-merge" | "--no-merge" => Goal::ReadyForReview,
"email" | "--email" => {
if email.is_some() {
bail!("repeated argument: {arg}");
}
email = Some(args.next_if(|next| is_address(next)).cloned());
continue;
}
_ => {
if issue.is_some() {
bail!("unexpected argument after the Issue URL: {arg}");
Expand All @@ -63,5 +76,11 @@ pub fn parse(args: &[String]) -> Result<Command> {
let Some(issue) = issue else {
bail!("missing Issue URL");
};
Ok(Command::Run(RunArgs { issue, goal }))
Ok(Command::Run(RunArgs { issue, goal, email }))
}

/// Is `arg`, after `email`, the address to send to? Only if it looks like
/// one, so the Issue URL is never taken for it.
fn is_address(arg: &str) -> bool {
arg.contains('@') && !arg.starts_with("https://")
}
4 changes: 2 additions & 2 deletions src/email.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Email through Resend's HTTP API (ADR 0005): one HTTPS POST per email, with
//! the key from `RESEND_API_KEY`. `thirdshift email-test` sends through it,
//! and Run notifications are to use the same checks and the same send.
//! the key from `RESEND_API_KEY`. `thirdshift email-test` and Run
//! notifications both send through it, after the same checks.

use std::time::Duration;

Expand Down
7 changes: 6 additions & 1 deletion src/failed_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub struct FailedRun {
pub pr_url: Option<String>,
/// The most recent session log, if a session was started.
pub log: Option<PathBuf>,
/// Whether the Run was interrupted, as it was when the Run failed.
pub interrupted: bool,
}

/// A merge GitHub refused when a round of the Repair loop found nothing to
Expand All @@ -43,6 +45,7 @@ impl From<anyhow::Error> for FailedRun {
error,
pr_url: None,
log: None,
interrupted: interrupt::requested(),
}
}
}
Expand All @@ -60,7 +63,8 @@ pub fn fail(
error: anyhow::Error,
) -> FailedRun {
// An interrupt can surface as some other error, such as a killed git.
let error = if interrupt::requested() {
let interrupted = interrupt::requested();
let error = if interrupted {
anyhow!("interrupted")
} else {
error
Expand Down Expand Up @@ -101,6 +105,7 @@ pub fn fail(
error,
pr_url,
log: log.exists().then(|| log.to_path_buf()),
interrupted,
}
}

Expand Down
17 changes: 17 additions & 0 deletions src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ pub fn issue_is_open(issue: &IssueUrl) -> Result<bool> {
Ok(json["state"].as_str().context("gh output has no state")? == "OPEN")
}

/// The title of `issue`.
pub fn issue_title(issue: &IssueUrl) -> Result<String> {
let json = gh_json(&[
"issue",
"view",
&issue.number.to_string(),
"--repo",
&issue.repo_slug(),
"--json",
"title",
])?;
Ok(json["title"]
.as_str()
.context("gh output has no title")?
.to_string())
}

/// Close `issue` with `comment`.
pub fn close_issue(issue: &IssueUrl, comment: &str) -> Result<()> {
gh(&[
Expand Down
52 changes: 41 additions & 11 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod github;
mod host;
mod interrupt;
mod issue;
mod notification;
mod plugin;
mod poll;
mod preflight;
Expand All @@ -21,23 +22,30 @@ mod session;
mod update;
mod worktree;

use std::io::Write;
use std::process::ExitCode;

use args::{Command, RunArgs};
use config::UserConfig;
use notification::RunNotification;

const HELP: &str = "\
thirdshift turns a GitHub issue into a ready-for-review pull request, or a merged one, unattended.

usage: thirdshift <Issue URL> Run the factory on the issue, from the clone on the Base branch
thirdshift merge <Issue URL> Run the factory on the issue, then merge its pull request
thirdshift --no-merge <Issue URL> Run the factory on the issue and leave its pull request for review
thirdshift --email <Issue URL> Run the factory on the issue, then email how the Run ended
thirdshift email-test [<address>] Send a test email through Resend, to check the email setup
thirdshift update Update thirdshift to the latest release
thirdshift version Print thirdshift's version
thirdshift help Print this help

merge and --no-merge go before or after the Issue URL.
merge, --no-merge and --email go before or after the Issue URL, in any order.

--email sends one Run notification when the Run ends, whatever the outcome: ready for
review, merged, failed or interrupted. --email <address> sends it to <address>; a word
after --email is the address only if it has an @ and isn't a URL.

The User config, ~/.thirdshift/config.toml, sets defaults for every Run on this machine.
With merge.always set, every Run is a Merge run unless given --no-merge:
Expand All @@ -50,8 +58,8 @@ logs.dir sets where session logs go instead of ~/.thirdshift/logs: an absolute p
[logs]
dir = \"~/elsewhere/logs\"

email-test sends to <address>, else to email.to, from email.from, else from
onboarding@resend.dev, which only delivers to your own Resend account's address.
--email and email-test send to their <address>, else to email.to, from email.from, else
from onboarding@resend.dev, which only delivers to your own Resend account's address.
The Resend API key comes only from the RESEND_API_KEY environment variable:

[email]
Expand All @@ -61,7 +69,7 @@ The Resend API key comes only from the RESEND_API_KEY environment variable:

fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let RunArgs { issue, goal } = match args::parse(&args) {
let RunArgs { issue, goal, email } = match args::parse(&args) {
Ok(Command::Help) => {
print!("{HELP}");
return ExitCode::SUCCESS;
Expand All @@ -87,28 +95,50 @@ fn main() -> ExitCode {
}
};
let goal = goal.unwrap_or(config.default_goal());
// First, so no interrupt can end the Run once its notification is checked.
if let Err(error) = interrupt::install() {
progress::step(format_args!("{error:#}"));
return ExitCode::FAILURE;
}
match run::run(&issue, goal, &config.logs_dir) {
Ok(pr_url) => {
let notification = match email
.map(|to| RunNotification::new(to, &config.email, &issue))
.transpose()
{
Ok(notification) => notification,
Err(error) => {
progress::step(format_args!("{error:#}"));
return ExitCode::FAILURE;
}
};
let ended = run::run(&issue, goal, &config.logs_dir);
let code = match &ended {
Ok(reached) => {
// Also on stderr, so the outcome shows even when stdout is captured.
progress::step(format_args!("PR {pr_url} {}", goal.outcome()));
println!("{pr_url}");
progress::step(format_args!("PR {} is {}", reached.pr_url, goal.outcome()));
print_pr_url(&reached.pr_url);
ExitCode::SUCCESS
}
Err(failed) => {
progress::step(format_args!("{:#}", failed.error));
if let Some(log) = failed.log {
if let Some(log) = &failed.log {
progress::step(format_args!("session log: {}", log.display()));
}
if let Some(pr_url) = failed.pr_url {
println!("{pr_url}");
if let Some(pr_url) = &failed.pr_url {
print_pr_url(pr_url);
}
ExitCode::FAILURE
}
};
if let Some(notification) = notification {
notification.send(&ended, goal);
}
code
}

/// The PR's URL on stdout. A failed write, as once the terminal has closed,
/// is ignored, so the Run notification still goes.
fn print_pr_url(pr_url: &str) {
let _ = writeln!(std::io::stdout(), "{pr_url}");
}

/// The end of a command other than a Run: the line that says how it went,
Expand Down
Loading
Loading