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
77 changes: 77 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ libc = "0.2.189"
serde_json = "1.0.151"
signal-hook = "0.4.4"
tempfile = "3.27.0"
toml = "1.1.6"

# The profile that 'dist' will build with
[profile.dist]
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ To have the Run merge its pull request instead of leaving it for your review, st
thirdshift merge https://github.com/acme/widgets/issues/7
```

`thirdshift --merge <Issue URL>` does the same. The agent gets the same implement prompt either way; the differences are the Self-merge at the end and the review of [Foreign commits](#foreign-commits-in-a-merge-run). The keyword goes before the URL: anything after the URL, `--merge` included, is an argument error.
`thirdshift --merge <Issue URL>` does the same, and either goes before or after the URL. The agent gets the same implement prompt either way; the differences are the Self-merge at the end and the review of [Foreign commits](#foreign-commits-in-a-merge-run). To make every Run on a machine a Merge run, set `merge.always` in the [User config](#user-config); `no-merge` (or `--no-merge`), before or after the URL, then makes one Run end ready for review instead. Giving a flag twice, or `merge` together with `no-merge`, is an argument error.

A Merge run ends in one of three ways:

Expand All @@ -114,7 +114,7 @@ A Merge run ends in one of three ways:

- **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.
- **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, or something follows it; the error and the help text go to stderr. Any other failure exits `1`.
- **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 All @@ -128,6 +128,19 @@ thirdshift help # print every form of the command, each with a one-line des

Uncommitted changes in your clone are fine: the Run works in its own worktree from `origin`, so they are simply left out. Unpushed commits on the Base branch are not: push them first, or the Run stops.

### User config

A **User config** at `~/.thirdshift/config.toml` sets this machine's defaults for every Run. It is optional: with no file, or one that says nothing about a setting, a Run does only what its command asks for.

```toml
[merge]
always = true # every Run is a Merge run, without the merge word
```

With `merge.always = true`, `thirdshift <Issue URL>` is a Merge run, and `thirdshift --no-merge <Issue URL>` (or `no-merge`, before or after the URL) leaves that one Run's pull request ready for review.

A Run reads the file before any work. One that isn't valid TOML, or that has a key or section thirdshift doesn't know, such as `alway` for `always`, stops the Run with an error naming the file and the offending key, so a typo can't silently leave a setting off. `update`, `version` and `help` never read it, so a broken User config can't block them.

### Logs

Each session's full transcript, as Claude Code's `stream-json` output, is written to its own file under `~/.thirdshift/logs/` (created if missing):
Expand Down
58 changes: 58 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//! The command line: which command, and for a Run, its Issue URL and flags.

use anyhow::{Result, bail};

use crate::issue::IssueUrl;
use crate::run::Goal;

/// What thirdshift was asked to do.
pub enum Command {
Help,
Version,
Update,
Run(RunArgs),
}

/// A Run's arguments.
pub struct RunArgs {
pub issue: IssueUrl,
/// The goal `merge` or `no-merge` asked for, if either was given; without
/// one, the User config decides.
pub goal: Option<Goal>,
}

/// Parse the arguments after the program name. `help`, `version` and
/// `update` 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.
pub fn parse(args: &[String]) -> Result<Command> {
match args.first().map(String::as_str) {
Some("help" | "--help" | "-h") => return Ok(Command::Help),
Some("version" | "--version" | "-V") => return Ok(Command::Version),
Some("update") => return Ok(Command::Update),
_ => {}
}
let mut issue = None;
let mut goal = None;
for arg in args {
let asked = match arg.as_str() {
"merge" | "--merge" => Goal::Merged,
"no-merge" | "--no-merge" => Goal::ReadyForReview,
_ => {
if issue.is_some() {
bail!("unexpected argument after the Issue URL: {arg}");
}
issue = Some(IssueUrl::parse(arg)?);
continue;
}
};
match goal {
None => goal = Some(asked),
Some(given) if given == asked => bail!("repeated argument: {arg}"),
Some(_) => bail!("merge and no-merge can't be used together"),
}
}
let Some(issue) = issue else {
bail!("missing Issue URL");
};
Ok(Command::Run(RunArgs { issue, goal }))
}
108 changes: 108 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//! The User config: `~/.thirdshift/config.toml`, this machine's defaults for
//! every Run. Only a Run reads it, so a broken one can't block `update`,
//! `version` or `help`.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow, bail};
use toml::{Table, Value};

use crate::run::Goal;

/// The settings a User config can hold. Each is what a Run does when its
/// command says nothing about it.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct UserConfig {
/// `merge.always`: every Run is a Merge run unless told `no-merge`.
pub merge_always: bool,
}

impl UserConfig {
/// The User config under `$HOME`, or the defaults if there is none.
pub fn load() -> Result<Self> {
let Some(home) = std::env::var_os("HOME").filter(|home| !home.is_empty()) else {
return Ok(UserConfig::default());
};
let path = PathBuf::from(home).join(".thirdshift/config.toml");
match std::fs::read_to_string(&path) {
Ok(text) => UserConfig::parse(&text, &path),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(UserConfig::default()),
Err(error) => Err(error).with_context(|| format!("can't read {}", path.display())),
}
}

/// Parse `text`, the contents of the User config at `path`. Any key or
/// section thirdshift doesn't know is an error, so a typo can't silently
/// leave a setting unset.
fn parse(text: &str, path: &Path) -> Result<Self> {
let file = format!("the User config {}", path.display());
let table: Table = text
.parse()
.map_err(|error| anyhow!("{error}"))
.with_context(|| format!("can't parse {file}"))?;
let mut config = UserConfig::default();
for (section, value) in &table {
let settings = match (section.as_str(), value) {
("merge", Value::Table(settings)) => settings,
("merge", _) => bail!("merge must be the section [merge] in {file}"),
(_, Value::Table(_)) => bail!("unknown section [{section}] in {file}"),
_ => bail!("unknown key {section} in {file}"),
};
for (key, value) in settings {
match (key.as_str(), value) {
("always", Value::Boolean(always)) => config.merge_always = *always,
("always", _) => bail!("merge.always must be true or false in {file}"),
_ => bail!("unknown key merge.{key} in {file}"),
}
}
}
Ok(config)
}

/// The goal of a Run whose command gave no `merge` or `no-merge`.
pub fn default_goal(&self) -> Goal {
if self.merge_always {
Goal::Merged
} else {
Goal::ReadyForReview
}
}
}

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

fn parse(text: &str) -> Result<UserConfig> {
UserConfig::parse(text, Path::new("/home/me/.thirdshift/config.toml"))
}

#[test]
fn merge_always_is_read() {
assert!(parse("[merge]\nalways = true\n").unwrap().merge_always);
assert!(!parse("[merge]\nalways = false\n").unwrap().merge_always);
assert!(!parse("").unwrap().merge_always);
}

#[test]
fn errors_name_the_file_and_the_offending_key() {
for (text, named) in [
("[merge\n", "can't parse the User config"),
("[merge]\nalway = true\n", "unknown key merge.alway"),
("[logs]\n", "unknown section [logs]"),
("merge = true\n", "merge must be the section [merge]"),
("colour = true\n", "unknown key colour "),
(
"[merge]\nalways = 1\n",
"merge.always must be true or false",
),
] {
let error = format!("{:#}", parse(text).unwrap_err());
assert!(error.contains(named), "{text:?}: {error}");
assert!(
error.contains("/home/me/.thirdshift/config.toml"),
"{text:?}: {error}"
);
}
}
}
Loading
Loading