diff --git a/.env.sample b/.env.sample index 64ee9f9..d44fe94 100644 --- a/.env.sample +++ b/.env.sample @@ -53,6 +53,8 @@ BOT_SERVER_BIND_PORT=8008 BOT_SERVER_DISABLE_WEBHOOK_SIGNATURE= # Enable welcome comments BOT_SERVER_ENABLE_WELCOME_COMMENTS= +# Admin private key +BOT_SERVER_ADMIN_PRIVATE_KEY= # Tenor API key BOT_TENOR_API_KEY= # Debug mode diff --git a/Cargo.lock b/Cargo.lock index 13ccca1..6079c3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2179,6 +2179,10 @@ dependencies = [ "tracing", ] +[[package]] +name = "prbot-frontend" +version = "0.0.0" + [[package]] name = "prbot-ghapi-github" version = "0.0.0" @@ -2299,6 +2303,7 @@ dependencies = [ "async-trait", "futures", "lazy_static", + "percent-encoding", "prbot-config", "prbot-core", "prbot-crypto", diff --git a/crates/prbot-config/src/lib.rs b/crates/prbot-config/src/lib.rs index d0fe303..fa570e3 100644 --- a/crates/prbot-config/src/lib.rs +++ b/crates/prbot-config/src/lib.rs @@ -92,8 +92,10 @@ pub struct ServerConfig { pub webhook_secret: String, /// Disable webhook signature verification. pub disable_webhook_signature: bool, - /// Enable welcome coments. + /// Enable welcome comments. pub enable_welcome_comments: bool, + /// Admin private key. + pub admin_private_key: String, } /// Bot configuration. @@ -182,6 +184,7 @@ impl Config { false, ), enable_welcome_comments: env_to_bool("BOT_SERVER_ENABLE_WELCOME_COMMENTS", false), + admin_private_key: env_to_str("BOT_SERVER_ADMIN_PRIVATE_KEY", ""), }, tenor_api_key: env_to_str("BOT_TENOR_API_KEY", ""), test_debug_mode: env_to_bool("BOT_TEST_DEBUG_MODE", false), diff --git a/crates/prbot-crypto/src/lib.rs b/crates/prbot-crypto/src/lib.rs index 9bbad1d..a9e99fa 100644 --- a/crates/prbot-crypto/src/lib.rs +++ b/crates/prbot-crypto/src/lib.rs @@ -10,7 +10,7 @@ pub use rand; pub use self::{ errors::{CryptoError, Result}, - rsa::RsaUtils, + rsa::{PrivateRsaKey, PublicRsaKey, RsaUtils}, sig::Signature, }; diff --git a/crates/prbot-crypto/src/rsa.rs b/crates/prbot-crypto/src/rsa.rs index aa70e7e..fff8230 100644 --- a/crates/prbot-crypto/src/rsa.rs +++ b/crates/prbot-crypto/src/rsa.rs @@ -1,7 +1,10 @@ use std::fmt::Display; use jsonwebtoken::{DecodingKey, EncodingKey}; -use rsa::pkcs1::{EncodeRsaPrivateKey, EncodeRsaPublicKey}; +use rsa::{ + pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey, EncodeRsaPublicKey}, + RsaPrivateKey, +}; use super::{CryptoError, Result}; @@ -15,6 +18,10 @@ pub struct PublicRsaKey(String); pub struct PrivateRsaKey(String); impl PublicRsaKey { + pub fn new(value: String) -> Self { + Self(value) + } + /// Get key as string. pub fn as_str(&self) -> &str { &self.0 @@ -22,10 +29,27 @@ impl PublicRsaKey { } impl PrivateRsaKey { + pub fn new(value: String) -> Self { + Self(value) + } + /// Get key as string. pub fn as_str(&self) -> &str { &self.0 } + + /// Extract public key. + pub fn extract_public_key(&self) -> PublicRsaKey { + let priv_key: RsaPrivateKey = RsaPrivateKey::from_pkcs1_pem(&self.0).unwrap(); + let value = priv_key.to_public_key(); + + PublicRsaKey( + value + .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF) + .unwrap() + .to_string(), + ) + } } impl Display for PublicRsaKey { @@ -43,7 +67,7 @@ impl Display for PrivateRsaKey { impl RsaUtils { /// Generate a RSA key-pair. pub fn generate_rsa_keys() -> (PrivateRsaKey, PublicRsaKey) { - use ::rsa::{RsaPrivateKey, RsaPublicKey}; + use ::rsa::RsaPublicKey; use rand::rngs::OsRng; let mut rng = OsRng; diff --git a/crates/prbot-database-pg/src/lib.rs b/crates/prbot-database-pg/src/lib.rs index ebd9e63..c8c1a4c 100644 --- a/crates/prbot-database-pg/src/lib.rs +++ b/crates/prbot-database-pg/src/lib.rs @@ -21,6 +21,8 @@ where A: Acquire<'a>, ::Target: Migrate, { + info!("Running database migrations..."); + sqlx::migrate!("./migrations") .run(migrator) .await @@ -30,7 +32,7 @@ where } pub async fn establish_pool_connection(config: &Config) -> Result { - info!("Trying to establish connection to database pool"); + info!("Establishing connection to database pool..."); PgPoolOptions::new() .acquire_timeout(Duration::from_secs( diff --git a/crates/prbot-frontend/Cargo.toml b/crates/prbot-frontend/Cargo.toml new file mode 100644 index 0000000..c60df9e --- /dev/null +++ b/crates/prbot-frontend/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "prbot-frontend" +version = "0.0.0" +authors = ["Denis BOURGE "] +edition = "2021" + +[dependencies] diff --git a/crates/prbot-frontend/src/lib.rs b/crates/prbot-frontend/src/lib.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/prbot-frontend/src/lib.rs @@ -0,0 +1 @@ + diff --git a/crates/prbot-server/Cargo.toml b/crates/prbot-server/Cargo.toml index c9787ca..4e646d2 100644 --- a/crates/prbot-server/Cargo.toml +++ b/crates/prbot-server/Cargo.toml @@ -34,6 +34,7 @@ thiserror = { workspace = true } time = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } +percent-encoding = "2.3.1" [dev-dependencies] prbot-database-memory = { path = "../prbot-database-memory" } diff --git a/crates/prbot-server/src/admin/mod.rs b/crates/prbot-server/src/admin/mod.rs new file mode 100644 index 0000000..b9bd261 --- /dev/null +++ b/crates/prbot-server/src/admin/mod.rs @@ -0,0 +1,103 @@ +//! Admin module. + +use actix_web::{web, HttpResponse, Result}; +use actix_web_httpauth::extractors::bearer::BearerAuth; +use percent_encoding::{percent_decode, percent_decode_str}; +use prbot_models::{ExternalAccount, ExternalAccountRight, MergeRule, PullRequest, PullRequestRule, Repository}; +use serde::Serialize; + +use crate::server::AppContext; + +pub mod validator; + +#[derive(Serialize)] +struct ExtendedRepository { + repository: Repository, + pull_requests: Vec, + merge_rules: Vec, + pull_request_rules: Vec +} + +#[derive(Serialize)] +struct ExtendedExternalAccount { + external_account: ExternalAccount, + rights: Vec +} + + +#[tracing::instrument(skip_all)] +pub(crate) async fn repositories_list( + ctx: web::Data, + _auth: BearerAuth, +) -> Result { + let mut output = vec![]; + let repositories = ctx.db_service.repositories_all().await.unwrap(); + + for repository in repositories.into_iter() { + let pull_requests = ctx.db_service.pull_requests_list(&repository.owner, &repository.name).await.unwrap(); + let merge_rules = ctx.db_service.merge_rules_list(&repository.owner, &repository.name).await.unwrap(); + let pull_request_rules = ctx.db_service.pull_request_rules_list(&repository.owner, &repository.name).await.unwrap(); + + output.push(ExtendedRepository { + repository, + pull_requests, + merge_rules, + pull_request_rules + }) + } + + Ok(HttpResponse::Ok().json(&output)) +} + +#[tracing::instrument(skip_all)] +pub(crate) async fn accounts_list( + ctx: web::Data, + _auth: BearerAuth, +) -> Result { + let accounts = ctx.db_service.accounts_all().await.unwrap(); + Ok(HttpResponse::Ok().json(&accounts)) +} + +#[tracing::instrument(skip_all)] +pub(crate) async fn external_accounts_list( + ctx: web::Data, + _auth: BearerAuth, +) -> Result { + let mut output = vec![]; + let external_accounts = ctx.db_service.external_accounts_all().await.unwrap(); + for external_account in external_accounts.into_iter() { + let rights = ctx.db_service.external_account_rights_list(&external_account.username).await.unwrap(); + output.push(ExtendedExternalAccount { + external_account, + rights + }); + } + + Ok(HttpResponse::Ok().json(&output)) +} + +#[tracing::instrument(skip_all)] +pub(crate) async fn pull_request_rules_create( + ctx: web::Data, + rule: web::Json, + _auth: BearerAuth, +) -> Result { + let output = ctx.db_service.pull_request_rules_create(rule.0).await.unwrap(); + Ok(HttpResponse::Ok().json(&output)) +} + +#[tracing::instrument(skip_all)] +pub(crate) async fn pull_request_rules_delete( + ctx: web::Data, + path: web::Path<(u64, String)>, + _auth: BearerAuth, +) -> Result { + let repository_id = &path.0; + let repository = ctx.db_service.repositories_get_from_id_expect(*repository_id).await.unwrap(); + + let rule_name = &path.1; + let rule_name = percent_decode_str(rule_name).decode_utf8_lossy().to_string(); + ctx.db_service.pull_request_rules_delete(&repository.owner, &repository.name, &rule_name).await.unwrap(); + + Ok(HttpResponse::NoContent().finish()) +} diff --git a/crates/prbot-server/src/admin/validator.rs b/crates/prbot-server/src/admin/validator.rs new file mode 100644 index 0000000..4aef81f --- /dev/null +++ b/crates/prbot-server/src/admin/validator.rs @@ -0,0 +1,104 @@ +//! External API validator. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use actix_web::{dev::ServiceRequest, http::StatusCode, web, Error, ResponseError}; +use actix_web_httpauth::extractors::bearer::BearerAuth; +use prbot_config::Config; +use prbot_crypto::{CryptoError, JwtUtils, PrivateRsaKey}; +use prbot_sentry::sentry; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::server::AppContext; + +/// External Jwt claims. +#[derive(Debug, Serialize, Deserialize)] +pub struct AdminJwtClaims { + /// Issued at time + pub iat: u64, + /// Expiration + pub exp: u64, +} + +/// Validation error. +#[derive(Debug, Error)] +pub enum ValidationError { + #[error("Admin disabled")] + AdminDisabled, + #[error("Token error,\n caused by: {}", source)] + TokenError { source: CryptoError }, + #[error("Token expired")] + TokenExpired +} + +impl ValidationError { + pub fn token_error(token: &str, source: CryptoError) -> Self { + sentry::configure_scope(|scope| { + scope.set_extra("Token", token.into()); + }); + + Self::TokenError { source } + } +} + +impl ResponseError for ValidationError { + fn status_code(&self) -> StatusCode { + StatusCode::BAD_REQUEST + } +} + +/// Admin jwt authentication validator. +pub async fn admin_jwt_auth_validator( + req: ServiceRequest, + credentials: BearerAuth, +) -> Result { + admin_jwt_auth_validator_inner(req, credentials) + .await + .map_err(|(err, req)| (err.into(), req)) +} + +async fn admin_jwt_auth_validator_inner( + req: ServiceRequest, + credentials: BearerAuth, +) -> Result { + let ctx = req.app_data::>().unwrap(); + if ctx.config.server.admin_private_key.is_empty() { + return Err((ValidationError::AdminDisabled, req)); + } + + // Validate token + let pubkey = + PrivateRsaKey::new(ctx.config.server.admin_private_key.clone()).extract_public_key(); + let tok = credentials.token(); + let claims: AdminJwtClaims = match JwtUtils::verify_jwt(tok, pubkey.as_str()) { + Ok(claims) => claims, + Err(e) => return Err((ValidationError::token_error(tok, e), req)), + }; + + if now_timestamp() >= claims.exp { + return Err((ValidationError::TokenExpired, req)); + } + + Ok(req) +} + +fn now_timestamp() -> u64 { + let start = SystemTime::now(); + let duration = start.duration_since(UNIX_EPOCH).expect("time collapsed"); + + duration.as_secs() +} + +/// Generate admin token. +pub fn generate_admin_token(config: &Config) -> Result { + let now_ts = now_timestamp(); + let claims = AdminJwtClaims { + // Issued at time + iat: now_ts, + // Expiration in 24h + exp: now_ts + (60 * 60 * 24), + }; + + JwtUtils::create_jwt(&config.server.admin_private_key, &claims) +} diff --git a/crates/prbot-server/src/lib.rs b/crates/prbot-server/src/lib.rs index 88ccb4f..5261f25 100644 --- a/crates/prbot-server/src/lib.rs +++ b/crates/prbot-server/src/lib.rs @@ -3,6 +3,7 @@ #![warn(missing_docs)] #![warn(clippy::all)] +pub mod admin; pub mod constants; mod debug; pub mod errors; diff --git a/crates/prbot-server/src/server.rs b/crates/prbot-server/src/server.rs index 1c11c8b..4c85c87 100644 --- a/crates/prbot-server/src/server.rs +++ b/crates/prbot-server/src/server.rs @@ -20,6 +20,7 @@ use sentry_actix::Sentry; use tracing::info; use crate::{ + admin::{accounts_list, external_accounts_list, pull_request_rules_create, pull_request_rules_delete, repositories_list, validator::admin_jwt_auth_validator}, debug::configure_debug_handlers, external::{status::set_qa_status, validator::jwt_auth_validator}, ghapi::MetricsApiService, @@ -111,6 +112,16 @@ pub fn build_actix_app( .wrap(Cors::permissive()) .route("/set-qa-status", web::post().to(set_qa_status)), ) + .service( + web::scope("/admin") + .wrap(HttpAuthentication::bearer(admin_jwt_auth_validator)) + .wrap(Cors::permissive()) + .route("/accounts/", web::get().to(accounts_list)) + .route("/repositories/", web::get().to(repositories_list)) + .route("/repositories/{repository_id}/pull-request-rules/", web::post().to(pull_request_rules_create)) + .route("/repositories/{repository_id}/pull-request-rules/{rule_name}/", web::delete().to(pull_request_rules_delete)) + .route("/external-accounts/", web::get().to(external_accounts_list)) + ) .service( web::scope("/webhook") .wrap(VerifySignature::new(&context.config)) diff --git a/crates/prbot/src/commands/auth/admin/generate_token.rs b/crates/prbot/src/commands/auth/admin/generate_token.rs new file mode 100644 index 0000000..d453e79 --- /dev/null +++ b/crates/prbot/src/commands/auth/admin/generate_token.rs @@ -0,0 +1,16 @@ +use clap::Parser; +use prbot_server::admin::validator::generate_admin_token; + +use crate::{commands::CommandContext, Result}; + +/// Create admin token +#[derive(Parser)] +pub(crate) struct GenerateTokenCommand; + +impl GenerateTokenCommand { + pub async fn run(self, ctx: CommandContext) -> Result<()> { + let admin_token = generate_admin_token(&ctx.config)?; + writeln!(ctx.writer.write().await, "{}", admin_token)?; + Ok(()) + } +} diff --git a/crates/prbot/src/commands/auth/admin/mod.rs b/crates/prbot/src/commands/auth/admin/mod.rs index e08bf40..6803c09 100644 --- a/crates/prbot/src/commands/auth/admin/mod.rs +++ b/crates/prbot/src/commands/auth/admin/mod.rs @@ -1,13 +1,17 @@ use async_trait::async_trait; use clap::{Parser, Subcommand}; -use self::{add::AuthAdminAddCommand, list::AuthAdminListCommand, remove::AuthAdminRemoveCommand}; +use self::{ + add::AuthAdminAddCommand, generate_token::GenerateTokenCommand, list::AuthAdminListCommand, + remove::AuthAdminRemoveCommand, +}; use crate::{ commands::{Command, CommandContext}, Result, }; mod add; +mod generate_token; mod list; mod remove; @@ -30,6 +34,7 @@ enum AuthAdminSubCommand { Add(AuthAdminAddCommand), List(AuthAdminListCommand), Remove(AuthAdminRemoveCommand), + GenerateToken(GenerateTokenCommand), } #[async_trait] @@ -39,6 +44,7 @@ impl Command for AuthAdminSubCommand { Self::Add(sub) => sub.run(ctx).await, Self::List(sub) => sub.run(ctx).await, Self::Remove(sub) => sub.run(ctx).await, + Self::GenerateToken(sub) => sub.run(ctx).await, } } } diff --git a/crates/prbot/src/commands/utils/pem_to_string.rs b/crates/prbot/src/commands/utils/pem_to_string.rs index 11ff8f3..6bc39ab 100644 --- a/crates/prbot/src/commands/utils/pem_to_string.rs +++ b/crates/prbot/src/commands/utils/pem_to_string.rs @@ -20,7 +20,7 @@ impl Command for PemToStringCommand { writeln!( ctx.writer.write().await, "{}", - content.split('\n').collect::>().join("\\n") + content.lines().collect::>().join("\\n") )?; Ok(()) diff --git a/crates/prbot/src/config_validator.rs b/crates/prbot/src/config_validator.rs index 5c4c6f3..75d83ad 100644 --- a/crates/prbot/src/config_validator.rs +++ b/crates/prbot/src/config_validator.rs @@ -75,6 +75,13 @@ fn validate_env_vars(config: &Config) -> Result<(), ValidationError> { } } + // Check server admin key + if !config.server.admin_private_key.is_empty() + && RsaUtils::parse_encoding_key(&config.api.github.app_private_key).is_err() + { + _invalid_key(&mut error, "BOT_SERVER_ADMIN_PRIVATE_KEY"); + } + if error.is_empty() { Ok(()) } else { diff --git a/frontend/app/.editorconfig b/frontend/app/.editorconfig index 6d052c6..492ddaf 100644 --- a/frontend/app/.editorconfig +++ b/frontend/app/.editorconfig @@ -6,9 +6,9 @@ insert_final_newline = true trim_trailing_whitespace = true charset = utf-8 -[*.{ts,js,json,svelte}] +[*.{ts,js,json,svelte,scss}] indent_style = tab -indent_size = 4 +indent_size = 2 [*.md] trim_trailing_whitespace = false diff --git a/frontend/app/package-lock.json b/frontend/app/package-lock.json index c2864a0..f08ee01 100644 --- a/frontend/app/package-lock.json +++ b/frontend/app/package-lock.json @@ -7,13 +7,20 @@ "": { "name": "app", "version": "0.0.1", + "dependencies": { + "clsx": "^2.1.0", + "iconify-icon": "^2.0.0", + "open-props": "^1.7.0" + }, "devDependencies": { "@biomejs/biome": "1.6.4", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0", + "sass": "^1.74.1", "svelte": "^4.2.7", "svelte-check": "^3.6.0", + "svelte-preprocess-delegate-events": "^0.4.3", "tslib": "^2.4.1", "typescript": "^5.0.0", "vite": "^5.0.3", @@ -556,6 +563,11 @@ "node": ">=12" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==" + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -1222,6 +1234,14 @@ "fsevents": "~2.3.2" } }, + "node_modules/clsx": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", + "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "engines": { + "node": ">=6" + } + }, "node_modules/code-red": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", @@ -1567,6 +1587,23 @@ "node": ">=16.17.0" } }, + "node_modules/iconify-icon": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/iconify-icon/-/iconify-icon-2.0.0.tgz", + "integrity": "sha512-38ArOkxmyD9oDbJBkxaFpE6eZ0K3F9Sk+3x4mWGfjMJaxi3EKrix9Du4iWhgBFT3imKC4FJJE34ur2Rc7Xm+Uw==", + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + } + }, + "node_modules/immutable": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", + "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==", + "dev": true + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -1944,6 +1981,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open-props": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/open-props/-/open-props-1.7.0.tgz", + "integrity": "sha512-exvA+8HSxD5qihtBnaDQ1uSKrZV/hfM4/K6UCY7LMzwiMKwejshuNPVpM7exoe74wluOq1NdWYmoSfFxGW9iyw==" + }, "node_modules/p-limit": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", @@ -2236,6 +2278,23 @@ "rimraf": "^2.5.2" } }, + "node_modules/sass": { + "version": "1.74.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.74.1.tgz", + "integrity": "sha512-w0Z9p/rWZWelb88ISOLyvqTWGmtmu2QJICqDBGyNnfG4OUnPX9BBjjYIXUpXCMOOg5MQWNpqzt876la1fsTvUA==", + "dev": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/set-cookie-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", @@ -2426,6 +2485,18 @@ "svelte": "^3.19.0 || ^4.0.0" } }, + "node_modules/svelte-parse-markup": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/svelte-parse-markup/-/svelte-parse-markup-0.1.2.tgz", + "integrity": "sha512-DycY7DJr7VqofiJ63ut1/NEG92HrWWL56VWITn/cJCu+LlZhMoBkBXT4opUitPEEwbq1nMQbv4vTKUfbOqIW1g==", + "dev": true, + "funding": { + "url": "https://bjornlu.com/sponsor" + }, + "peerDependencies": { + "svelte": "^3.0.0 || ^4.0.0" + } + }, "node_modules/svelte-preprocess": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.3.tgz", @@ -2489,6 +2560,68 @@ } } }, + "node_modules/svelte-preprocess-delegate-events": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/svelte-preprocess-delegate-events/-/svelte-preprocess-delegate-events-0.4.3.tgz", + "integrity": "sha512-bl3ue4YbDYySNAQ5XOhOJxBspq1zeOLmaKGAzhU7eiPON54sN3LL4Sf7K1QdTzPtN6hpzurZoZ/BeCbd5YwRTQ==", + "dev": true, + "dependencies": { + "magic-string": "0.30.5", + "svelte": "4.2.3", + "svelte-parse-markup": "0.1.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "svelte": ">=3 <5" + } + }, + "node_modules/svelte-preprocess-delegate-events/node_modules/axobject-query": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", + "integrity": "sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/svelte-preprocess-delegate-events/node_modules/magic-string": { + "version": "0.30.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", + "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/svelte-preprocess-delegate-events/node_modules/svelte": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.3.tgz", + "integrity": "sha512-sqmG9KC6uUc7fb3ZuWoxXvqk6MI9Uu4ABA1M0fYDgTlFYu1k02xp96u6U9+yJZiVm84m9zge7rrA/BNZdFpOKw==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@jridgewell/sourcemap-codec": "^1.4.15", + "@jridgewell/trace-mapping": "^0.3.18", + "acorn": "^8.9.0", + "aria-query": "^5.3.0", + "axobject-query": "^3.2.1", + "code-red": "^1.0.3", + "css-tree": "^2.3.1", + "estree-walker": "^3.0.3", + "is-reference": "^3.0.1", + "locate-character": "^3.0.0", + "magic-string": "^0.30.4", + "periscopic": "^3.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tiny-glob": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", diff --git a/frontend/app/package.json b/frontend/app/package.json index a115de5..6385eb2 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -17,12 +17,19 @@ "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0", + "sass": "^1.74.1", "svelte": "^4.2.7", "svelte-check": "^3.6.0", + "svelte-preprocess-delegate-events": "^0.4.3", "tslib": "^2.4.1", "typescript": "^5.0.0", "vite": "^5.0.3", "vitest": "^1.2.0" }, - "type": "module" + "type": "module", + "dependencies": { + "clsx": "^2.1.0", + "iconify-icon": "^2.0.0", + "open-props": "^1.7.0" + } } diff --git a/frontend/app/src/lib/api/client.ts b/frontend/app/src/lib/api/client.ts new file mode 100644 index 0000000..ae8505e --- /dev/null +++ b/frontend/app/src/lib/api/client.ts @@ -0,0 +1,104 @@ +import { authToken } from "../store"; +import type { Account, ExtendedExternalAccount, ExtendedRepository, PullRequestRule } from "./types"; + +const URL = "http://localhost:8008"; +let token: string | null = null; + +authToken.subscribe((value) => token = value); + +function buildRoute(path: string): string { + return `${URL}${path}`; +} + +export class ApiClient { + accounts: ApiAccountClient + repositories: ApiRepositoryClient + externalAccounts: ApiExternalAccountClient + + constructor() { + this.accounts = new ApiAccountClient(); + this.repositories = new ApiRepositoryClient(); + this.externalAccounts = new ApiExternalAccountClient(); + } +} + +export class ApiRepositoryClient { + async list(): Promise { + const response = await fetch(buildRoute("/admin/repositories/"), { + method: "GET", + headers: { + "Authorization": `Bearer ${token}` + } + }) + + return await response.json() + } + + withId(repositoryId: number): ApiRepositoryDetailClient { + return new ApiRepositoryDetailClient(repositoryId); + } +} + +export class ApiRepositoryDetailClient { + pullRequestRules: ApiRepositoryPullRequestRuleClient + + constructor(repositoryId: number) { + this.pullRequestRules = new ApiRepositoryPullRequestRuleClient(repositoryId); + } +} + +export class ApiRepositoryPullRequestRuleClient { + repositoryId: number; + + constructor(repositoryId: number) { + this.repositoryId = repositoryId; + } + + async create(rule: PullRequestRule): Promise { + const response = await fetch(buildRoute(`/admin/repositories/${this.repositoryId}/pull-request-rules/`), { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(rule) + }) + + return await response.json() + } + + async delete(rule: PullRequestRule): Promise { + return await fetch(buildRoute(`/admin/repositories/${this.repositoryId}/pull-request-rules/${encodeURI(rule.name)}/`), { + method: "DELETE", + headers: { + "Authorization": `Bearer ${token}` + } + }) + } +} + +export class ApiAccountClient { + async list(): Promise { + const response = await fetch(buildRoute("/admin/accounts/"), { + method: "GET", + headers: { + "Authorization": `Bearer ${token}` + } + }) + + return await response.json() + } +} + +export class ApiExternalAccountClient { + async list(): Promise { + const response = await fetch(buildRoute("/admin/external-accounts/"), { + method: "GET", + headers: { + "Authorization": `Bearer ${token}` + } + }) + + return await response.json() + } +} diff --git a/frontend/app/src/lib/api/types.ts b/frontend/app/src/lib/api/types.ts new file mode 100644 index 0000000..56f18d4 --- /dev/null +++ b/frontend/app/src/lib/api/types.ts @@ -0,0 +1,104 @@ +export interface Repository { + id: number, + owner: string, + name: string, + manual_interaction: boolean, + pr_title_validation_regex: string, + default_strategy: MergeStrategy, + default_needed_reviewers_count: number, + default_automerge: boolean, + default_enable_qa: boolean, + default_enable_checks: boolean +} + +export interface PullRequest { + id: number, + repository_id: number, + number: number, + qa_status: QaStatus, + needed_reviewers_count: number, + status_comment_id: number, + checks_enabled: boolean, + automerge: boolean, + locked: boolean, + strategy_override: MergeStrategy | null +} + +export interface ExtendedRepository { + repository: Repository, + pull_requests: PullRequest[], + merge_rules: MergeRule[], + pull_request_rules: PullRequestRule[] +} + +export interface Account { + username: string, + is_admin: boolean +} + +export interface ExternalAccount { + username: string, + public_key: string, + private_key: string +} + +export interface ExtendedExternalAccount { + external_account: ExternalAccount, + rights: ExternalAccountRight[] +} + +export interface ExternalAccountRight { + username: string, + repository_id: number +} + +export interface MergeRule { + repository_id: number, + base_branch: RuleBranch, + head_branch: RuleBranch, + strategy: MergeStrategy +} + +export enum MergeStrategy { + Merge = "merge", + Squash = "squash", + Rebase = "rebase" +} + +export enum QaStatus { + Waiting = "waiting", + Skipped = "skipped", + Pass = "pass", + Fail = "fail" +} + +export interface RuleBranchWildcard { + kind: "wildcard" +} + +export interface RuleBranchNamed { + kind: "named", + name: string +} + +export type RuleBranch = RuleBranchWildcard | RuleBranchNamed; + +type RuleActionSetAutomerge = { ["set_automerge"]: boolean }; +type RuleActionSetQaEnabled = { ["set_qa_enabled"]: boolean }; +type RuleActionSetChecksEnabled = { ["set_checks_enabled"]: boolean }; +type RuleActionSetNeededReviewers = { ["set_needed_reviewers"]: number }; + +export type RuleAction = RuleActionSetAutomerge | RuleActionSetQaEnabled | RuleActionSetChecksEnabled | RuleActionSetNeededReviewers; + +type RuleConditionBaseBranch = { ["base_branch"]: RuleBranch }; +type RuleConditionHeadBranch = { ["head_branch"]: RuleBranch }; +type RuleConditionAuthor = { ["author"]: string }; + +export type RuleCondition = RuleConditionBaseBranch | RuleConditionHeadBranch | RuleConditionAuthor; + +export interface PullRequestRule { + repository_id: number, + name: string, + conditions: RuleCondition[], + actions: RuleAction[] +} diff --git a/frontend/app/src/lib/components/AccountCard.svelte b/frontend/app/src/lib/components/AccountCard.svelte new file mode 100644 index 0000000..a61f092 --- /dev/null +++ b/frontend/app/src/lib/components/AccountCard.svelte @@ -0,0 +1,24 @@ + + + + + Account: {account.username} + + + + + Is admin + {account.is_admin} + + + diff --git a/frontend/app/src/lib/components/ExternalAccountCard.svelte b/frontend/app/src/lib/components/ExternalAccountCard.svelte new file mode 100644 index 0000000..2020fc0 --- /dev/null +++ b/frontend/app/src/lib/components/ExternalAccountCard.svelte @@ -0,0 +1,38 @@ + + + + + External account: {externalAccount.username} + + + + + Private key + {externalAccount.private_key} + + + Public key + {externalAccount.public_key} + + + Rights + + {#each extendedExternalAccount.rights as right} +
#{right.repository_id}
+ {/each} +
+
+
+
diff --git a/frontend/app/src/lib/components/MergeRuleCard.svelte b/frontend/app/src/lib/components/MergeRuleCard.svelte new file mode 100644 index 0000000..a278144 --- /dev/null +++ b/frontend/app/src/lib/components/MergeRuleCard.svelte @@ -0,0 +1,32 @@ + + + + + Merge rule: {mergeRule.base_branch.toString()} <- {mergeRule.head_branch.toString()} + + + + + Base + {mergeRule.base_branch.toString()} + + + Head + {mergeRule.head_branch.toString()} + + + Strategy + {mergeRule.strategy} + + + diff --git a/frontend/app/src/lib/components/PullRequestCard.svelte b/frontend/app/src/lib/components/PullRequestCard.svelte new file mode 100644 index 0000000..7a34ded --- /dev/null +++ b/frontend/app/src/lib/components/PullRequestCard.svelte @@ -0,0 +1,51 @@ + + + + + Pull request: #{pullRequest.number} + + + + + ID + {pullRequest.id} + + + QA status + {pullRequest.qa_status} + + + Needed reviewers count + {pullRequest.needed_reviewers_count} + + + Status comment ID + {pullRequest.status_comment_id} + + + Checks enabled + {pullRequest.checks_enabled} + + + Automerge enabled + {pullRequest.automerge} + + + Locked + {pullRequest.locked} + + + Strategy override + {pullRequest.strategy_override} + + + diff --git a/frontend/app/src/lib/components/PullRequestRuleCard.svelte b/frontend/app/src/lib/components/PullRequestRuleCard.svelte new file mode 100644 index 0000000..1e6bc5b --- /dev/null +++ b/frontend/app/src/lib/components/PullRequestRuleCard.svelte @@ -0,0 +1,38 @@ + + + + + Pull request rule: {pullRequestRule.name} + + + + + Conditions + {JSON.stringify(pullRequestRule.conditions, null, 2)} + + + Actions + {JSON.stringify(pullRequestRule.actions, null, 2)} + + + + + + + diff --git a/frontend/app/src/lib/components/RepositoryCard.svelte b/frontend/app/src/lib/components/RepositoryCard.svelte new file mode 100644 index 0000000..9c251a1 --- /dev/null +++ b/frontend/app/src/lib/components/RepositoryCard.svelte @@ -0,0 +1,95 @@ + + + + + Repository: {repository.owner}/{repository.name} + + + + + ID + {repository.id} + + + Manual interaction + {repository.manual_interaction} + + + Validation regex for pull request title + "{repository.pr_title_validation_regex}" + + + Default merge strategy to use + {repository.default_strategy} + + + Default needed reviewers count + {repository.default_needed_reviewers_count} + + + Default automerge activation + {repository.default_automerge} + + + Default QA activation + {repository.default_enable_qa} + + + Default checks activation + {repository.default_enable_checks} + + + + + Pull requests + + + {#each extendedRepository.pull_requests as pullRequest} + + {/each} + + + + + Merge rules + + + {#each extendedRepository.merge_rules as mergeRule} + + {/each} + + + + + Pull request rules + + + {#each extendedRepository.pull_request_rules as pullRequestRule} + dispatch("pull-request-rule:delete", e.detail)} /> + {/each} + + + diff --git a/frontend/app/src/lib/components/base/Button.svelte b/frontend/app/src/lib/components/base/Button.svelte new file mode 100644 index 0000000..48e029a --- /dev/null +++ b/frontend/app/src/lib/components/base/Button.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/app/src/lib/components/base/Card.svelte b/frontend/app/src/lib/components/base/Card.svelte new file mode 100644 index 0000000..2c87c17 --- /dev/null +++ b/frontend/app/src/lib/components/base/Card.svelte @@ -0,0 +1,21 @@ + + +
+ {#if $$slots.header} +
+ +
+ {/if} + +
+ +
+ + {#if $$slots.footer} + + {/if} +
diff --git a/frontend/app/src/lib/components/base/CheckBox.svelte b/frontend/app/src/lib/components/base/CheckBox.svelte new file mode 100644 index 0000000..d332b71 --- /dev/null +++ b/frontend/app/src/lib/components/base/CheckBox.svelte @@ -0,0 +1,11 @@ + + +
+ + +
diff --git a/frontend/app/src/lib/components/base/DescriptionList.svelte b/frontend/app/src/lib/components/base/DescriptionList.svelte new file mode 100644 index 0000000..5d1a2d9 --- /dev/null +++ b/frontend/app/src/lib/components/base/DescriptionList.svelte @@ -0,0 +1,7 @@ + + +
+ +
diff --git a/frontend/app/src/lib/components/base/DescriptionListDetails.svelte b/frontend/app/src/lib/components/base/DescriptionListDetails.svelte new file mode 100644 index 0000000..06697c1 --- /dev/null +++ b/frontend/app/src/lib/components/base/DescriptionListDetails.svelte @@ -0,0 +1,7 @@ + + +
+ +
diff --git a/frontend/app/src/lib/components/base/DescriptionListLine.svelte b/frontend/app/src/lib/components/base/DescriptionListLine.svelte new file mode 100644 index 0000000..a195e79 --- /dev/null +++ b/frontend/app/src/lib/components/base/DescriptionListLine.svelte @@ -0,0 +1,7 @@ + + +
+ +
diff --git a/frontend/app/src/lib/components/base/DescriptionListTerm.svelte b/frontend/app/src/lib/components/base/DescriptionListTerm.svelte new file mode 100644 index 0000000..c23e97c --- /dev/null +++ b/frontend/app/src/lib/components/base/DescriptionListTerm.svelte @@ -0,0 +1,7 @@ + + +
+ +
diff --git a/frontend/app/src/lib/components/base/Divider.svelte b/frontend/app/src/lib/components/base/Divider.svelte new file mode 100644 index 0000000..1bc7d69 --- /dev/null +++ b/frontend/app/src/lib/components/base/Divider.svelte @@ -0,0 +1,11 @@ + + +
+
+ +
+
diff --git a/frontend/app/src/lib/components/base/FieldSet.svelte b/frontend/app/src/lib/components/base/FieldSet.svelte new file mode 100644 index 0000000..3281eb0 --- /dev/null +++ b/frontend/app/src/lib/components/base/FieldSet.svelte @@ -0,0 +1,14 @@ + + +
+ {#if labelText} + + {/if} +
+ +
+
diff --git a/frontend/app/src/lib/components/base/Form.svelte b/frontend/app/src/lib/components/base/Form.svelte new file mode 100644 index 0000000..7c02a6a --- /dev/null +++ b/frontend/app/src/lib/components/base/Form.svelte @@ -0,0 +1,14 @@ + + +
+ {#if labelText} + + {/if} +
+ + +
diff --git a/frontend/app/src/lib/components/base/Header.svelte b/frontend/app/src/lib/components/base/Header.svelte new file mode 100644 index 0000000..614c303 --- /dev/null +++ b/frontend/app/src/lib/components/base/Header.svelte @@ -0,0 +1,7 @@ + + +
+ +
diff --git a/frontend/app/src/lib/components/base/NumberInput.svelte b/frontend/app/src/lib/components/base/NumberInput.svelte new file mode 100644 index 0000000..98d251f --- /dev/null +++ b/frontend/app/src/lib/components/base/NumberInput.svelte @@ -0,0 +1,11 @@ + + +
+ + +
diff --git a/frontend/app/src/lib/components/base/Select.svelte b/frontend/app/src/lib/components/base/Select.svelte new file mode 100644 index 0000000..d478551 --- /dev/null +++ b/frontend/app/src/lib/components/base/Select.svelte @@ -0,0 +1,16 @@ + + +
+ + +
diff --git a/frontend/app/src/lib/components/base/TextArea.svelte b/frontend/app/src/lib/components/base/TextArea.svelte new file mode 100644 index 0000000..58a6b04 --- /dev/null +++ b/frontend/app/src/lib/components/base/TextArea.svelte @@ -0,0 +1,12 @@ + + +
+ + +
diff --git a/frontend/app/src/lib/components/base/TextInput.svelte b/frontend/app/src/lib/components/base/TextInput.svelte new file mode 100644 index 0000000..c45ce60 --- /dev/null +++ b/frontend/app/src/lib/components/base/TextInput.svelte @@ -0,0 +1,11 @@ + + +
+ + +
diff --git a/frontend/app/src/lib/store/index.ts b/frontend/app/src/lib/store/index.ts new file mode 100644 index 0000000..4a97d14 --- /dev/null +++ b/frontend/app/src/lib/store/index.ts @@ -0,0 +1,21 @@ +import { browser } from "$app/environment"; +import { writable } from "svelte/store"; + +function createAuthTokenStore() { + const token = browser && localStorage.getItem("PRBOT_ADMIN_ACCESS_TOKEN") || null; + const { subscribe, set } = writable(token); + + return { + subscribe, + set: (value: string) => { + localStorage.setItem("PRBOT_ADMIN_ACCESS_TOKEN", value); + set(value) + }, + reset: () => { + localStorage.removeItem("PRBOT_ADMIN_ACCESS_TOKEN"); + set(""); + } + } +} + +export const authToken = createAuthTokenStore(); diff --git a/frontend/app/src/lib/styles/components/base/Button.scss b/frontend/app/src/lib/styles/components/base/Button.scss new file mode 100644 index 0000000..ba76964 --- /dev/null +++ b/frontend/app/src/lib/styles/components/base/Button.scss @@ -0,0 +1,3 @@ +.app-button { + padding: var(--size-1); +} diff --git a/frontend/app/src/lib/styles/components/base/Card.scss b/frontend/app/src/lib/styles/components/base/Card.scss new file mode 100644 index 0000000..7bb2208 --- /dev/null +++ b/frontend/app/src/lib/styles/components/base/Card.scss @@ -0,0 +1,20 @@ +.app-card { + margin: var(--size-2); + border-radius: var(--size-1); + border: 1px solid black; + + &__header { + padding: var(--size-2); + border-bottom: 1px solid black; + font-weight: 600; + } + + &__footer { + padding: var(--size-2); + border-top: 1px solid black; + } + + &__content { + padding: var(--size-2); + } +} diff --git a/frontend/app/src/lib/styles/components/base/DescriptionList.scss b/frontend/app/src/lib/styles/components/base/DescriptionList.scss new file mode 100644 index 0000000..794ec21 --- /dev/null +++ b/frontend/app/src/lib/styles/components/base/DescriptionList.scss @@ -0,0 +1,4 @@ +.app-description-list { + padding: 0; + margin: 0; +} diff --git a/frontend/app/src/lib/styles/components/base/DescriptionListDetails.scss b/frontend/app/src/lib/styles/components/base/DescriptionListDetails.scss new file mode 100644 index 0000000..1854cc7 --- /dev/null +++ b/frontend/app/src/lib/styles/components/base/DescriptionListDetails.scss @@ -0,0 +1,6 @@ +.app-description-list-details { + padding: 0; + margin: 0; + + font-family: monospace; +} diff --git a/frontend/app/src/lib/styles/components/base/DescriptionListLine.scss b/frontend/app/src/lib/styles/components/base/DescriptionListLine.scss new file mode 100644 index 0000000..4d70e9a --- /dev/null +++ b/frontend/app/src/lib/styles/components/base/DescriptionListLine.scss @@ -0,0 +1,7 @@ +.app-description-list-line { + display: flex; + flex-direction: row; + gap: var(--size-2); + + align-items: baseline; +} diff --git a/frontend/app/src/lib/styles/components/base/DescriptionListTerm.scss b/frontend/app/src/lib/styles/components/base/DescriptionListTerm.scss new file mode 100644 index 0000000..9518002 --- /dev/null +++ b/frontend/app/src/lib/styles/components/base/DescriptionListTerm.scss @@ -0,0 +1,10 @@ +.app-description-list-term { + padding: 0; + margin: 0; + + font-weight: 400; + + &::after { + content: ":"; + } +} diff --git a/frontend/app/src/lib/styles/components/base/Divider.scss b/frontend/app/src/lib/styles/components/base/Divider.scss new file mode 100644 index 0000000..bf752a3 --- /dev/null +++ b/frontend/app/src/lib/styles/components/base/Divider.scss @@ -0,0 +1,37 @@ +.app-divider { + margin: var(--size-4) 0; + + &__divider { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--size-2); + + &::before { + width: 4rem; + height: 8px; + content: " "; + display: inline-block; + background-color: white; + border-radius: 2px 0 0 2px; + } + + &::after { + flex-grow: 1; + height: 8px; + content: " "; + display: inline-block; + background-color: white; + border-radius: 0 2px 2px 0; + } + } + + &--header { + font-weight: 600; + font-size: var(--font-size-2); + } + + &--no-header { + + } +} diff --git a/frontend/app/src/lib/styles/components/base/Header.scss b/frontend/app/src/lib/styles/components/base/Header.scss new file mode 100644 index 0000000..3b06d61 --- /dev/null +++ b/frontend/app/src/lib/styles/components/base/Header.scss @@ -0,0 +1,5 @@ +.app-header { + padding: var(--size-1); + font-size: var(--font-size-2); + font-weight: 600; +} diff --git a/frontend/app/src/lib/styles/components/index.scss b/frontend/app/src/lib/styles/components/index.scss new file mode 100644 index 0000000..4dc3c60 --- /dev/null +++ b/frontend/app/src/lib/styles/components/index.scss @@ -0,0 +1,8 @@ +@import "./base/Button.scss"; +@import "./base/Card.scss"; +@import "./base/DescriptionList.scss"; +@import "./base/DescriptionListTerm.scss"; +@import "./base/DescriptionListLine.scss"; +@import "./base/DescriptionListDetails.scss"; +@import "./base/Divider.scss"; +@import "./base/Header.scss"; diff --git a/frontend/app/src/lib/styles/fonts.scss b/frontend/app/src/lib/styles/fonts.scss new file mode 100644 index 0000000..b0f0c9c --- /dev/null +++ b/frontend/app/src/lib/styles/fonts.scss @@ -0,0 +1,8 @@ +@import url('https://fonts.googleapis.com/css2?family=Raleway:ital,wght@0,100..900;1,100..900&display=swap'); + +@mixin use-raleway { + font-family: "Raleway", sans-serif; + font-optical-sizing: auto; + font-weight: 400; + font-style: normal; +} diff --git a/frontend/app/src/lib/styles/global.scss b/frontend/app/src/lib/styles/global.scss new file mode 100644 index 0000000..733de26 --- /dev/null +++ b/frontend/app/src/lib/styles/global.scss @@ -0,0 +1,18 @@ +html, body { + padding: 0; + margin: 0; + + width: 100%; + height: 100%; +} + +* { + box-sizing: border-box; +} + +body { + @include use-raleway; + + background-color: rgb(40, 40, 40); + color: rgb(240, 240, 240); +} diff --git a/frontend/app/src/lib/styles/index.scss b/frontend/app/src/lib/styles/index.scss new file mode 100644 index 0000000..5413400 --- /dev/null +++ b/frontend/app/src/lib/styles/index.scss @@ -0,0 +1,8 @@ +@import "open-props/open-props.min.css"; + +@import "./fonts.scss"; +@import "./global.scss"; + +@import "./components/index.scss"; +@import "./layouts/index.scss"; +@import "./pages/index.scss"; diff --git a/frontend/app/src/lib/styles/layouts/base.scss b/frontend/app/src/lib/styles/layouts/base.scss new file mode 100644 index 0000000..b1d83ee --- /dev/null +++ b/frontend/app/src/lib/styles/layouts/base.scss @@ -0,0 +1,41 @@ +.app-layout { + display: flex; + flex-direction: column; + + width: 100%; + height: 100%; + + &__header { + display: flex; + flex-direction: row; + + background-color: black; + + position: fixed; + width: 100%; + + padding: var(--size-1); + border: 1px solid black; + + &__title { + font-weight: bold; + margin-right: var(--size-2); + } + + &__actions { + display: flex; + flex-direction: row; + gap: var(--size-2); + + &__action { + a { + color: inherit; + } + } + } + } + + &__content { + margin-top: 32px; + } +} diff --git a/frontend/app/src/lib/styles/layouts/index.scss b/frontend/app/src/lib/styles/layouts/index.scss new file mode 100644 index 0000000..d865f8b --- /dev/null +++ b/frontend/app/src/lib/styles/layouts/index.scss @@ -0,0 +1 @@ +@import "./base.scss"; diff --git a/frontend/app/src/lib/styles/pages/index.scss b/frontend/app/src/lib/styles/pages/index.scss new file mode 100644 index 0000000..fb832e8 --- /dev/null +++ b/frontend/app/src/lib/styles/pages/index.scss @@ -0,0 +1 @@ +@import "./signin.scss"; diff --git a/frontend/app/src/lib/styles/pages/signin.scss b/frontend/app/src/lib/styles/pages/signin.scss new file mode 100644 index 0000000..994b5bc --- /dev/null +++ b/frontend/app/src/lib/styles/pages/signin.scss @@ -0,0 +1,18 @@ +.app-signin-page { + width: 100%; + height: 100vh; + + display: flex; + flex-direction: column; + + align-items: center; + justify-content: center; + + &__container { + padding: 2rem; + background-color: rgb(39, 39, 39); + + display: flex; + flex-direction: column; + } +} diff --git a/frontend/app/src/lib/styles/variables.scss b/frontend/app/src/lib/styles/variables.scss new file mode 100644 index 0000000..b9f442e --- /dev/null +++ b/frontend/app/src/lib/styles/variables.scss @@ -0,0 +1,2 @@ +@mixin base-variables { +} diff --git a/frontend/app/src/routes/+layout.svelte b/frontend/app/src/routes/+layout.svelte new file mode 100644 index 0000000..46694c0 --- /dev/null +++ b/frontend/app/src/routes/+layout.svelte @@ -0,0 +1,5 @@ + + + diff --git a/frontend/app/src/routes/+page.svelte b/frontend/app/src/routes/+page.svelte index 5982b0a..defcecb 100644 --- a/frontend/app/src/routes/+page.svelte +++ b/frontend/app/src/routes/+page.svelte @@ -1,2 +1,15 @@ -

Welcome to SvelteKit

-

Visit kit.svelte.dev to read the documentation

+ + +Loading... diff --git a/frontend/app/src/routes/app/+layout.svelte b/frontend/app/src/routes/app/+layout.svelte new file mode 100644 index 0000000..d73d66f --- /dev/null +++ b/frontend/app/src/routes/app/+layout.svelte @@ -0,0 +1,20 @@ +
+
+
prbot
+ +
+ +
+ +
+
diff --git a/frontend/app/src/routes/app/+page.svelte b/frontend/app/src/routes/app/+page.svelte new file mode 100644 index 0000000..639037f --- /dev/null +++ b/frontend/app/src/routes/app/+page.svelte @@ -0,0 +1 @@ +Select a category diff --git a/frontend/app/src/routes/app/accounts/+page.svelte b/frontend/app/src/routes/app/accounts/+page.svelte new file mode 100644 index 0000000..eea22d8 --- /dev/null +++ b/frontend/app/src/routes/app/accounts/+page.svelte @@ -0,0 +1,26 @@ + + +
+
Accounts
+ + {#each accounts as account} + + {/each} + + +
diff --git a/frontend/app/src/routes/app/accounts/new/+page.svelte b/frontend/app/src/routes/app/accounts/new/+page.svelte new file mode 100644 index 0000000..e6d3fa9 --- /dev/null +++ b/frontend/app/src/routes/app/accounts/new/+page.svelte @@ -0,0 +1,2 @@ +

Create a new account

+
diff --git a/frontend/app/src/routes/app/external-accounts/+page.svelte b/frontend/app/src/routes/app/external-accounts/+page.svelte new file mode 100644 index 0000000..d9531ab --- /dev/null +++ b/frontend/app/src/routes/app/external-accounts/+page.svelte @@ -0,0 +1,26 @@ + + +
+
External accounts
+ + {#each extendedExternalAccounts as extendedExternalAccount} + + {/each} + + +
diff --git a/frontend/app/src/routes/app/external-accounts/new/+page.svelte b/frontend/app/src/routes/app/external-accounts/new/+page.svelte new file mode 100644 index 0000000..17c04c1 --- /dev/null +++ b/frontend/app/src/routes/app/external-accounts/new/+page.svelte @@ -0,0 +1,2 @@ +

Create a new external account

+
diff --git a/frontend/app/src/routes/app/repositories/+page.svelte b/frontend/app/src/routes/app/repositories/+page.svelte new file mode 100644 index 0000000..c3b31d9 --- /dev/null +++ b/frontend/app/src/routes/app/repositories/+page.svelte @@ -0,0 +1,26 @@ + + +
+
Repositories
+ + {#each repositories as extendedRepository} + { + let client = new ApiClient(); + await client.repositories.withId(extendedRepository.repository.id).pullRequestRules.delete(e.detail); + repositories = await client.repositories.list(); + }} /> + {/each} +
diff --git a/frontend/app/src/routes/app/repositories/[repository_id]/merge-rules/new/+page.svelte b/frontend/app/src/routes/app/repositories/[repository_id]/merge-rules/new/+page.svelte new file mode 100644 index 0000000..56b693c --- /dev/null +++ b/frontend/app/src/routes/app/repositories/[repository_id]/merge-rules/new/+page.svelte @@ -0,0 +1,2 @@ +

Create a new merge rule

+
diff --git a/frontend/app/src/routes/app/repositories/[repository_id]/pull-request-rules/new/+page.svelte b/frontend/app/src/routes/app/repositories/[repository_id]/pull-request-rules/new/+page.svelte new file mode 100644 index 0000000..e6f18c7 --- /dev/null +++ b/frontend/app/src/routes/app/repositories/[repository_id]/pull-request-rules/new/+page.svelte @@ -0,0 +1,160 @@ + + +

Create a new pull request rule

+
+ +
{ + e.preventDefault(); + + let client = new ApiClient(); + await client.repositories.withId(data.repositoryId).pullRequestRules.create({ + repository_id: data.repositoryId, + name, + conditions, + actions + }); + + goto("/app/repositories"); + }} +> +
+ +
+ +
+ {#if conditions.length > 0} + + + {#each conditions as condition} + + + + + + {/each} + +
{Object.keys(condition)[0]}{Object.values(condition)[0]}
+ {/if} + +
+ + + {#if currentActionKind == "set_automerge"} + + {:else if currentActionKind == "set_needed_reviewers"} + + {:else if currentActionKind == "set_qa_enabled" || currentActionKind == "set_checks_enabled"} + + {/if} + + +
+
+ + +
+ + diff --git a/frontend/app/src/routes/app/repositories/[repository_id]/pull-request-rules/new/+page.ts b/frontend/app/src/routes/app/repositories/[repository_id]/pull-request-rules/new/+page.ts new file mode 100644 index 0000000..da3740c --- /dev/null +++ b/frontend/app/src/routes/app/repositories/[repository_id]/pull-request-rules/new/+page.ts @@ -0,0 +1,7 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = ({ params }) => { + return { + repositoryId: parseInt(params.repository_id) + }; +}; diff --git a/frontend/app/src/routes/signin/+layout@.svelte b/frontend/app/src/routes/signin/+layout@.svelte new file mode 100644 index 0000000..4fa864c --- /dev/null +++ b/frontend/app/src/routes/signin/+layout@.svelte @@ -0,0 +1 @@ + diff --git a/frontend/app/src/routes/signin/+page.svelte b/frontend/app/src/routes/signin/+page.svelte new file mode 100644 index 0000000..2da0053 --- /dev/null +++ b/frontend/app/src/routes/signin/+page.svelte @@ -0,0 +1,22 @@ + + +