contributors.rs

  1use std::sync::{Arc, OnceLock};
  2
  3use axum::{
  4    Extension, Json, Router,
  5    extract::{self, Query},
  6    routing::get,
  7};
  8use chrono::{NaiveDateTime, SecondsFormat};
  9use serde::{Deserialize, Serialize};
 10
 11use crate::db::ContributorSelector;
 12use crate::{AppState, Result};
 13
 14pub fn router() -> Router {
 15    Router::new()
 16        .route("/contributors", get(get_contributors).post(add_contributor))
 17        .route("/contributor", get(check_is_contributor))
 18}
 19
 20async fn get_contributors(Extension(app): Extension<Arc<AppState>>) -> Result<Json<Vec<String>>> {
 21    Ok(Json(app.db.get_contributors().await?))
 22}
 23
 24#[derive(Debug, Deserialize)]
 25struct CheckIsContributorParams {
 26    github_user_id: Option<i32>,
 27    github_login: Option<String>,
 28}
 29
 30impl CheckIsContributorParams {
 31    fn into_contributor_selector(self) -> Result<ContributorSelector> {
 32        if let Some(github_user_id) = self.github_user_id {
 33            return Ok(ContributorSelector::GitHubUserId { github_user_id });
 34        }
 35
 36        if let Some(github_login) = self.github_login {
 37            return Ok(ContributorSelector::GitHubLogin { github_login });
 38        }
 39
 40        Err(anyhow::anyhow!(
 41            "must be one of `github_user_id` or `github_login`."
 42        ))?
 43    }
 44}
 45
 46#[derive(Debug, Serialize)]
 47struct CheckIsContributorResponse {
 48    signed_at: Option<String>,
 49}
 50
 51async fn check_is_contributor(
 52    Extension(app): Extension<Arc<AppState>>,
 53    Query(params): Query<CheckIsContributorParams>,
 54) -> Result<Json<CheckIsContributorResponse>> {
 55    let params = params.into_contributor_selector()?;
 56
 57    if CopilotSweAgentBot::is_copilot_bot(&params) {
 58        return Ok(Json(CheckIsContributorResponse {
 59            signed_at: Some(
 60                CopilotSweAgentBot::created_at()
 61                    .and_utc()
 62                    .to_rfc3339_opts(SecondsFormat::Millis, true),
 63            ),
 64        }));
 65    }
 66
 67    if Dependabot::is_dependabot(&params) {
 68        return Ok(Json(CheckIsContributorResponse {
 69            signed_at: Some(
 70                Dependabot::created_at()
 71                    .and_utc()
 72                    .to_rfc3339_opts(SecondsFormat::Millis, true),
 73            ),
 74        }));
 75    }
 76
 77    if RenovateBot::is_renovate_bot(&params) {
 78        return Ok(Json(CheckIsContributorResponse {
 79            signed_at: Some(
 80                RenovateBot::created_at()
 81                    .and_utc()
 82                    .to_rfc3339_opts(SecondsFormat::Millis, true),
 83            ),
 84        }));
 85    }
 86
 87    if ZedZippyBot::is_zed_zippy_bot(&params) {
 88        return Ok(Json(CheckIsContributorResponse {
 89            signed_at: Some(
 90                ZedZippyBot::created_at()
 91                    .and_utc()
 92                    .to_rfc3339_opts(SecondsFormat::Millis, true),
 93            ),
 94        }));
 95    }
 96
 97    Ok(Json(CheckIsContributorResponse {
 98        signed_at: app
 99            .db
100            .get_contributor_sign_timestamp(&params)
101            .await?
102            .map(|ts| ts.and_utc().to_rfc3339_opts(SecondsFormat::Millis, true)),
103    }))
104}
105
106/// The Copilot bot GitHub user (`copilot-swe-agent[bot]`).
107///
108/// https://api.github.com/users/copilot-swe-agent[bot]
109struct CopilotSweAgentBot;
110
111impl CopilotSweAgentBot {
112    const LOGIN: &'static str = "copilot-swe-agent[bot]";
113    const USER_ID: i32 = 198982749;
114    /// The alias of the GitHub copilot user. Although https://api.github.com/users/copilot
115    /// yields a 404, GitHub still refers to the copilot bot user as @Copilot in some cases.
116    const NAME_ALIAS: &'static str = "Copilot";
117
118    /// Returns the `created_at` timestamp for the Dependabot bot user.
119    fn created_at() -> &'static NaiveDateTime {
120        static CREATED_AT: OnceLock<NaiveDateTime> = OnceLock::new();
121        CREATED_AT.get_or_init(|| {
122            chrono::DateTime::parse_from_rfc3339("2025-02-12T20:26:08Z")
123                .expect("failed to parse 'created_at' for 'copilot-swe-agent[bot]'")
124                .naive_utc()
125        })
126    }
127
128    /// Returns whether the given contributor selector corresponds to the Copilot bot user.
129    fn is_copilot_bot(contributor: &ContributorSelector) -> bool {
130        match contributor {
131            ContributorSelector::GitHubLogin { github_login } => {
132                github_login == Self::LOGIN || github_login == Self::NAME_ALIAS
133            }
134            ContributorSelector::GitHubUserId { github_user_id } => {
135                github_user_id == &Self::USER_ID
136            }
137        }
138    }
139}
140
141/// The Dependabot bot GitHub user (`dependabot[bot]`).
142///
143/// https://api.github.com/users/dependabot[bot]
144struct Dependabot;
145
146impl Dependabot {
147    const LOGIN: &'static str = "dependabot[bot]";
148    const USER_ID: i32 = 49699333;
149
150    /// Returns the `created_at` timestamp for the Dependabot bot user.
151    fn created_at() -> &'static NaiveDateTime {
152        static CREATED_AT: OnceLock<NaiveDateTime> = OnceLock::new();
153        CREATED_AT.get_or_init(|| {
154            chrono::DateTime::parse_from_rfc3339("2019-04-16T22:34:25Z")
155                .expect("failed to parse 'created_at' for 'dependabot[bot]'")
156                .naive_utc()
157        })
158    }
159
160    /// Returns whether the given contributor selector corresponds to the Dependabot bot user.
161    fn is_dependabot(contributor: &ContributorSelector) -> bool {
162        match contributor {
163            ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN,
164            ContributorSelector::GitHubUserId { github_user_id } => {
165                github_user_id == &Self::USER_ID
166            }
167        }
168    }
169}
170
171/// The Renovate bot GitHub user (`renovate[bot]`).
172///
173/// https://api.github.com/users/renovate[bot]
174struct RenovateBot;
175
176impl RenovateBot {
177    const LOGIN: &'static str = "renovate[bot]";
178    const USER_ID: i32 = 29139614;
179
180    /// Returns the `created_at` timestamp for the Renovate bot user.
181    fn created_at() -> &'static NaiveDateTime {
182        static CREATED_AT: OnceLock<NaiveDateTime> = OnceLock::new();
183        CREATED_AT.get_or_init(|| {
184            chrono::DateTime::parse_from_rfc3339("2017-06-02T07:04:12Z")
185                .expect("failed to parse 'created_at' for 'renovate[bot]'")
186                .naive_utc()
187        })
188    }
189
190    /// Returns whether the given contributor selector corresponds to the Renovate bot user.
191    fn is_renovate_bot(contributor: &ContributorSelector) -> bool {
192        match contributor {
193            ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN,
194            ContributorSelector::GitHubUserId { github_user_id } => {
195                github_user_id == &Self::USER_ID
196            }
197        }
198    }
199}
200
201/// The Zed Zippy bot GitHub user (`zed-zippy[bot]`).
202///
203/// https://api.github.com/users/zed-zippy[bot]
204struct ZedZippyBot;
205
206impl ZedZippyBot {
207    const LOGIN: &'static str = "zed-zippy[bot]";
208    const USER_ID: i32 = 234243425;
209
210    /// Returns the `created_at` timestamp for the Zed Zippy bot user.
211    fn created_at() -> &'static NaiveDateTime {
212        static CREATED_AT: OnceLock<NaiveDateTime> = OnceLock::new();
213        CREATED_AT.get_or_init(|| {
214            chrono::DateTime::parse_from_rfc3339("2025-09-24T17:00:11Z")
215                .expect("failed to parse 'created_at' for 'zed-zippy[bot]'")
216                .naive_utc()
217        })
218    }
219
220    /// Returns whether the given contributor selector corresponds to the Zed Zippy bot user.
221    fn is_zed_zippy_bot(contributor: &ContributorSelector) -> bool {
222        match contributor {
223            ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN,
224            ContributorSelector::GitHubUserId { github_user_id } => {
225                github_user_id == &Self::USER_ID
226            }
227        }
228    }
229}
230
231#[derive(Debug, Deserialize)]
232struct AddContributorBody {
233    github_user_id: i32,
234    github_login: String,
235    github_email: Option<String>,
236    github_name: Option<String>,
237    github_user_created_at: chrono::DateTime<chrono::Utc>,
238}
239
240async fn add_contributor(
241    Extension(app): Extension<Arc<AppState>>,
242    extract::Json(params): extract::Json<AddContributorBody>,
243) -> Result<()> {
244    let initial_channel_id = app.config.auto_join_channel_id;
245    app.db
246        .add_contributor(
247            &params.github_login,
248            params.github_user_id,
249            params.github_email.as_deref(),
250            params.github_name.as_deref(),
251            params.github_user_created_at,
252            initial_channel_id,
253        )
254        .await
255}