diff options
author | Mark Rousskov <mark.simulacrum@gmail.com> | 2019-07-13 17:18:41 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-07-13 17:18:41 -0400 |
commit | 732feec5e8db47846ae2fbfe69fbf028090a809d (patch) | |
tree | 336c1390b505d6f08b8fbf247d06da20a57bf677 /src | |
parent | 14a42ebabfd56eb50447d8d8c8d4e2b1235cf1c7 (diff) | |
parent | 5fde6a68757402d50fa312ea75103c083f9483af (diff) |
Merge pull request #87 from rust-lang/github-ids
Store GitHub IDs in the repo
Diffstat (limited to 'src')
-rw-r--r-- | src/github.rs | 152 | ||||
-rw-r--r-- | src/main.rs | 71 | ||||
-rw-r--r-- | src/permissions.rs | 21 | ||||
-rw-r--r-- | src/schema.rs | 7 | ||||
-rw-r--r-- | src/static_api.rs | 14 | ||||
-rw-r--r-- | src/validate.rs | 40 |
6 files changed, 281 insertions, 24 deletions
diff --git a/src/github.rs b/src/github.rs new file mode 100644 index 0000000..3810947 --- /dev/null +++ b/src/github.rs @@ -0,0 +1,152 @@ +use failure::{bail, Error}; +use reqwest::header::{self, HeaderValue}; +use reqwest::{Client, Method, RequestBuilder}; +use std::borrow::Cow; +use std::collections::HashMap; + +static API_BASE: &str = "https://api.github.com/"; +static TOKEN_VAR: &str = "GITHUB_TOKEN"; + +#[derive(serde::Deserialize)] +pub(crate) struct User { + pub(crate) login: String, + pub(crate) name: Option<String>, + pub(crate) email: Option<String>, +} + +#[derive(serde::Deserialize)] +struct GraphResult<T> { + data: Option<T>, + #[serde(default)] + errors: Vec<GraphError>, +} + +#[derive(serde::Deserialize)] +struct GraphError { + message: String, +} + +#[derive(serde::Deserialize)] +struct GraphNodes<T> { + nodes: Vec<Option<T>>, +} + +pub(crate) struct GitHubApi { + http: Client, + token: Option<String>, +} + +impl GitHubApi { + pub(crate) fn new() -> Self { + GitHubApi { + http: Client::new(), + token: std::env::var(TOKEN_VAR).ok(), + } + } + + fn prepare( + &self, + require_auth: bool, + method: Method, + url: &str, + ) -> Result<RequestBuilder, Error> { + let url = if url.starts_with("https://") { + Cow::Borrowed(url) + } else { + Cow::Owned(format!("{}{}", API_BASE, url)) + }; + if require_auth { + self.require_auth()?; + } + + let mut req = self.http.request(method, url.as_ref()); + if let Some(token) = &self.token { + req = req.header( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("token {}", token))?, + ); + } + Ok(req) + } + + fn graphql<R, V>(&self, query: &str, variables: V) -> Result<R, Error> + where + R: serde::de::DeserializeOwned, + V: serde::Serialize, + { + #[derive(serde::Serialize)] + struct Request<'a, V> { + query: &'a str, + variables: V, + } + let res: GraphResult<R> = self + .prepare(true, Method::POST, "graphql")? + .json(&Request { query, variables }) + .send()? + .error_for_status()? + .json()?; + if let Some(error) = res.errors.iter().next() { + bail!("graphql error: {}", error.message); + } else if let Some(data) = res.data { + Ok(data) + } else { + bail!("missing graphql data"); + } + } + + pub(crate) fn require_auth(&self) -> Result<(), Error> { + if self.token.is_none() { + bail!("missing environment variable {}", TOKEN_VAR); + } + Ok(()) + } + + pub(crate) fn user(&self, login: &str) -> Result<User, Error> { + Ok(self + .prepare(false, Method::GET, &format!("users/{}", login))? + .send()? + .error_for_status()? + .json()?) + } + + pub(crate) fn usernames(&self, ids: &[usize]) -> Result<HashMap<usize, String>, Error> { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct Usernames { + database_id: usize, + login: String, + } + #[derive(serde::Serialize)] + struct Params { + ids: Vec<String>, + } + static QUERY: &str = " + query($ids: [ID!]!) { + nodes(ids: $ids) { + ... on User { + databaseId + login + } + } + } + "; + + let mut result = HashMap::new(); + for chunk in ids.chunks(100) { + let res: GraphNodes<Usernames> = self.graphql( + QUERY, + Params { + ids: chunk.iter().map(|id| user_node_id(*id)).collect(), + }, + )?; + for node in res.nodes.into_iter().filter_map(|n| n) { + result.insert(node.database_id, node.login); + } + } + Ok(result) + } +} + +fn user_node_id(id: usize) -> String { + base64::encode(&format!("04:User{}", id)) +} diff --git a/src/main.rs b/src/main.rs index 1b45257..d8669bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,12 +3,14 @@ mod data; #[macro_use] mod permissions; +mod github; mod schema; mod static_api; mod validate; use crate::data::Data; use failure::{err_msg, Error}; +use log::{error, info, warn}; use std::path::PathBuf; use structopt::StructOpt; @@ -16,7 +18,15 @@ use structopt::StructOpt; #[structopt(name = "team", about = "manage the rust team members")] enum Cli { #[structopt(name = "check", help = "check if the configuration is correct")] - Check, + Check { + #[structopt(long = "strict", help = "fail if optional checks are not executed")] + strict: bool, + }, + #[structopt( + name = "add-person", + help = "add a new person from their GitHub profile" + )] + AddPerson { github_name: String }, #[structopt(name = "static-api", help = "generate the static API")] StaticApi { dest: String }, #[structopt(name = "dump-team", help = "print the members of a team")] @@ -36,11 +46,19 @@ enum Cli { } fn main() { - env_logger::init(); + let mut env = env_logger::Builder::new(); + env.default_format_timestamp(false); + env.default_format_module_path(false); + env.filter_module("rust_team", log::LevelFilter::Info); + if let Ok(content) = std::env::var("RUST_LOG") { + env.parse(&content); + } + env.init(); + if let Err(e) = run() { - eprintln!("error: {}", e); + error!("{}", e); for e in e.iter_causes() { - eprintln!(" cause: {}", e); + error!("cause: {}", e); } std::process::exit(1); } @@ -50,8 +68,46 @@ fn run() -> Result<(), Error> { let cli = Cli::from_args(); let data = Data::load()?; match cli { - Cli::Check => { - crate::validate::validate(&data)?; + Cli::Check { strict } => { + crate::validate::validate(&data, strict)?; + } + Cli::AddPerson { ref github_name } => { + #[derive(serde::Serialize)] + struct PersonToAdd<'a> { + name: &'a str, + github: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + email: Option<&'a str>, + } + + let github = github::GitHubApi::new(); + let user = github.user(github_name)?; + let github_name = user.login; + + if data.person(&github_name).is_some() { + failure::bail!("person already in the repo: {}", github_name); + } + + let file = format!("people/{}.toml", github_name); + std::fs::write( + &file, + toml::to_string_pretty(&PersonToAdd { + name: user.name.as_ref().map(|n| n.as_str()).unwrap_or_else(|| { + warn!( + "the person is missing the name on GitHub, defaulting to the username" + ); + github_name.as_str() + }), + github: &github_name, + email: user.email.as_ref().map(|e| e.as_str()).or_else(|| { + warn!("the person is missing the email on GitHub, leaving the field empty"); + None + }), + })? + .as_bytes(), + )?; + + info!("written data to {}", file); } Cli::StaticApi { ref dest } => { let dest = PathBuf::from(dest); @@ -100,8 +156,9 @@ fn run() -> Result<(), Error> { if !crate::schema::Permissions::AVAILABLE.contains(&name.as_str()) { failure::bail!("unknown permission: {}", name); } - let mut allowed = crate::permissions::allowed_github_users(&data, name)? + let mut allowed = crate::permissions::allowed_people(&data, name)? .into_iter() + .map(|person| person.github()) .collect::<Vec<_>>(); allowed.sort(); for github_username in &allowed { diff --git a/src/permissions.rs b/src/permissions.rs index 5901cb8..c4d2454 100644 --- a/src/permissions.rs +++ b/src/permissions.rs @@ -1,4 +1,5 @@ use crate::data::Data; +use crate::schema::Person; use failure::{bail, Error}; use std::collections::HashSet; @@ -149,22 +150,20 @@ permissions! { } } -pub(crate) fn allowed_github_users( - data: &Data, +pub(crate) fn allowed_people<'a>( + data: &'a Data, permission: &str, -) -> Result<HashSet<String>, Error> { - let mut github_users = HashSet::new(); +) -> Result<Vec<&'a Person>, Error> { + let mut members_with_perms = HashSet::new(); for team in data.teams() { if team.permissions().has(permission) { for member in team.members(&data)? { - github_users.insert(member.to_string()); + members_with_perms.insert(member); } } } - for person in data.people() { - if person.permissions().has(permission) { - github_users.insert(person.github().to_string()); - } - } - Ok(github_users) + Ok(data + .people() + .filter(|p| members_with_perms.contains(p.github()) || p.permissions().has(permission)) + .collect()) } diff --git a/src/schema.rs b/src/schema.rs index c7a1b48..570b212 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -38,10 +38,11 @@ pub(crate) enum Email<'a> { } #[derive(serde_derive::Deserialize, Debug)] -#[serde(deny_unknown_fields)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] pub(crate) struct Person { name: String, github: String, + github_id: usize, irc: Option<String>, #[serde(default)] email: EmailField, @@ -59,6 +60,10 @@ impl Person { &self.github } + pub(crate) fn github_id(&self) -> usize { + self.github_id + } + #[allow(unused)] pub(crate) fn irc(&self) -> &str { if let Some(irc) = &self.irc { diff --git a/src/static_api.rs b/src/static_api.rs index c752bc3..15e1dcd 100644 --- a/src/static_api.rs +++ b/src/static_api.rs @@ -40,6 +40,7 @@ impl<'a> Generator<'a> { members.push(v1::TeamMember { name: person.name().into(), github: (*github_name).into(), + github_id: person.github_id(), is_lead: leads.contains(github_name), }); } @@ -101,13 +102,20 @@ impl<'a> Generator<'a> { fn generate_permissions(&self) -> Result<(), Error> { for perm in Permissions::AVAILABLE { - let mut github_users = crate::permissions::allowed_github_users(&self.data, perm)? - .into_iter() + let allowed = crate::permissions::allowed_people(&self.data, perm)?; + let mut github_users = allowed + .iter() + .map(|p| p.github().to_string()) .collect::<Vec<_>>(); + let mut github_ids = allowed.iter().map(|p| p.github_id()).collect::<Vec<_>>(); github_users.sort(); + github_ids.sort(); self.add( &format!("v1/permissions/{}.json", perm), - &v1::Permission { github_users }, + &v1::Permission { + github_users, + github_ids, + }, )?; } Ok(()) diff --git a/src/validate.rs b/src/validate.rs index 87f2354..4c4ebfb 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -1,6 +1,8 @@ use crate::data::Data; +use crate::github::GitHubApi; use crate::schema::{Email, Permissions}; use failure::{bail, Error}; +use log::{error, warn}; use regex::Regex; use std::collections::{HashMap, HashSet}; @@ -23,19 +25,35 @@ static CHECKS: &[fn(&Data, &mut Vec<String>)] = &[ validate_team_names, ]; -pub(crate) fn validate(data: &Data) -> Result<(), Error> { +static GITHUB_CHECKS: &[fn(&Data, &GitHubApi, &mut Vec<String>)] = &[validate_github_usernames]; + +pub(crate) fn validate(data: &Data, strict: bool) -> Result<(), Error> { let mut errors = Vec::new(); for check in CHECKS { check(data, &mut errors); } + let github = GitHubApi::new(); + if let Err(err) = github.require_auth() { + if strict { + return Err(err); + } else { + warn!("couldn't perform checks relying on the GitHub API, some errors will not be detected"); + warn!("cause: {}", err); + } + } else { + for check in GITHUB_CHECKS { + check(data, &github, &mut errors); + } + } + if !errors.is_empty() { errors.sort(); errors.dedup_by(|a, b| a == b); for err in &errors { - eprintln!("validation error: {}", err); + error!("validation error: {}", err); } bail!("{} validation errors found", errors.len()); @@ -363,6 +381,24 @@ fn validate_team_names(data: &Data, errors: &mut Vec<String>) { }); } +/// Ensure there are no misspelled GitHub account names +fn validate_github_usernames(data: &Data, github: &GitHubApi, errors: &mut Vec<String>) { + let people = data + .people() + .map(|p| (p.github_id(), p)) + .collect::<HashMap<_, _>>(); + match github.usernames(&people.keys().cloned().collect::<Vec<_>>()) { + Ok(res) => wrapper(res.iter(), errors, |(id, name), _| { + let original = people[id].github(); + if original != name { + bail!("user `{}` changed username to `{}`", original, name); + } + Ok(()) + }), + Err(err) => errors.push(format!("couldn't verify GitHub usernames: {}", err)), + } +} + fn wrapper<T, I, F>(iter: I, errors: &mut Vec<String>, mut func: F) where I: Iterator<Item = T>, |