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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ from = "onboarding@resend.dev" # the sender; default onboarding@resend.dev, whi
dir = "~/.thirdshift/logs" # where session logs go; default ~/.thirdshift/logs
```

Every key holds its real value, so a Run reading it does exactly what it does with no file. `email.to` has no default, so it is the only line written commented out. `setup` asks nothing, prints the file's path on stderr and exits `0` with stdout empty. A User config that is already there keeps its values; one a Run would refuse is refused the same way, exit `1`, and not touched. Any argument after `setup` is an argument error (exit `2`).
Every key holds its real value, so a Run reading it does exactly what it does with no file. `email.to` has no default, so `setup` suggests one: the public email of your GitHub profile (from `gh api user`), else your global git `user.email`, unless that is a `@users.noreply.github.com` address, which can't receive mail. With neither, `email.to` is the only line written commented out, as above. `setup` never asks `gh` for more scopes, so a private GitHub email is not read, and a Run never looks the suggestion up: `--email` with no address and no `email.to` still stops the Run. `setup` asks nothing, prints the file's path on stderr and exits `0` with stdout empty. A User config that is already there keeps its values; one a Run would refuse is refused the same way, exit `1`, and not touched. Any argument after `setup` is an argument error (exit `2`).

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.

Expand Down
58 changes: 55 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use toml::{Table, Value};

use crate::git::Git;
use crate::github;
use crate::notification::NotificationAsk;
use crate::run::Goal;

Expand Down Expand Up @@ -133,8 +135,9 @@ impl EmailSettings {
}

/// Setup with no terminal: write the User config with every setting at its
/// default, asking nothing. An existing User config keeps its values, once it
/// parses as a Run would parse it, so Setup never resets a configured machine.
/// default, asking nothing, and `email.to` as the suggested address, if there
/// is one. An existing User config keeps its values, once it parses as a Run
/// would parse it, so Setup never resets a configured machine.
pub fn setup() -> Result<String> {
let (home, path) = home_and_path()?;
match std::fs::read_to_string(&path) {
Expand All @@ -151,7 +154,7 @@ pub fn setup() -> Result<String> {
}
}
std::fs::create_dir_all(home.join(".thirdshift"))
.and_then(|()| std::fs::write(&path, DEFAULTS))
.and_then(|()| std::fs::write(&path, with_email_to(suggested_address(&home))))
.with_context(|| format!("can't write {}", path.display()))?;
Ok(format!("wrote the User config {}", path.display()))
}
Expand All @@ -174,6 +177,36 @@ from = "onboarding@resend.dev" # the sender; default onboarding@resend.dev, whi
dir = "~/.thirdshift/logs" # where session logs go; default ~/.thirdshift/logs
"#;

/// The line `DEFAULTS` holds for `email.to`, which has no default.
const NO_EMAIL_TO: &str = r#"# to = "you@example.com" # where email goes when the command names no address; no default"#;

/// `DEFAULTS` with `email.to` set to `to`, if there is one.
fn with_email_to(to: Option<String>) -> String {
let Some(to) = to else {
return DEFAULTS.to_string();
};
let (_, comment) = NO_EMAIL_TO.split_once(" # ").unwrap();
let line = format!("to = {}", Value::String(to));
DEFAULTS.replace(NO_EMAIL_TO, &format!("{line:<31} # {comment}"))
}

/// The address Setup suggests for `email.to`: the public email of the user's
/// GitHub profile, else the global git `user.email`, unless that is a
/// `@users.noreply.github.com` address, which can't receive mail. Only Setup
/// looks it up; a Run never falls back to it.
fn suggested_address(home: &Path) -> Option<String> {
let github = github::profile_email().ok().flatten();
github.filter(|email| !email.is_empty()).or_else(|| {
let git = Git::new(home).run(&["config", "--global", "user.email"]);
git.ok().filter(|email| {
!email.is_empty()
&& !email
.to_ascii_lowercase()
.ends_with("@users.noreply.github.com")
})
})
}

/// `$HOME`, and the User config's path under it.
fn home_and_path() -> Result<(PathBuf, PathBuf)> {
let home = std::env::var_os("HOME")
Expand Down Expand Up @@ -219,6 +252,25 @@ mod tests {
assert_eq!(config, UserConfig::defaults(Path::new("/home/me")));
}

#[test]
fn a_suggested_address_sets_email_to_in_place_of_the_commented_out_line() {
assert!(DEFAULTS.contains(&format!("\n{NO_EMAIL_TO}\n")));
assert_eq!(with_email_to(None), DEFAULTS);
let text = with_email_to(Some("o\"brien@example.com".to_string()));
let config = parse(&text).unwrap();
assert_eq!(config.email.to.as_deref(), Some("o\"brien@example.com"));
let line = text.lines().find(|line| line.starts_with("to = ")).unwrap();
let from = text
.lines()
.find(|line| line.starts_with("from = "))
.unwrap();
assert_eq!(
line.find(" # "),
from.find(" # ").map(|at| at + 1),
"{text}"
);
}

#[test]
fn merge_always_is_read() {
assert!(parse("[merge]\nalways = true\n").unwrap().merge_always);
Expand Down
11 changes: 10 additions & 1 deletion src/github.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! Asking GitHub, through `gh`, about issues and pull requests.
//! Asking GitHub, through `gh`, about issues, pull requests and the signed-in
//! user.

use std::fmt;
use std::process::Command;
Expand Down Expand Up @@ -52,6 +53,14 @@ pub fn close_issue(issue: &IssueUrl, comment: &str) -> Result<()> {
])
}

/// The public email of the signed-in user's GitHub profile, or `None` if it
/// is private. Private addresses need the `user` scope, which a default
/// `gh auth login` token lacks, so thirdshift never asks for them.
pub fn profile_email() -> Result<Option<String>> {
let json = gh_json(&["api", "user"])?;
Ok(json["email"].as_str().map(str::to_string))
}

const PR_FIELDS: &str = "number,url,state,headRefName,baseRefName,isDraft";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down
18 changes: 15 additions & 3 deletions tests/fakes/gh
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ State lives in the JSON file named by $FAKE_GH_STATE:
"on_merge"?: {"times", "script"},
"refuse_merges"?: {"times", "error"},
"after_merge"?: "<script>",
"failing"?: ["<command> <subcommand>", ...]}
"failing"?: ["<command> <subcommand>", ...],
"user_email"?: "<address>" | null}

`gh pr merge` makes a real merge commit of the PR's head into its base in the
bare origin repo beside $FAKE_GH_STATE (`origin.git`), whose HEAD names the
Expand All @@ -28,6 +29,9 @@ merged. Its `closingIssuesReferences` are the issues its body closes.

An issue's title is its entry in `titles`, else `Issue <number>`.

`gh api user` answers with the signed-in user's profile, whose public `email`
is `user_email`, else null. `gh fake fails 'api user'` makes it fail.

`gh issue close` closes the issue, recording its `--comment`; on an issue
already closed it only warns, as gh does.

Expand Down Expand Up @@ -55,6 +59,8 @@ command):
successful `gh pr merge`
gh fake fails '<command> <subcommand>' make every such call fail, e.g.
'issue close'
gh fake user-email '<JSON>' set the public profile email, an
address or null

Every call's argv is appended to the JSON list in $FAKE_GH_RECORD.

Expand Down Expand Up @@ -307,8 +313,12 @@ def issue_close(state, positional, flags):


def api(state, positional, flags):
"""The two commit endpoints thirdshift reads CI from, one page, each item
of the list `--jq '.<list>[]'` names on its own line."""
"""The signed-in user, or the two commit endpoints thirdshift reads CI
from, one page, each item of the list `--jq '.<list>[]'` names on its own
line."""
if positional[0] == "user" and not flags:
print(json.dumps({"login": "runner", "email": state.get("user_email")}))
return
match = re.fullmatch(
r"repos/([^/]+/[^/]+)/commits/([0-9a-f]+)/(check-runs|status)(\?.*)?",
positional[0],
Expand Down Expand Up @@ -387,6 +397,8 @@ def fake(state, args):
state["after_merge"] = args[1]
elif args[0] == "issue":
state["issues"][args[1]] = args[2]
elif args[0] == "user-email":
state["user_email"] = json.loads(args[1])
elif args[0] == "fails":
state.setdefault("failing", []).append(args[1])
elif args[0] == "pr":
Expand Down
104 changes: 103 additions & 1 deletion tests/setup.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! `thirdshift setup`: Setup writes a complete User config. With no terminal
//! and no User config, it writes every setting at its default without asking.
//! and no User config, it writes every setting at its default without asking,
//! and `email.to` as the GitHub email it suggests, if it finds one.

mod support;

Expand All @@ -22,6 +23,12 @@ fn user_config(scenario: &Scenario) -> Option<String> {
fs::read_to_string(scenario.path("home/.thirdshift/config.toml")).ok()
}

/// The `email.to` the User config sets, if it sets one.
fn email_to(scenario: &Scenario) -> Option<String> {
let config: toml::Table = user_config(scenario)?.parse().unwrap();
Some(config["email"].get("to")?.as_str()?.to_string())
}

/// The lines of `text` that set a key, commented out or not, as
/// `(section, line)`.
fn key_lines(text: &str) -> Vec<(String, String)> {
Expand All @@ -41,6 +48,7 @@ fn key_lines(text: &str) -> Vec<(String, String)> {
#[test]
fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() {
let scenario = Scenario::new();
scenario.git_email_is(None);

let result = scenario.run(&["setup"]);

Expand Down Expand Up @@ -73,6 +81,7 @@ fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() {
#[test]
fn every_key_is_written_with_a_comment_giving_what_it_does_and_its_default() {
let scenario = Scenario::new();
scenario.git_email_is(None);

scenario.run(&["setup"]);

Expand Down Expand Up @@ -138,7 +147,10 @@ fn a_run_with_the_written_user_config_behaves_as_with_none() {
#[test]
fn a_run_with_the_written_user_config_still_needs_an_address_for_email() {
let scenario = Scenario::new();
scenario.git_email_is(Some("123+runner@users.noreply.github.com"));
assert_eq!(scenario.run(&["setup"]).code, Some(0));
assert!(email_to(&scenario).is_none());
let setup_calls = scenario.gh_calls().len();

let result = scenario.run_with_env(
&["--email", &scenario.issue_url(7)],
Expand All @@ -152,6 +164,8 @@ fn a_run_with_the_written_user_config_still_needs_an_address_for_email() {
result.stderr
);
assert!(scenario.claude_calls().is_empty(), "the Run started");
let run_calls = &scenario.gh_calls()[setup_calls..];
assert!(run_calls.is_empty(), "the Run asked GitHub: {run_calls:?}");
}

#[test]
Expand Down Expand Up @@ -213,3 +227,91 @@ fn setup_refuses_a_user_config_a_run_would_refuse_and_leaves_it_alone() {
);
assert_eq!(user_config(&scenario).as_deref(), Some(broken));
}

#[test]
fn setup_writes_the_public_github_email_as_email_to() {
let scenario = Scenario::new();
scenario.github_email_is(Some("octo@example.com"));

let result = scenario.run(&["setup"]);

assert_eq!(result.code, Some(0), "stderr: {}", result.stderr);
assert_eq!(email_to(&scenario).as_deref(), Some("octo@example.com"));
let text = user_config(&scenario).unwrap();
assert!(!text.contains("# to = "), "{text}");
let to = key_lines(&text)
.into_iter()
.find(|(_, line)| line.starts_with("to = "))
.unwrap()
.1;
assert!(to.contains(" # ") && to.contains("default"), "{to}");
}

#[test]
fn with_no_public_github_email_setup_writes_the_git_email() {
let scenario = Scenario::new();
scenario.github_email_is(None);
scenario.git_email_is(Some("me@example.org"));

assert_eq!(scenario.run(&["setup"]).code, Some(0));

assert_eq!(email_to(&scenario).as_deref(), Some("me@example.org"));
}

#[test]
fn when_gh_api_user_fails_setup_writes_the_git_email() {
let scenario = Scenario::new();
scenario.github_profile_fails();
scenario.git_email_is(Some("me@example.org"));

let result = scenario.run(&["setup"]);

assert_eq!(result.code, Some(0), "stderr: {}", result.stderr);
assert_eq!(email_to(&scenario).as_deref(), Some("me@example.org"));
assert!(
scenario
.gh_calls()
.iter()
.any(|call| call[..2] == ["api", "user"]),
"{:?}",
scenario.gh_calls()
);
}

#[test]
fn a_noreply_git_email_is_never_written() {
let scenario = Scenario::new();
scenario.git_email_is(Some("123+octo@users.noreply.github.com"));

assert_eq!(scenario.run(&["setup"]).code, Some(0));

let text = user_config(&scenario).unwrap();
assert!(!text.contains("noreply"), "{text}");
assert!(text.contains("\n# to = "), "{text}");
}

#[test]
fn with_neither_email_setup_writes_email_to_commented_out() {
let scenario = Scenario::new();
scenario.github_profile_fails();
scenario.git_email_is(None);

assert_eq!(scenario.run(&["setup"]).code, Some(0));

let text = user_config(&scenario).unwrap();
assert_eq!(email_to(&scenario), None, "{text}");
assert!(text.contains("\n# to = \"you@example.com\""), "{text}");
}

#[test]
fn setup_never_replaces_an_existing_email_to() {
let scenario = Scenario::new();
scenario.github_email_is(Some("octo@example.com"));
let mine = "[email]\nto = \"mine@example.net\"\n";
scenario.user_config_is(mine);

let result = scenario.run(&["setup"]);

assert_eq!(result.code, Some(0), "stderr: {}", result.stderr);
assert_eq!(email_to(&scenario).as_deref(), Some("mine@example.net"));
}
32 changes: 32 additions & 0 deletions tests/support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,38 @@ impl Scenario {
self.write_gh_state(&gh);
}

/// Set the public email of the profile `gh api user` answers with: an
/// address, or `None` for a private one.
pub fn github_email_is(&self, email: Option<&str>) {
let mut gh = self.gh_state();
gh["user_email"] = json!(email);
self.write_gh_state(&gh);
}

/// Make every `gh api user` call fail.
pub fn github_profile_fails(&self) {
let mut gh = self.gh_state();
let failing = gh.as_object_mut().unwrap().entry("failing");
failing
.or_insert(json!([]))
.as_array_mut()
.unwrap()
.push(json!("api user"));
self.write_gh_state(&gh);
}

/// Set the global git `user.email`, or with `None` unset it.
pub fn git_email_is(&self, email: Option<&str>) {
let change = match email {
Some(email) => ["user.email", email],
None => ["--unset", "user.email"],
};
git(
&self.path("home"),
&[&["config", "--global"][..], &change].concat(),
);
}

pub fn gh_state(&self) -> Value {
serde_json::from_str(&fs::read_to_string(self.path("gh-state.json")).unwrap()).unwrap()
}
Expand Down
Loading