Add REST APIs for getting and adding contributors

Max Brunsfeld and Mikayla created

Co-authored-by: Mikayla <mikayla@zed.dev>

Change summary

crates/collab/migrations.sqlite/20221109000000_test_schema.sql |  6 
crates/collab/migrations/20240122174606_add_contributors.sql   |  5 
crates/collab/src/api.rs                                       | 24 
crates/collab/src/db/queries.rs                                |  1 
crates/collab/src/db/queries/contributors.rs                   | 50 ++
crates/collab/src/db/queries/users.rs                          | 90 ++-
crates/collab/src/db/tables.rs                                 |  1 
crates/collab/src/db/tables/contributor.rs                     | 30 +
crates/collab/src/db/tables/user.rs                            |  2 
crates/collab/src/db/tests.rs                                  |  1 
crates/collab/src/db/tests/contributor_tests.rs                | 37 +
crates/collab/src/db/tests/db_tests.rs                         | 15 
12 files changed, 206 insertions(+), 56 deletions(-)

Detailed changes

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -344,3 +344,9 @@ CREATE INDEX
     "index_notifications_on_recipient_id_is_read_kind_entity_id"
     ON "notifications"
     ("recipient_id", "is_read", "kind", "entity_id");
+
+CREATE TABLE contributors (
+    user_id INTEGER REFERENCES users(id),
+    signed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (user_id)
+);

crates/collab/src/api.rs 🔗

@@ -25,6 +25,7 @@ pub fn routes(rpc_server: Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body
         .route("/users/:id/access_tokens", post(create_access_token))
         .route("/panic", post(trace_panic))
         .route("/rpc_server_snapshot", get(get_rpc_server_snapshot))
+        .route("/contributors", get(get_contributors).post(add_contributor))
         .layer(
             ServiceBuilder::new()
                 .layer(Extension(state))
@@ -66,7 +67,7 @@ pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> impl IntoR
 
 #[derive(Debug, Deserialize)]
 struct AuthenticatedUserParams {
-    github_user_id: Option<i32>,
+    github_user_id: i32,
     github_login: String,
     github_email: Option<String>,
 }
@@ -88,8 +89,7 @@ async fn get_authenticated_user(
             params.github_user_id,
             params.github_email.as_deref(),
         )
-        .await?
-        .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "user not found".into()))?;
+        .await?;
     let metrics_id = app.db.get_user_metrics_id(user.id).await?;
     return Ok(Json(AuthenticatedUserResponse { user, metrics_id }));
 }
@@ -133,6 +133,24 @@ async fn get_rpc_server_snapshot(
     Ok(ErasedJson::pretty(rpc_server.snapshot().await))
 }
 
+async fn get_contributors(Extension(app): Extension<Arc<AppState>>) -> Result<Json<Vec<String>>> {
+    Ok(Json(app.db.get_contributors().await?))
+}
+
+async fn add_contributor(
+    Json(params): Json<AuthenticatedUserParams>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<()> {
+    Ok(app
+        .db
+        .add_contributor(
+            &params.github_login,
+            params.github_user_id,
+            params.github_email.as_deref(),
+        )
+        .await?)
+}
+
 #[derive(Deserialize)]
 struct CreateAccessTokenQueryParams {
     public_key: String,

crates/collab/src/db/queries.rs 🔗

@@ -4,6 +4,7 @@ pub mod access_tokens;
 pub mod buffers;
 pub mod channels;
 pub mod contacts;
+pub mod contributors;
 pub mod messages;
 pub mod notifications;
 pub mod projects;

crates/collab/src/db/queries/contributors.rs 🔗

@@ -0,0 +1,50 @@
+use super::*;
+
+impl Database {
+    /// Retrieves the GitHub logins of all users who have signed the CLA.
+    pub async fn get_contributors(&self) -> Result<Vec<String>> {
+        self.transaction(|tx| async move {
+            #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+            enum QueryGithubLogin {
+                GithubLogin,
+            }
+
+            Ok(contributor::Entity::find()
+                .inner_join(user::Entity)
+                .order_by_asc(contributor::Column::SignedAt)
+                .select_only()
+                .column(user::Column::GithubLogin)
+                .into_values::<_, QueryGithubLogin>()
+                .all(&*tx)
+                .await?)
+        })
+        .await
+    }
+
+    /// Records that a given user has signed the CLA.
+    pub async fn add_contributor(
+        &self,
+        github_login: &str,
+        github_user_id: i32,
+        github_email: Option<&str>,
+    ) -> Result<()> {
+        self.transaction(|tx| async move {
+            let user = self
+                .get_or_create_user_by_github_account_tx(
+                    github_login,
+                    github_user_id,
+                    github_email,
+                    &*tx,
+                )
+                .await?;
+            contributor::ActiveModel {
+                user_id: ActiveValue::Set(user.id),
+                signed_at: ActiveValue::NotSet,
+            }
+            .insert(&*tx)
+            .await?;
+            Ok(())
+        })
+        .await
+    }
+}

crates/collab/src/db/queries/users.rs 🔗

@@ -72,53 +72,61 @@ impl Database {
     pub async fn get_or_create_user_by_github_account(
         &self,
         github_login: &str,
-        github_user_id: Option<i32>,
+        github_user_id: i32,
         github_email: Option<&str>,
-    ) -> Result<Option<User>> {
+    ) -> Result<User> {
         self.transaction(|tx| async move {
-            let tx = &*tx;
-            if let Some(github_user_id) = github_user_id {
-                if let Some(user_by_github_user_id) = user::Entity::find()
-                    .filter(user::Column::GithubUserId.eq(github_user_id))
-                    .one(tx)
-                    .await?
-                {
-                    let mut user_by_github_user_id = user_by_github_user_id.into_active_model();
-                    user_by_github_user_id.github_login = ActiveValue::set(github_login.into());
-                    Ok(Some(user_by_github_user_id.update(tx).await?))
-                } else if let Some(user_by_github_login) = user::Entity::find()
-                    .filter(user::Column::GithubLogin.eq(github_login))
-                    .one(tx)
-                    .await?
-                {
-                    let mut user_by_github_login = user_by_github_login.into_active_model();
-                    user_by_github_login.github_user_id = ActiveValue::set(Some(github_user_id));
-                    Ok(Some(user_by_github_login.update(tx).await?))
-                } else {
-                    let user = user::Entity::insert(user::ActiveModel {
-                        email_address: ActiveValue::set(github_email.map(|email| email.into())),
-                        github_login: ActiveValue::set(github_login.into()),
-                        github_user_id: ActiveValue::set(Some(github_user_id)),
-                        admin: ActiveValue::set(false),
-                        invite_count: ActiveValue::set(0),
-                        invite_code: ActiveValue::set(None),
-                        metrics_id: ActiveValue::set(Uuid::new_v4()),
-                        ..Default::default()
-                    })
-                    .exec_with_returning(&*tx)
-                    .await?;
-                    Ok(Some(user))
-                }
-            } else {
-                Ok(user::Entity::find()
-                    .filter(user::Column::GithubLogin.eq(github_login))
-                    .one(tx)
-                    .await?)
-            }
+            self.get_or_create_user_by_github_account_tx(
+                github_login,
+                github_user_id,
+                github_email,
+                &*tx,
+            )
+            .await
         })
         .await
     }
 
+    pub async fn get_or_create_user_by_github_account_tx(
+        &self,
+        github_login: &str,
+        github_user_id: i32,
+        github_email: Option<&str>,
+        tx: &DatabaseTransaction,
+    ) -> Result<User> {
+        if let Some(user_by_github_user_id) = user::Entity::find()
+            .filter(user::Column::GithubUserId.eq(github_user_id))
+            .one(tx)
+            .await?
+        {
+            let mut user_by_github_user_id = user_by_github_user_id.into_active_model();
+            user_by_github_user_id.github_login = ActiveValue::set(github_login.into());
+            Ok(user_by_github_user_id.update(tx).await?)
+        } else if let Some(user_by_github_login) = user::Entity::find()
+            .filter(user::Column::GithubLogin.eq(github_login))
+            .one(tx)
+            .await?
+        {
+            let mut user_by_github_login = user_by_github_login.into_active_model();
+            user_by_github_login.github_user_id = ActiveValue::set(Some(github_user_id));
+            Ok(user_by_github_login.update(tx).await?)
+        } else {
+            let user = user::Entity::insert(user::ActiveModel {
+                email_address: ActiveValue::set(github_email.map(|email| email.into())),
+                github_login: ActiveValue::set(github_login.into()),
+                github_user_id: ActiveValue::set(Some(github_user_id)),
+                admin: ActiveValue::set(false),
+                invite_count: ActiveValue::set(0),
+                invite_code: ActiveValue::set(None),
+                metrics_id: ActiveValue::set(Uuid::new_v4()),
+                ..Default::default()
+            })
+            .exec_with_returning(&*tx)
+            .await?;
+            Ok(user)
+        }
+    }
+
     /// get_all_users returns the next page of users. To get more call again with
     /// the same limit and the page incremented by 1.
     pub async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<User>> {

crates/collab/src/db/tables.rs 🔗

@@ -9,6 +9,7 @@ pub mod channel_member;
 pub mod channel_message;
 pub mod channel_message_mention;
 pub mod contact;
+pub mod contributor;
 pub mod feature_flag;
 pub mod follower;
 pub mod language_server;

crates/collab/src/db/tables/contributor.rs 🔗

@@ -0,0 +1,30 @@
+use crate::db::UserId;
+use sea_orm::entity::prelude::*;
+use serde::Serialize;
+
+/// A user who has signed the CLA.
+#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
+#[sea_orm(table_name = "contributors")]
+pub struct Model {
+    #[sea_orm(primary_key)]
+    pub user_id: UserId,
+    pub signed_at: DateTime,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+    #[sea_orm(
+        belongs_to = "super::user::Entity",
+        from = "Column::UserId",
+        to = "super::user::Column::Id"
+    )]
+    User,
+}
+
+impl ActiveModelBehavior for ActiveModel {}
+
+impl Related<super::user::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::User.def()
+    }
+}

crates/collab/src/db/tables/user.rs 🔗

@@ -31,6 +31,8 @@ pub enum Relation {
     ChannelMemberships,
     #[sea_orm(has_many = "super::user_feature::Entity")]
     UserFeatures,
+    #[sea_orm(has_one = "super::contributor::Entity")]
+    Contributor,
 }
 
 impl Related<super::access_token::Entity> for Entity {

crates/collab/src/db/tests.rs 🔗

@@ -1,5 +1,6 @@
 mod buffer_tests;
 mod channel_tests;
+mod contributor_tests;
 mod db_tests;
 mod feature_flag_tests;
 mod message_tests;

crates/collab/src/db/tests/contributor_tests.rs 🔗

@@ -0,0 +1,37 @@
+use super::Database;
+use crate::{db::NewUserParams, test_both_dbs};
+use std::sync::Arc;
+
+test_both_dbs!(
+    test_contributors,
+    test_contributors_postgres,
+    test_contributors_sqlite
+);
+
+async fn test_contributors(db: &Arc<Database>) {
+    db.create_user(
+        &format!("user1@example.com"),
+        false,
+        NewUserParams {
+            github_login: format!("user1"),
+            github_user_id: 1,
+        },
+    )
+    .await
+    .unwrap()
+    .user_id;
+
+    assert_eq!(db.get_contributors().await.unwrap(), Vec::<String>::new());
+
+    db.add_contributor("user1", 1, None).await.unwrap();
+    assert_eq!(
+        db.get_contributors().await.unwrap(),
+        vec!["user1".to_string()]
+    );
+
+    db.add_contributor("user2", 2, None).await.unwrap();
+    assert_eq!(
+        db.get_contributors().await.unwrap(),
+        vec!["user1".to_string(), "user2".to_string()]
+    );
+}

crates/collab/src/db/tests/db_tests.rs 🔗

@@ -106,33 +106,24 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
         .user_id;
 
     let user = db
-        .get_or_create_user_by_github_account("login1", None, None)
+        .get_or_create_user_by_github_account("login1", 1, None)
         .await
-        .unwrap()
         .unwrap();
     assert_eq!(user.id, user_id1);
     assert_eq!(&user.github_login, "login1");
     assert_eq!(user.github_user_id, Some(101));
 
-    assert!(db
-        .get_or_create_user_by_github_account("non-existent-login", None, None)
-        .await
-        .unwrap()
-        .is_none());
-
     let user = db
-        .get_or_create_user_by_github_account("the-new-login2", Some(102), None)
+        .get_or_create_user_by_github_account("the-new-login2", 102, None)
         .await
-        .unwrap()
         .unwrap();
     assert_eq!(user.id, user_id2);
     assert_eq!(&user.github_login, "the-new-login2");
     assert_eq!(user.github_user_id, Some(102));
 
     let user = db
-        .get_or_create_user_by_github_account("login3", Some(103), Some("user3@example.com"))
+        .get_or_create_user_by_github_account("login3", 103, Some("user3@example.com"))
         .await
-        .unwrap()
         .unwrap();
     assert_eq!(&user.github_login, "login3");
     assert_eq!(user.github_user_id, Some(103));