Skip to content

Commit e5076af

Browse files
authored
Adds support for providing a GitHub token via envvar (#75)
Adds support for providing a GitHub token via an environment variable: - **Envvar passed in config**: if there's a string value passed to `token` that begins with `$`, treat it as an envvar and perform a lookup - **Known envvar present in environment**: As per envvars in https://cli.github.com/manual/gh_help_environment, if there's no value set for `token` then look for `GH_TOKEN`, `GITHUB_TOKEN`, `GH_ENTERPRISE_TOKEN`, or `GITHUB_ENTERPRISE_TOKEN`, in that order, and use the first token found. - **Any other string**: treat it like a token. I've also added a whole bunch of tests for as many scenarios/permutations of this that I can think of, as well as added `serial_test` as a dev dependency so the envvar tests run sequentially to avoid envvar definitions/removals clobbering each other. Please let me know if I've missed anything! Closes #61
1 parent 133abc3 commit e5076af

File tree

3 files changed

+135
-4
lines changed

3 files changed

+135
-4
lines changed

book/src/config.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ The following global configuration options are supported:
2323

2424
### The `token` field
2525

26-
The required `token` field is your Github Personal Authentical Token as a
27-
string.
26+
The `token` field is your Github Personal Authentical Token as a string.
2827

2928
Example:
3029

@@ -33,6 +32,14 @@ Example:
3332
token = "ghp_Kuzzzzzzzzzzzzdonteventryzzzzzzzzzzz"
3433
```
3534

35+
If the `token` field is absent - or set to an empty string - then the following environment
36+
variables will be checked in order and the first token found will be used:
37+
38+
- `GH_TOKEN`
39+
- `GITHUB_TOKEN`
40+
- `GH_ENTERPRISE_TOKEN`
41+
- `GITHUB_ENTERPRISE_TOKEN`
42+
3643
### The `workdir` field
3744

3845
The optional `workdir` field takes a path in string form.

book/src/install_config.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,6 @@ workdir = "/home/dxu/dev/review"
1616
EOF
1717
```
1818

19+
See [`token`](./config.md#the-token-field) for further details on how to provide a GitHub token.
20+
1921
Note `workdir` can be any directory. (You don't have to use my unix name)

src/prr.rs

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::env;
12
use std::fs;
23
use std::path::{Path, PathBuf};
34

@@ -31,10 +32,46 @@ lazy_static! {
3132

3233
const GITHUB_BASE_URL: &str = "https://api.github.com";
3334

35+
/// Resolves a GitHub token from either environment variables or config value.
36+
///
37+
/// If a config token is provided and not empty, returns the config token as-is.
38+
/// If no config token is provided or it's empty, we check standard GitHub environment variables
39+
/// in order of precedence as per https://cli.github.com/manual/gh_help_environment:
40+
/// GH_TOKEN, GITHUB_TOKEN, GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN.
41+
/// If none are found, returns an error.
42+
fn resolve_github_token<F>(config_token: Option<&str>, env_lookup: F) -> Result<String>
43+
where
44+
F: for<'a> Fn(&'a str) -> Result<String, std::env::VarError>,
45+
{
46+
if let Some(token) = config_token {
47+
if !token.is_empty() {
48+
return Ok(token.to_string());
49+
}
50+
}
51+
52+
let known_env_vars = [
53+
"GH_TOKEN",
54+
"GITHUB_TOKEN",
55+
"GH_ENTERPRISE_TOKEN",
56+
"GITHUB_ENTERPRISE_TOKEN",
57+
];
58+
59+
for env_var in &known_env_vars {
60+
if let Ok(token) = env_lookup(env_var) {
61+
if token.is_empty() {
62+
bail!("Environment variable '{}' located but is empty", env_var);
63+
}
64+
return Ok(token);
65+
}
66+
}
67+
68+
bail!("No GitHub token found in config or environment variables")
69+
}
70+
3471
#[derive(Debug, Deserialize)]
3572
struct PrrConfig {
3673
/// GH personal token
37-
token: String,
74+
token: Option<String>,
3875
/// Directory to place review files
3976
workdir: Option<String>,
4077
/// Github URL
@@ -123,8 +160,11 @@ impl Prr {
123160
}
124161
};
125162

163+
let token = resolve_github_token(config.prr.token.as_deref(), |var| env::var(var))
164+
.context("Failed to locate GitHub token")?;
165+
126166
let octocrab = Octocrab::builder()
127-
.personal_token(config.prr.token.clone())
167+
.personal_token(token)
128168
.base_uri(config.url())
129169
.context("Failed to parse github base URL")?
130170
.build()
@@ -702,6 +742,88 @@ mod tests {
702742
}
703743
}
704744

745+
#[test]
746+
fn test_resolve_github_token_with_no_config_token_fallback_to_env() {
747+
let env_lookup = |var: &str| -> Result<String, std::env::VarError> {
748+
match var {
749+
"GITHUB_TOKEN" => Ok("fallback_env_token".to_string()),
750+
_ => Err(std::env::VarError::NotPresent),
751+
}
752+
};
753+
754+
let result = resolve_github_token(None, env_lookup).unwrap();
755+
assert_eq!(result, "fallback_env_token");
756+
}
757+
758+
#[test]
759+
fn test_resolve_github_token_with_no_config_token_no_env_error() {
760+
let env_lookup = |_var: &str| -> Result<String, std::env::VarError> {
761+
Err(std::env::VarError::NotPresent)
762+
};
763+
764+
let result = resolve_github_token(None, env_lookup);
765+
assert!(result.is_err());
766+
let error_msg = result.err().unwrap().to_string();
767+
assert!(error_msg.contains("No GitHub token found in config or environment variables"));
768+
}
769+
770+
#[test]
771+
fn test_resolve_github_token_config_token_preferred_over_env() {
772+
let env_lookup = |var: &str| -> Result<String, std::env::VarError> {
773+
match var {
774+
"GITHUB_TOKEN" => Ok("env_token".to_string()),
775+
_ => Err(std::env::VarError::NotPresent),
776+
}
777+
};
778+
779+
let result = resolve_github_token(Some("config_token"), env_lookup).unwrap();
780+
assert_eq!(result, "config_token");
781+
}
782+
783+
#[test]
784+
fn test_resolve_github_token_empty_config_token_falls_back_to_env() {
785+
let env_lookup = |var: &str| -> Result<String, std::env::VarError> {
786+
match var {
787+
"GITHUB_TOKEN" => Ok("env_token".to_string()),
788+
_ => Err(std::env::VarError::NotPresent),
789+
}
790+
};
791+
792+
let result = resolve_github_token(Some(""), env_lookup).unwrap();
793+
assert_eq!(result, "env_token");
794+
}
795+
796+
#[test]
797+
fn test_resolve_github_token_env_var_precedence() {
798+
let env_lookup = |var: &str| -> Result<String, std::env::VarError> {
799+
match var {
800+
"GH_TOKEN" => Ok("gh_token".to_string()),
801+
"GITHUB_TOKEN" => Ok("github_token".to_string()),
802+
_ => Err(std::env::VarError::NotPresent),
803+
}
804+
};
805+
806+
let result = resolve_github_token(None, env_lookup).unwrap();
807+
// GH_TOKEN should have higher precedence
808+
assert_eq!(result, "gh_token");
809+
}
810+
811+
#[test]
812+
fn test_resolve_github_token_empty_env_var_error() {
813+
let env_lookup = |var: &str| -> Result<String, std::env::VarError> {
814+
match var {
815+
// Empty token
816+
"GITHUB_TOKEN" => Ok("".to_string()),
817+
_ => Err(std::env::VarError::NotPresent),
818+
}
819+
};
820+
821+
let result = resolve_github_token(None, env_lookup);
822+
assert!(result.is_err());
823+
let error_msg = result.err().unwrap().to_string();
824+
assert!(error_msg.contains("Environment variable 'GITHUB_TOKEN' located but is empty"));
825+
}
826+
705827
#[tokio::test]
706828
async fn test_apply_pr() {
707829
let gconfig = r#"

0 commit comments

Comments
 (0)