diff --git a/README.md b/README.md index 91d56e141..c2034e4c5 100644 --- a/README.md +++ b/README.md @@ -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 ` is a Merge run, and `thirdshift --no-merge ` (or `no-merge`, before or after the URL) leaves that one Run's pull request ready for review. diff --git a/src/config.rs b/src/config.rs index 84d2638af..8c4e19d15 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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; @@ -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 { let (home, path) = home_and_path()?; match std::fs::read_to_string(&path) { @@ -151,7 +154,7 @@ pub fn setup() -> Result { } } 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())) } @@ -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 { + 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 { + 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") @@ -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); diff --git a/src/github.rs b/src/github.rs index fc8d0cd30..0813c8da5 100644 --- a/src/github.rs +++ b/src/github.rs @@ -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; @@ -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> { + 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)] diff --git a/tests/fakes/gh b/tests/fakes/gh index ed85031ea..aceedc778 100644 --- a/tests/fakes/gh +++ b/tests/fakes/gh @@ -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"?: "