diff --git a/ee/tabby-db/migrations/0024_github-provided-repos.down.sql b/ee/tabby-db/migrations/0024_github-provided-repos.down.sql new file mode 100644 index 000000000000..92d711019fc3 --- /dev/null +++ b/ee/tabby-db/migrations/0024_github-provided-repos.down.sql @@ -0,0 +1 @@ +DROP TABLE github_provided_repositories; diff --git a/ee/tabby-db/migrations/0024_github-provided-repos.up.sql b/ee/tabby-db/migrations/0024_github-provided-repos.up.sql new file mode 100644 index 000000000000..dc11e6c1f8d0 --- /dev/null +++ b/ee/tabby-db/migrations/0024_github-provided-repos.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE github_provided_repositories( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + github_repository_provider_id INTEGER NOT NULL, + -- vendor_id from https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-a-user + vendor_id TEXT NOT NULL, + name TEXT NOT NULL, + git_url TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT FALSE, + FOREIGN KEY (github_repository_provider_id) REFERENCES github_repository_provider(id) ON DELETE CASCADE +); diff --git a/ee/tabby-db/schema.sqlite b/ee/tabby-db/schema.sqlite index 7424ba93c65c..75e988b6a4d4 100644 Binary files a/ee/tabby-db/schema.sqlite and b/ee/tabby-db/schema.sqlite differ diff --git a/ee/tabby-db/src/github_repository_provider.rs b/ee/tabby-db/src/github_repository_provider.rs index b2741d238650..2f7b9603e91e 100644 --- a/ee/tabby-db/src/github_repository_provider.rs +++ b/ee/tabby-db/src/github_repository_provider.rs @@ -13,6 +13,16 @@ pub struct GithubRepositoryProviderDAO { pub access_token: Option, } +#[derive(FromRow)] +pub struct GithubProvidedRepositoryDAO { + pub id: i64, + pub vendor_id: String, + pub github_repository_provider_id: i64, + pub name: String, + pub git_url: String, + pub active: bool, +} + impl DbConn { pub async fn create_github_provider( &self, @@ -95,4 +105,83 @@ impl DbConn { .await?; Ok(providers) } + + pub async fn create_github_provided_repository( + &self, + github_provider_id: i64, + vendor_id: String, + name: String, + git_url: String, + ) -> Result { + let res = query!("INSERT INTO github_provided_repositories (github_repository_provider_id, vendor_id, name, git_url) VALUES (?, ?, ?, ?)", + github_provider_id, vendor_id, name, git_url).execute(&self.pool).await?; + Ok(res.last_insert_rowid()) + } + + pub async fn delete_github_provided_repository(&self, id: i64) -> Result<()> { + let res = query!("DELETE FROM github_provided_repositories WHERE id = ?", id) + .execute(&self.pool) + .await?; + + if res.rows_affected() != 1 { + return Err(anyhow!("Repository not found")); + } + Ok(()) + } + + pub async fn list_github_provided_repositories( + &self, + provider_ids: Vec, + limit: Option, + skip_id: Option, + backwards: bool, + ) -> Result> { + let provider_ids = provider_ids + .into_iter() + .map(|id| id.to_string()) + .collect::>() + .join(", "); + let repos = query_paged_as!( + GithubProvidedRepositoryDAO, + "github_provided_repositories", + [ + "id", + "vendor_id", + "name", + "git_url", + "active", + "github_repository_provider_id" + ], + limit, + skip_id, + backwards, + (!provider_ids.is_empty()) + .then(|| format!("github_repository_provider_id IN ({provider_ids})")) + ) + .fetch_all(&self.pool) + .await?; + Ok(repos) + } + + pub async fn update_github_provided_repository_active( + &self, + id: i64, + active: bool, + ) -> Result<()> { + let not_active = !active; + let res = query!( + "UPDATE github_provided_repositories SET active = ? WHERE id = ? AND active = ?", + active, + id, + not_active + ) + .execute(&self.pool) + .await?; + + if res.rows_affected() != 1 { + return Err(anyhow!("Repository active status was not changed")); + } + + Ok(()) + } } diff --git a/ee/tabby-db/src/lib.rs b/ee/tabby-db/src/lib.rs index d2cef167b46f..47962670b1f7 100644 --- a/ee/tabby-db/src/lib.rs +++ b/ee/tabby-db/src/lib.rs @@ -5,7 +5,7 @@ use cache::Cache; use cached::TimedSizedCache; use chrono::{DateTime, NaiveDateTime, Utc}; pub use email_setting::EmailSettingDAO; -pub use github_repository_provider::GithubRepositoryProviderDAO; +pub use github_repository_provider::{GithubProvidedRepositoryDAO, GithubRepositoryProviderDAO}; pub use invitations::InvitationDAO; pub use job_runs::JobRunDAO; pub use oauth_credential::OAuthCredentialDAO; diff --git a/ee/tabby-webserver/graphql/schema.graphql b/ee/tabby-webserver/graphql/schema.graphql index d73fd88abe2d..b3f81884c1f7 100644 --- a/ee/tabby-webserver/graphql/schema.graphql +++ b/ee/tabby-webserver/graphql/schema.graphql @@ -36,6 +36,11 @@ enum AuthMethod { LOGIN } +type GithubProvidedRepositoryConnection { + edges: [GithubProvidedRepositoryEdge!]! + pageInfo: PageInfo! +} + type RegisterResponse { accessToken: String! refreshToken: String! @@ -175,6 +180,7 @@ type Mutation { deleteEmailSetting: Boolean! uploadLicense(license: String!): Boolean! resetLicense: Boolean! + updateGithubProvidedRepositoryActive(id: ID!, active: Boolean!): Boolean! } type RepositoryEdge { @@ -192,8 +198,13 @@ type FileEntrySearchResult { indices: [Int!]! } -input NetworkSettingInput { - externalUrl: String! +type GithubProvidedRepository { + id: ID! + vendorId: String! + githubRepositoryProviderId: ID! + name: String! + gitUrl: String! + active: Boolean! } type Query { @@ -204,6 +215,7 @@ type Query { users(after: String, before: String, first: Int, last: Int): UserConnection! invitations(after: String, before: String, first: Int, last: Int): InvitationConnection! githubRepositoryProviders(after: String, before: String, first: Int, last: Int): GithubRepositoryProviderConnection! + githubRepositories(providerIds: [ID!]!, after: String, before: String, first: Int, last: Int): GithubProvidedRepositoryConnection! jobRuns(ids: [ID!], jobs: [String!], after: String, before: String, first: Int, last: Int): JobRunConnection! jobRunStats(jobs: [String!]): JobStats! emailSetting: EmailSetting @@ -221,6 +233,10 @@ type Query { dailyStats(start: DateTimeUtc!, end: DateTimeUtc!, users: [ID!], languages: [Language!]): [CompletionStats!]! } +input NetworkSettingInput { + externalUrl: String! +} + enum Encryption { START_TLS SSL_TLS @@ -328,6 +344,11 @@ type PageInfo { endCursor: String } +type GithubProvidedRepositoryEdge { + node: GithubProvidedRepository! + cursor: String! +} + type Repository { id: ID! name: String! diff --git a/ee/tabby-webserver/src/handler.rs b/ee/tabby-webserver/src/handler.rs index c1c51a44712d..49c2cec1ff26 100644 --- a/ee/tabby-webserver/src/handler.rs +++ b/ee/tabby-webserver/src/handler.rs @@ -144,7 +144,11 @@ impl WebserverHandle { ) .nest( "/integrations/github", - integrations::github::routes(ctx.setting(), ctx.github_repository_provider()), + integrations::github::routes( + ctx.auth(), + ctx.setting(), + ctx.github_repository_provider(), + ), ) .route( "/avatar/:id", diff --git a/ee/tabby-webserver/src/integrations/github.rs b/ee/tabby-webserver/src/integrations/github.rs index af70237253c8..2b2b84e77a68 100644 --- a/ee/tabby-webserver/src/integrations/github.rs +++ b/ee/tabby-webserver/src/integrations/github.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::Result; use axum::{ extract::{Path, Query, State}, + middleware::from_fn_with_state, response::Redirect, routing, Router, }; @@ -12,8 +13,12 @@ use serde::Deserialize; use tracing::error; use url::Url; -use crate::schema::{ - github_repository_provider::GithubRepositoryProviderService, setting::SettingService, +use crate::{ + handler::require_login_middleware, + schema::{ + auth::AuthenticationService, github_repository_provider::GithubRepositoryProviderService, + setting::SettingService, + }, }; #[derive(Debug, Deserialize)] @@ -47,6 +52,7 @@ struct IntegrationState { } pub fn routes( + auth: Arc, settings: Arc, github_repository_provider: Arc, ) -> Router { @@ -57,6 +63,7 @@ pub fn routes( Router::new() .route("/connect/:id", routing::get(connect)) .route("/callback", routing::get(callback)) + .layer(from_fn_with_state(auth, require_login_middleware)) .with_state(state) } diff --git a/ee/tabby-webserver/src/schema/github_repository_provider.rs b/ee/tabby-webserver/src/schema/github_repository_provider.rs index 334c62e23d5c..aee8d1a74a99 100644 --- a/ee/tabby-webserver/src/schema/github_repository_provider.rs +++ b/ee/tabby-webserver/src/schema/github_repository_provider.rs @@ -11,6 +11,8 @@ pub struct GithubRepositoryProvider { pub id: ID, pub display_name: String, pub application_id: String, + #[graphql(skip)] + pub access_token: Option, } impl NodeType for GithubRepositoryProvider { @@ -29,6 +31,33 @@ impl NodeType for GithubRepositoryProvider { } } +#[derive(GraphQLObject, Debug)] +#[graphql(context = Context)] +pub struct GithubProvidedRepository { + pub id: ID, + pub vendor_id: String, + pub github_repository_provider_id: ID, + pub name: String, + pub git_url: String, + pub active: bool, +} + +impl NodeType for GithubProvidedRepository { + type Cursor = String; + + fn cursor(&self) -> Self::Cursor { + self.id.to_string() + } + + fn connection_type_name() -> &'static str { + "GithubProvidedRepositoryConnection" + } + + fn edge_type_name() -> &'static str { + "GithubProvidedRepositoryEdge" + } +} + #[async_trait] pub trait GithubRepositoryProviderService: Send + Sync { async fn get_github_repository_provider(&self, id: ID) -> Result; @@ -46,4 +75,15 @@ pub trait GithubRepositoryProviderService: Send + Sync { first: Option, last: Option, ) -> Result>; + + async fn list_github_provided_repositories_by_provider( + &self, + provider: Vec, + after: Option, + before: Option, + first: Option, + last: Option, + ) -> Result>; + + async fn update_github_provided_repository_active(&self, id: ID, active: bool) -> Result<()>; } diff --git a/ee/tabby-webserver/src/schema/mod.rs b/ee/tabby-webserver/src/schema/mod.rs index a6f658f39398..49fbb3bcbb5b 100644 --- a/ee/tabby-webserver/src/schema/mod.rs +++ b/ee/tabby-webserver/src/schema/mod.rs @@ -37,7 +37,9 @@ use self::{ RequestInvitationInput, RequestPasswordResetEmailInput, UpdateOAuthCredentialInput, }, email::{EmailService, EmailSetting, EmailSettingInput}, - github_repository_provider::{GithubRepositoryProvider, GithubRepositoryProviderService}, + github_repository_provider::{ + GithubProvidedRepository, GithubRepositoryProvider, GithubRepositoryProviderService, + }, job::JobStats, license::{IsLicenseValid, LicenseInfo, LicenseService, LicenseType}, repository::{Repository, RepositoryService}, @@ -254,6 +256,37 @@ impl Query { .await } + async fn github_repositories( + ctx: &Context, + provider_ids: Vec, + after: Option, + before: Option, + first: Option, + last: Option, + ) -> FieldResult> { + check_admin(ctx).await?; + relay::query_async( + after, + before, + first, + last, + |after, before, first, last| async move { + Ok(ctx + .locator + .github_repository_provider() + .list_github_provided_repositories_by_provider( + provider_ids, + after, + before, + first, + last, + ) + .await?) + }, + ) + .await + } + async fn job_runs( ctx: &Context, ids: Option>, @@ -669,6 +702,18 @@ impl Mutation { ctx.locator.license().reset_license().await?; Ok(true) } + + async fn update_github_provided_repository_active( + ctx: &Context, + id: ID, + active: bool, + ) -> Result { + ctx.locator + .github_repository_provider() + .update_github_provided_repository_active(id, active) + .await?; + Ok(true) + } } async fn check_analytic_access(ctx: &Context, users: &[ID]) -> Result<(), CoreError> { diff --git a/ee/tabby-webserver/src/service/dao.rs b/ee/tabby-webserver/src/service/dao.rs index 402e973540d3..aa65cb3278c9 100644 --- a/ee/tabby-webserver/src/service/dao.rs +++ b/ee/tabby-webserver/src/service/dao.rs @@ -2,14 +2,14 @@ use anyhow::anyhow; use hash_ids::HashIds; use lazy_static::lazy_static; use tabby_db::{ - EmailSettingDAO, GithubRepositoryProviderDAO, InvitationDAO, JobRunDAO, OAuthCredentialDAO, - RepositoryDAO, ServerSettingDAO, UserDAO, + EmailSettingDAO, GithubProvidedRepositoryDAO, GithubRepositoryProviderDAO, InvitationDAO, + JobRunDAO, OAuthCredentialDAO, RepositoryDAO, ServerSettingDAO, UserDAO, }; use crate::schema::{ auth::{self, OAuthCredential, OAuthProvider}, email::{AuthMethod, EmailSetting, Encryption}, - github_repository_provider::GithubRepositoryProvider, + github_repository_provider::{GithubProvidedRepository, GithubRepositoryProvider}, job, repository::Repository, setting::{NetworkSetting, SecuritySetting}, @@ -126,6 +126,20 @@ impl From for GithubRepositoryProvider { display_name: value.display_name, application_id: value.application_id, id: value.id.as_id(), + access_token: value.access_token, + } + } +} + +impl From for GithubProvidedRepository { + fn from(value: GithubProvidedRepositoryDAO) -> Self { + Self { + id: value.id.as_id(), + github_repository_provider_id: value.github_repository_provider_id.as_id(), + name: value.name, + git_url: value.git_url, + vendor_id: value.vendor_id, + active: value.active, } } } diff --git a/ee/tabby-webserver/src/service/github_repository_provider.rs b/ee/tabby-webserver/src/service/github_repository_provider.rs index 5f4970b19443..ae94a7d1ca1f 100644 --- a/ee/tabby-webserver/src/service/github_repository_provider.rs +++ b/ee/tabby-webserver/src/service/github_repository_provider.rs @@ -5,7 +5,9 @@ use tabby_db::DbConn; use super::AsRowid; use crate::{ schema::{ - github_repository_provider::{GithubRepositoryProvider, GithubRepositoryProviderService}, + github_repository_provider::{ + GithubProvidedRepository, GithubRepositoryProvider, GithubRepositoryProviderService, + }, Result, }, service::graphql_pagination_to_filter, @@ -59,4 +61,127 @@ impl GithubRepositoryProviderService for GithubRepositoryProviderServiceImpl { .map(GithubRepositoryProvider::from) .collect()) } + + async fn list_github_provided_repositories_by_provider( + &self, + providers: Vec, + after: Option, + before: Option, + first: Option, + last: Option, + ) -> Result> { + let providers = providers + .into_iter() + .map(|i| i.as_rowid()) + .collect::, _>>()?; + let (limit, skip_id, backwards) = graphql_pagination_to_filter(after, before, last, first)?; + let repos = self + .db + .list_github_provided_repositories(providers, limit, skip_id, backwards) + .await?; + + Ok(repos + .into_iter() + .map(GithubProvidedRepository::from) + .collect()) + } + + async fn update_github_provided_repository_active(&self, id: ID, active: bool) -> Result<()> { + self.db + .update_github_provided_repository_active(id.as_rowid()?, active) + .await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::AsID; + + #[tokio::test] + async fn test_github_provided_repositories() { + let db = DbConn::new_in_memory().await.unwrap(); + let service = new_github_repository_provider_service(db.clone()); + + let provider_id1 = db + .create_github_provider( + "test_provider1".into(), + "test_id1".into(), + "test_secret".into(), + ) + .await + .unwrap(); + + let provider_id2 = db + .create_github_provider( + "test_provider2".into(), + "test_id2".into(), + "test_secret".into(), + ) + .await + .unwrap(); + + let repo_id1 = db + .create_github_provided_repository( + provider_id1, + "vendor_id1".into(), + "test_repo1".into(), + "https://github.com/test/test1".into(), + ) + .await + .unwrap(); + + let repo_id2 = db + .create_github_provided_repository( + provider_id2, + "vendor_id2".into(), + "test_repo2".into(), + "https://github.com/test/test2".into(), + ) + .await + .unwrap(); + + // Test listing with no filter on providers + let repos = service + .list_github_provided_repositories_by_provider(vec![], None, None, None, None) + .await + .unwrap(); + + assert_eq!(repos.len(), 2); + assert_eq!(repos[0].name, "test_repo1"); + assert_eq!(repos[1].name, "test_repo2"); + + // Test listing with a filter on providers + let repos = service + .list_github_provided_repositories_by_provider( + vec![provider_id1.as_id()], + None, + None, + None, + None, + ) + .await + .unwrap(); + + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].name, "test_repo1"); + + // Test deletion and toggling active status + db.delete_github_provided_repository(repo_id1) + .await + .unwrap(); + + db.update_github_provided_repository_active(repo_id2, true) + .await + .unwrap(); + + let repos = service + .list_github_provided_repositories_by_provider(vec![], None, None, None, None) + .await + .unwrap(); + + assert_eq!(repos.len(), 1); + assert!(repos[0].active); + } }