codealong_github/
config.rs

1use slog::Logger;
2use std::collections::HashMap;
3
4use codealong::{AuthorConfig, Config, Identity, RepoEntry, RepoInfo, WorkspaceConfig};
5
6use crate::client::Client;
7use crate::cursor::Cursor;
8use crate::error::{retry_when_rate_limited, Result};
9use crate::repo::Repo;
10use crate::team::Team;
11use crate::user::User;
12
13/// Generate a `Config` from Github organization information. The primary use
14/// case here is to generate a list of authors with aliases from all of the
15/// organization's members.
16pub fn config_from_org(
17    client: &Client,
18    github_org: &str,
19    logger: &Logger,
20) -> Result<WorkspaceConfig> {
21    let config = default_config_with_authors(client, github_org, logger)?;
22    let repos = build_repo_entries(client, github_org, logger)?;
23    Ok(WorkspaceConfig { config, repos })
24}
25
26fn default_config_with_authors(
27    client: &Client,
28    github_org: &str,
29    logger: &Logger,
30) -> Result<Config> {
31    let all_teams = get_all_teams(client, github_org, logger)?;
32    let url = format!("https://api.github.com/orgs/{}/members", github_org);
33    let cursor: Cursor<User> = Cursor::new(&client, &url, &logger);
34    let mut config = Config::default();
35    for user in cursor {
36        let teams = all_teams.get(&user.login);
37        add_user_to_config(&client, &mut config, user, teams, logger)?;
38    }
39    Ok(config)
40}
41
42fn get_all_teams(
43    client: &Client,
44    github_org: &str,
45    logger: &Logger,
46) -> Result<HashMap<String, Vec<Team>>> {
47    let url = format!("https://api.github.com/orgs/{}/teams", github_org);
48    let cursor: Cursor<Team> = Cursor::new(&client, &url, logger);
49    let mut res: HashMap<String, Vec<Team>> = HashMap::new();
50    for team in cursor {
51        let url = format!("https://api.github.com/teams/{}/members", &team.id);
52        let cursor: Cursor<User> = Cursor::new(&client, &url, logger);
53        for user in cursor {
54            let teams = res.entry(user.login).or_insert_with(|| Vec::new());
55            teams.push(team.clone());
56        }
57    }
58    Ok(res)
59}
60
61fn add_user_to_config(
62    client: &Client,
63    config: &mut Config,
64    mut user: User,
65    teams: Option<&Vec<Team>>,
66    logger: &Logger,
67) -> Result<()> {
68    augment_with_search_data(client, &mut user, logger)?;
69
70    let formatted_teams = teams
71        .map(|teams| teams.iter().map(|team| team.name.clone()).collect())
72        .unwrap_or_else(|| Vec::new());
73
74    let author_config = AuthorConfig {
75        github_logins: vec![user.login.clone()],
76        teams: formatted_teams,
77        ..Default::default()
78    };
79
80    // Prefer a User <email> formatted id for the author, but fallback to using
81    // the github login
82    let key = if user.email.is_some() || user.name.is_some() {
83        Identity {
84            name: user.name,
85            email: user.email,
86        }
87        .to_string()
88    } else {
89        user.login
90    };
91
92    config.authors.insert(key, author_config);
93    Ok(())
94}
95
96// Use the github search API to attempt to get email/name directly from commits
97fn augment_with_search_data(client: &Client, user: &mut User, logger: &Logger) -> Result<()> {
98    let url = format!(
99        "https://api.github.com/search/commits?q=author:{}",
100        &user.login
101    );
102    let mut resp = retry_when_rate_limited(
103        &mut || client.get_with_content_type(&url, "application/vnd.github.cloak-preview"),
104        Some(&mut |seconds| warn!(logger, "Rate limit reached, sleeping {} seconds", seconds)),
105    )?;
106    let results = resp.json::<SearchResults>()?;
107
108    if let Some(r) = results.items.first() {
109        user.email = user.email.take().or_else(|| r.commit.author.email.clone());
110        user.name = user.name.take().or_else(|| r.commit.author.name.clone());
111    }
112    Ok(())
113}
114
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116struct SearchResults {
117    items: Vec<SearchResult>,
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121struct SearchResult {
122    commit: Commit,
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126struct Commit {
127    author: Signature,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131struct Signature {
132    pub name: Option<String>,
133    pub email: Option<String>,
134}
135
136fn build_repo_entries(
137    client: &Client,
138    github_org: &str,
139    logger: &Logger,
140) -> Result<Vec<RepoEntry>> {
141    let url = format!("https://api.github.com/orgs/{}/repos", github_org);
142    let cursor: Cursor<Repo> = Cursor::new(&client, &url, logger);
143    let res = cursor.map(|repo| RepoEntry {
144        repo_info: RepoInfo {
145            name: repo.full_name.clone(),
146            github_name: Some(repo.full_name.clone()),
147            clone_url: repo.ssh_url,
148            fork: repo.fork,
149            ..Default::default()
150        },
151        path: Some(format!("{}.git", repo.full_name)),
152        ignore: false,
153    });
154    Ok(res.collect())
155}
156
157#[cfg(test)]
158mod test {
159    use super::*;
160    use codealong::test::build_test_logger;
161
162    #[test]
163    fn test_config_from_org() -> Result<()> {
164        let client = Client::from_env();
165        let workspace_config = config_from_org(&client, "codealong", &build_test_logger())?;
166        assert!(workspace_config.config.authors.len() >= 1);
167        assert!(workspace_config.repos.len() >= 1);
168        assert_eq!(
169            workspace_config
170                .config
171                .authors
172                .iter()
173                .next()
174                .unwrap()
175                .1
176                .tags,
177            vec!["team:Devs".to_owned(), "team:Ninjas".to_owned()]
178        );
179        Ok(())
180    }
181}