Cargo.lock 🔗
@@ -1463,6 +1463,7 @@ dependencies = [
"base64 0.13.1",
"call",
"channel",
+ "chrono",
"clap 3.2.25",
"client",
"clock",
Max Brunsfeld created
Cargo.lock | 1
Cargo.toml | 1
crates/assistant/Cargo.toml | 2
crates/collab/Cargo.toml | 1
crates/collab/migrations.sqlite/20221109000000_test_schema.sql | 6
crates/collab/migrations/20240122174606_add_contributors.sql | 5
crates/collab/src/api.rs | 69 ++
crates/collab/src/bin/seed.rs | 2
crates/collab/src/db.rs | 1
crates/collab/src/db/queries.rs | 1
crates/collab/src/db/queries/contributors.rs | 89 +++
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 | 44 -
18 files changed, 305 insertions(+), 78 deletions(-)
@@ -1463,6 +1463,7 @@ dependencies = [
"base64 0.13.1",
"call",
"channel",
+ "chrono",
"clap 3.2.25",
"client",
"clock",
@@ -93,6 +93,7 @@ resolver = "2"
anyhow = { version = "1.0.57" }
async-trait = { version = "0.1" }
async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
+chrono = { version = "0.4", features = ["serde"] }
ctor = "0.2.6"
derive_more = { version = "0.99.17" }
env_logger = { version = "0.9" }
@@ -30,7 +30,7 @@ workspace = { path = "../workspace" }
uuid.workspace = true
log.workspace = true
anyhow.workspace = true
-chrono = { version = "0.4", features = ["serde"] }
+chrono.workspace = true
futures.workspace = true
indoc.workspace = true
isahc.workspace = true
@@ -27,6 +27,7 @@ axum = { version = "0.5", features = ["json", "headers", "ws"] }
axum-extra = { version = "0.3", features = ["erased-json"] }
base64 = "0.13"
clap = { version = "3.1", features = ["derive"], optional = true }
+chrono.workspace = true
dashmap = "5.4"
envy = "0.4.2"
futures.workspace = true
@@ -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)
+);
@@ -0,0 +1,5 @@
+CREATE TABLE contributors (
+ user_id INTEGER REFERENCES users(id),
+ signed_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (user_id)
+);
@@ -1,6 +1,6 @@
use crate::{
auth,
- db::{User, UserId},
+ db::{ContributorSelector, User, UserId},
rpc, AppState, Error, Result,
};
use anyhow::anyhow;
@@ -14,6 +14,7 @@ use axum::{
Extension, Json, Router,
};
use axum_extra::response::ErasedJson;
+use chrono::SecondsFormat;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tower::ServiceBuilder;
@@ -25,6 +26,8 @@ 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))
+ .route("/contributor", get(check_is_contributor))
.layer(
ServiceBuilder::new()
.layer(Extension(state))
@@ -66,7 +69,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 +91,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 +135,65 @@ 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?))
+}
+
+#[derive(Debug, Deserialize)]
+struct CheckIsContributorParams {
+ github_user_id: Option<i32>,
+ github_login: Option<String>,
+}
+
+impl CheckIsContributorParams {
+ fn as_contributor_selector(self) -> Result<ContributorSelector> {
+ if let Some(github_user_id) = self.github_user_id {
+ return Ok(ContributorSelector::GitHubUserId { github_user_id });
+ }
+
+ if let Some(github_login) = self.github_login {
+ return Ok(ContributorSelector::GitHubLogin { github_login });
+ }
+
+ Err(anyhow!(
+ "must be one of `github_user_id` or `github_login`."
+ ))?
+ }
+}
+
+#[derive(Debug, Serialize)]
+struct CheckIsContributorResponse {
+ signed_at: Option<String>,
+}
+
+async fn check_is_contributor(
+ Extension(app): Extension<Arc<AppState>>,
+ Query(params): Query<CheckIsContributorParams>,
+) -> Result<Json<CheckIsContributorResponse>> {
+ let params = params.as_contributor_selector()?;
+ Ok(Json(CheckIsContributorResponse {
+ signed_at: app
+ .db
+ .get_contributor_sign_timestamp(¶ms)
+ .await?
+ .map(|ts| ts.and_utc().to_rfc3339_opts(SecondsFormat::Millis, true)),
+ }))
+}
+
+async fn add_contributor(
+ Json(params): Json<AuthenticatedUserParams>,
+ Extension(app): Extension<Arc<AppState>>,
+) -> Result<()> {
+ Ok(app
+ .db
+ .add_contributor(
+ ¶ms.github_login,
+ params.github_user_id,
+ params.github_email.as_deref(),
+ )
+ .await?)
+}
+
#[derive(Deserialize)]
struct CreateAccessTokenQueryParams {
public_key: String,
@@ -68,7 +68,7 @@ async fn main() {
user_count += 1;
db.get_or_create_user_by_github_account(
&github_user.login,
- Some(github_user.id),
+ github_user.id,
github_user.email.as_deref(),
)
.await
@@ -44,6 +44,7 @@ use tables::*;
use tokio::sync::{Mutex, OwnedMutexGuard};
pub use ids::*;
+pub use queries::contributors::ContributorSelector;
pub use sea_orm::ConnectOptions;
pub use tables::user::Model as User;
@@ -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;
@@ -0,0 +1,89 @@
+use super::*;
+
+#[derive(Debug)]
+pub enum ContributorSelector {
+ GitHubUserId { github_user_id: i32 },
+ GitHubLogin { github_login: String },
+}
+
+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 get_contributor_sign_timestamp(
+ &self,
+ selector: &ContributorSelector,
+ ) -> Result<Option<DateTime>> {
+ self.transaction(|tx| async move {
+ let condition = match selector {
+ ContributorSelector::GitHubUserId { github_user_id } => {
+ user::Column::GithubUserId.eq(*github_user_id)
+ }
+ ContributorSelector::GitHubLogin { github_login } => {
+ user::Column::GithubLogin.eq(github_login)
+ }
+ };
+
+ let Some(user) = user::Entity::find().filter(condition).one(&*tx).await? else {
+ return Ok(None);
+ };
+ let Some(contributor) = contributor::Entity::find_by_id(user.id).one(&*tx).await?
+ else {
+ return Ok(None);
+ };
+ Ok(Some(contributor.signed_at))
+ })
+ .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::Entity::insert(contributor::ActiveModel {
+ user_id: ActiveValue::Set(user.id),
+ signed_at: ActiveValue::NotSet,
+ })
+ .on_conflict(
+ OnConflict::column(contributor::Column::UserId)
+ .do_nothing()
+ .to_owned(),
+ )
+ .exec_without_returning(&*tx)
+ .await?;
+ Ok(())
+ })
+ .await
+ }
+}
@@ -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>> {
@@ -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;
@@ -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()
+ }
+}
@@ -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 {
@@ -1,5 +1,6 @@
mod buffer_tests;
mod channel_tests;
+mod contributor_tests;
mod db_tests;
mod feature_flag_tests;
mod message_tests;
@@ -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()]
+ );
+}
@@ -80,18 +80,17 @@ test_both_dbs!(
);
async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
- let user_id1 = db
- .create_user(
- "user1@example.com",
- false,
- NewUserParams {
- github_login: "login1".into(),
- github_user_id: 101,
- },
- )
- .await
- .unwrap()
- .user_id;
+ db.create_user(
+ "user1@example.com",
+ false,
+ NewUserParams {
+ github_login: "login1".into(),
+ github_user_id: 101,
+ },
+ )
+ .await
+ .unwrap()
+ .user_id;
let user_id2 = db
.create_user(
"user2@example.com",
@@ -106,33 +105,16 @@ 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("the-new-login2", 102, 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)
- .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));