Compare commits
4 Commits
duckdb-s3-
...
win-1317-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aebb06a1bb | ||
|
|
0a471a77dd | ||
|
|
e680e801b0 | ||
|
|
5537d4d6b3 |
@@ -0,0 +1,4 @@
|
||||
-- Drop workspace forking tables
|
||||
|
||||
DROP TABLE IF EXISTS forked_resource_refs;
|
||||
DROP TABLE IF EXISTS workspace_fork;
|
||||
38
backend/migrations/20250701063840_workspace_forking.up.sql
Normal file
38
backend/migrations/20250701063840_workspace_forking.up.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- Add workspace forking tables
|
||||
|
||||
-- Table to track fork relationships between workspaces
|
||||
CREATE TABLE workspace_fork (
|
||||
fork_workspace_id VARCHAR(63) PRIMARY KEY,
|
||||
parent_workspace_id VARCHAR(63) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by VARCHAR(255) NOT NULL,
|
||||
fork_point TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT fk_fork_workspace FOREIGN KEY (fork_workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_parent_workspace FOREIGN KEY (parent_workspace_id) REFERENCES workspace(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Table to track which resources are references vs clones in forked workspaces
|
||||
CREATE TABLE forked_resource_refs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
fork_workspace_id VARCHAR(63) NOT NULL,
|
||||
resource_type VARCHAR(50) NOT NULL,
|
||||
resource_path VARCHAR(4000) NOT NULL,
|
||||
is_reference BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
parent_resource_id VARCHAR(4000),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT fk_forked_resource_workspace FOREIGN KEY (fork_workspace_id) REFERENCES workspace(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes for efficient queries
|
||||
CREATE INDEX idx_workspace_fork_parent ON workspace_fork(parent_workspace_id);
|
||||
CREATE INDEX idx_forked_resource_refs_workspace ON forked_resource_refs(fork_workspace_id);
|
||||
CREATE INDEX idx_forked_resource_refs_type_path ON forked_resource_refs(fork_workspace_id, resource_type, resource_path);
|
||||
CREATE INDEX idx_forked_resource_refs_is_reference ON forked_resource_refs(fork_workspace_id, is_reference);
|
||||
|
||||
-- Grant permissions to windmill users
|
||||
GRANT ALL ON workspace_fork TO windmill_user;
|
||||
GRANT ALL ON workspace_fork TO windmill_admin;
|
||||
GRANT ALL ON forked_resource_refs TO windmill_user;
|
||||
GRANT ALL ON forked_resource_refs TO windmill_admin;
|
||||
GRANT ALL ON SEQUENCE forked_resource_refs_id_seq TO windmill_user;
|
||||
GRANT ALL ON SEQUENCE forked_resource_refs_id_seq TO windmill_admin;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Drop workspace merge system tables
|
||||
|
||||
DROP TABLE IF EXISTS workspace_merge_approver;
|
||||
DROP TABLE IF EXISTS workspace_resource_timestamp;
|
||||
DROP TABLE IF EXISTS workspace_merge_content;
|
||||
DROP TABLE IF EXISTS workspace_merge_change;
|
||||
DROP TABLE IF EXISTS workspace_merge_request;
|
||||
121
backend/migrations/20250702000001_workspace_merge_system.up.sql
Normal file
121
backend/migrations/20250702000001_workspace_merge_system.up.sql
Normal file
@@ -0,0 +1,121 @@
|
||||
-- Add workspace merge request and conflict detection tables
|
||||
|
||||
-- Table to track merge requests between workspaces
|
||||
CREATE TABLE workspace_merge_request (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
source_workspace_id VARCHAR(63) NOT NULL,
|
||||
target_workspace_id VARCHAR(63) NOT NULL,
|
||||
created_by VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- 'pending', 'approved', 'rejected', 'merged', 'conflicted'
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
merged_at TIMESTAMPTZ,
|
||||
merged_by VARCHAR(255),
|
||||
rejected_at TIMESTAMPTZ,
|
||||
rejected_by VARCHAR(255),
|
||||
rejection_reason TEXT,
|
||||
auto_merge BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
CONSTRAINT fk_merge_source_workspace FOREIGN KEY (source_workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_merge_target_workspace FOREIGN KEY (target_workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
CONSTRAINT chk_merge_status CHECK (status IN ('pending', 'approved', 'rejected', 'merged', 'conflicted'))
|
||||
);
|
||||
|
||||
-- Table to track changes in a merge request
|
||||
CREATE TABLE workspace_merge_change (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
merge_request_id BIGINT NOT NULL,
|
||||
resource_type VARCHAR(50) NOT NULL,
|
||||
resource_path VARCHAR(4000) NOT NULL,
|
||||
change_type VARCHAR(20) NOT NULL, -- 'added', 'modified', 'deleted'
|
||||
source_content_hash VARCHAR(64), -- SHA256 hash of source content
|
||||
target_content_hash VARCHAR(64), -- SHA256 hash of target content
|
||||
has_conflict BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
conflict_reason TEXT,
|
||||
resolved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
resolution_strategy VARCHAR(20), -- 'take_source', 'take_target', 'manual'
|
||||
resolved_by VARCHAR(255),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT fk_merge_change_request FOREIGN KEY (merge_request_id) REFERENCES workspace_merge_request(id) ON DELETE CASCADE,
|
||||
CONSTRAINT chk_change_type CHECK (change_type IN ('added', 'modified', 'deleted')),
|
||||
CONSTRAINT chk_resolution_strategy CHECK (resolution_strategy IS NULL OR resolution_strategy IN ('take_source', 'take_target', 'manual'))
|
||||
);
|
||||
|
||||
-- Table to store content snapshots for merge diff and conflict resolution
|
||||
CREATE TABLE workspace_merge_content (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
merge_change_id BIGINT NOT NULL,
|
||||
content_type VARCHAR(20) NOT NULL, -- 'source', 'target', 'base', 'resolved'
|
||||
content_hash VARCHAR(64) NOT NULL,
|
||||
content_data JSONB, -- Store the actual content for comparison
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT fk_merge_content_change FOREIGN KEY (merge_change_id) REFERENCES workspace_merge_change(id) ON DELETE CASCADE,
|
||||
CONSTRAINT chk_content_type CHECK (content_type IN ('source', 'target', 'base', 'resolved'))
|
||||
);
|
||||
|
||||
-- Table to track workspace modification timestamps for conflict detection
|
||||
CREATE TABLE workspace_resource_timestamp (
|
||||
workspace_id VARCHAR(63) NOT NULL,
|
||||
resource_type VARCHAR(50) NOT NULL,
|
||||
resource_path VARCHAR(4000) NOT NULL,
|
||||
last_modified TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
modified_by VARCHAR(255) NOT NULL,
|
||||
content_hash VARCHAR(64),
|
||||
PRIMARY KEY (workspace_id, resource_type, resource_path),
|
||||
CONSTRAINT fk_resource_timestamp_workspace FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Table to track merge permissions and approvers
|
||||
CREATE TABLE workspace_merge_approver (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
workspace_id VARCHAR(63) NOT NULL,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
can_approve_merges BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
can_auto_merge BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by VARCHAR(255) NOT NULL,
|
||||
CONSTRAINT fk_merge_approver_workspace FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_merge_approver_user UNIQUE (workspace_id, user_email)
|
||||
);
|
||||
|
||||
-- Indexes for efficient queries
|
||||
CREATE INDEX idx_workspace_merge_request_source ON workspace_merge_request(source_workspace_id, status);
|
||||
CREATE INDEX idx_workspace_merge_request_target ON workspace_merge_request(target_workspace_id, status);
|
||||
CREATE INDEX idx_workspace_merge_request_created_at ON workspace_merge_request(created_at DESC);
|
||||
|
||||
CREATE INDEX idx_workspace_merge_change_request ON workspace_merge_change(merge_request_id);
|
||||
CREATE INDEX idx_workspace_merge_change_conflict ON workspace_merge_change(merge_request_id, has_conflict);
|
||||
CREATE INDEX idx_workspace_merge_change_resource ON workspace_merge_change(resource_type, resource_path);
|
||||
|
||||
CREATE INDEX idx_workspace_merge_content_change ON workspace_merge_content(merge_change_id, content_type);
|
||||
CREATE INDEX idx_workspace_merge_content_hash ON workspace_merge_content(content_hash);
|
||||
|
||||
CREATE INDEX idx_workspace_resource_timestamp_modified ON workspace_resource_timestamp(workspace_id, last_modified DESC);
|
||||
CREATE INDEX idx_workspace_resource_timestamp_hash ON workspace_resource_timestamp(content_hash);
|
||||
|
||||
CREATE INDEX idx_workspace_merge_approver_workspace ON workspace_merge_approver(workspace_id);
|
||||
|
||||
-- Grant permissions to windmill users
|
||||
GRANT ALL ON workspace_merge_request TO windmill_user;
|
||||
GRANT ALL ON workspace_merge_request TO windmill_admin;
|
||||
GRANT ALL ON SEQUENCE workspace_merge_request_id_seq TO windmill_user;
|
||||
GRANT ALL ON SEQUENCE workspace_merge_request_id_seq TO windmill_admin;
|
||||
|
||||
GRANT ALL ON workspace_merge_change TO windmill_user;
|
||||
GRANT ALL ON workspace_merge_change TO windmill_admin;
|
||||
GRANT ALL ON SEQUENCE workspace_merge_change_id_seq TO windmill_user;
|
||||
GRANT ALL ON SEQUENCE workspace_merge_change_id_seq TO windmill_admin;
|
||||
|
||||
GRANT ALL ON workspace_merge_content TO windmill_user;
|
||||
GRANT ALL ON workspace_merge_content TO windmill_admin;
|
||||
GRANT ALL ON SEQUENCE workspace_merge_content_id_seq TO windmill_user;
|
||||
GRANT ALL ON SEQUENCE workspace_merge_content_id_seq TO windmill_admin;
|
||||
|
||||
GRANT ALL ON workspace_resource_timestamp TO windmill_user;
|
||||
GRANT ALL ON workspace_resource_timestamp TO windmill_admin;
|
||||
|
||||
GRANT ALL ON workspace_merge_approver TO windmill_user;
|
||||
GRANT ALL ON workspace_merge_approver TO windmill_admin;
|
||||
GRANT ALL ON SEQUENCE workspace_merge_approver_id_seq TO windmill_user;
|
||||
GRANT ALL ON SEQUENCE workspace_merge_approver_id_seq TO windmill_admin;
|
||||
@@ -195,6 +195,8 @@ pub mod workspaces_ee;
|
||||
mod workspaces_export;
|
||||
mod workspaces_extra;
|
||||
mod workspaces_oss;
|
||||
mod workspace_fork;
|
||||
mod workspace_merge;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
mod mcp;
|
||||
|
||||
363
backend/windmill-api/src/workspace_fork.rs
Normal file
363
backend/windmill-api/src/workspace_fork.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* Author: Windmill Labs
|
||||
* Copyright: Windmill Labs, Inc 2025
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
use axum::{extract::{Extension, Path}, routing::{get, post}, Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
use windmill_common::error::{Error, JsonResult, Result};
|
||||
use windmill_common::utils::{rd_string, require_admin};
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize)]
|
||||
pub struct WorkspaceFork {
|
||||
pub fork_workspace_id: String,
|
||||
pub parent_workspace_id: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub created_by: String,
|
||||
pub fork_point: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize)]
|
||||
pub struct ForkedResourceRef {
|
||||
pub id: i64,
|
||||
pub fork_workspace_id: String,
|
||||
pub resource_type: String,
|
||||
pub resource_path: String,
|
||||
pub is_reference: bool,
|
||||
pub parent_resource_id: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CreateForkRequest {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ForkResponse {
|
||||
pub fork_workspace_id: String,
|
||||
pub parent_workspace_id: String,
|
||||
pub name: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Get fork information for a workspace
|
||||
pub async fn get_fork_info(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
) -> Result<Option<WorkspaceFork>> {
|
||||
let fork_info = sqlx::query_as!(
|
||||
WorkspaceFork,
|
||||
"SELECT fork_workspace_id, parent_workspace_id, created_at, created_by, fork_point
|
||||
FROM workspace_fork
|
||||
WHERE fork_workspace_id = $1",
|
||||
workspace_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
Ok(fork_info)
|
||||
}
|
||||
|
||||
/// Get all resource references for a forked workspace
|
||||
pub async fn get_forked_resource_refs(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<ForkedResourceRef>> {
|
||||
let refs = sqlx::query_as!(
|
||||
ForkedResourceRef,
|
||||
"SELECT id, fork_workspace_id, resource_type, resource_path, is_reference, parent_resource_id, created_at
|
||||
FROM forked_resource_refs
|
||||
WHERE fork_workspace_id = $1
|
||||
ORDER BY resource_type, resource_path",
|
||||
workspace_id
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
Ok(refs)
|
||||
}
|
||||
|
||||
/// Create a resource reference in a forked workspace
|
||||
pub async fn create_resource_reference(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
fork_workspace_id: &str,
|
||||
resource_type: &str,
|
||||
resource_path: &str,
|
||||
parent_resource_id: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO forked_resource_refs (fork_workspace_id, resource_type, resource_path, is_reference, parent_resource_id)
|
||||
VALUES ($1, $2, $3, true, $4)",
|
||||
fork_workspace_id,
|
||||
resource_type,
|
||||
resource_path,
|
||||
parent_resource_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark a resource as cloned (no longer a reference)
|
||||
pub async fn mark_resource_as_cloned(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
fork_workspace_id: &str,
|
||||
resource_type: &str,
|
||||
resource_path: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query!(
|
||||
"UPDATE forked_resource_refs
|
||||
SET is_reference = false, parent_resource_id = NULL
|
||||
WHERE fork_workspace_id = $1 AND resource_type = $2 AND resource_path = $3",
|
||||
fork_workspace_id,
|
||||
resource_type,
|
||||
resource_path
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a resource is a reference in a forked workspace
|
||||
pub async fn is_resource_reference(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
resource_type: &str,
|
||||
resource_path: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let result = sqlx::query_scalar!(
|
||||
"SELECT parent_resource_id
|
||||
FROM forked_resource_refs
|
||||
WHERE fork_workspace_id = $1 AND resource_type = $2 AND resource_path = $3 AND is_reference = true",
|
||||
workspace_id,
|
||||
resource_type,
|
||||
resource_path
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
Ok(result.flatten())
|
||||
}
|
||||
|
||||
/// Create a new workspace fork
|
||||
pub async fn create_fork(
|
||||
db: &DB,
|
||||
parent_workspace_id: &str,
|
||||
created_by: &str,
|
||||
fork_name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<ForkResponse> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Generate fork workspace ID
|
||||
let fork_workspace_id = format!("{}-fork-{}", parent_workspace_id, rd_string(6));
|
||||
|
||||
// Create the fork workspace
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace (id, name, owner, deleted, premium)
|
||||
SELECT $1, $2, owner, false, premium FROM workspace WHERE id = $3",
|
||||
fork_workspace_id,
|
||||
fork_name,
|
||||
parent_workspace_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Create fork relationship
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_fork (fork_workspace_id, parent_workspace_id, created_by, fork_point)
|
||||
VALUES ($1, $2, $3, NOW())",
|
||||
fork_workspace_id,
|
||||
parent_workspace_id,
|
||||
created_by
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Copy workspace resources
|
||||
copy_workspace_resources(&mut tx, parent_workspace_id, &fork_workspace_id).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let fork_info = sqlx::query_as!(
|
||||
WorkspaceFork,
|
||||
"SELECT fork_workspace_id, parent_workspace_id, created_at, created_by, fork_point
|
||||
FROM workspace_fork
|
||||
WHERE fork_workspace_id = $1",
|
||||
fork_workspace_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
Ok(ForkResponse {
|
||||
fork_workspace_id: fork_info.fork_workspace_id,
|
||||
parent_workspace_id: fork_info.parent_workspace_id,
|
||||
name: fork_name.to_string(),
|
||||
created_at: fork_info.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Copy all basic resources from parent to fork (creates initial references)
|
||||
pub async fn copy_workspace_resources(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
parent_workspace_id: &str,
|
||||
fork_workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
tracing::info!(
|
||||
"Copying resources from workspace {} to fork {}",
|
||||
parent_workspace_id,
|
||||
fork_workspace_id
|
||||
);
|
||||
|
||||
// Copy core workspace resources by creating references
|
||||
let resource_types = vec![
|
||||
("script", "SELECT path FROM script WHERE workspace_id = $1 AND NOT deleted AND NOT archived"),
|
||||
("flow", "SELECT path FROM flow WHERE workspace_id = $1 AND NOT archived"),
|
||||
("app", "SELECT path FROM app WHERE workspace_id = $1"),
|
||||
("raw_app", "SELECT path FROM raw_app WHERE workspace_id = $1"),
|
||||
("variable", "SELECT path FROM variable WHERE workspace_id = $1"),
|
||||
("resource", "SELECT path FROM resource WHERE workspace_id = $1"),
|
||||
("resource_type", "SELECT name as path FROM resource_type WHERE workspace_id = $1"),
|
||||
("folder", "SELECT name as path FROM folder WHERE workspace_id = $1"),
|
||||
("schedule", "SELECT path FROM schedule WHERE workspace_id = $1"),
|
||||
];
|
||||
|
||||
for (resource_type, query) in resource_types {
|
||||
let paths: Vec<String> = sqlx::query_scalar(query)
|
||||
.bind(parent_workspace_id)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for path in paths {
|
||||
create_resource_reference(
|
||||
tx,
|
||||
fork_workspace_id,
|
||||
resource_type,
|
||||
&path,
|
||||
&format!("{}:{}", parent_workspace_id, path),
|
||||
).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle resource modification in a forked workspace
|
||||
/// This should be called whenever a resource is modified in a fork
|
||||
pub async fn handle_resource_modification(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
workspace_id: &str,
|
||||
resource_type: &str,
|
||||
resource_path: &str,
|
||||
) -> Result<()> {
|
||||
// Check if this is a forked workspace
|
||||
let fork_info = sqlx::query_scalar!(
|
||||
"SELECT parent_workspace_id FROM workspace_fork WHERE fork_workspace_id = $1",
|
||||
workspace_id
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
|
||||
if let Some(_parent_workspace_id) = fork_info {
|
||||
// Check if this resource is currently a reference
|
||||
let is_ref = sqlx::query_scalar!(
|
||||
"SELECT is_reference FROM forked_resource_refs
|
||||
WHERE fork_workspace_id = $1 AND resource_type = $2 AND resource_path = $3",
|
||||
workspace_id,
|
||||
resource_type,
|
||||
resource_path
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_ref {
|
||||
// Mark as cloned since it's being modified
|
||||
mark_resource_as_cloned(tx, workspace_id, resource_type, resource_path).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Resource {}:{} in workspace {} is now cloned due to modification",
|
||||
resource_type,
|
||||
resource_path,
|
||||
workspace_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/create_fork", post(create_fork_handler))
|
||||
.route("/fork_info", get(get_fork_info_handler))
|
||||
.route("/list_forks", get(list_forks_handler))
|
||||
.route("/resource_refs", get(list_resource_refs_handler))
|
||||
}
|
||||
|
||||
async fn create_fork_handler(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
Json(request): Json<CreateForkRequest>,
|
||||
) -> JsonResult<ForkResponse> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let fork = create_fork(
|
||||
&db,
|
||||
&workspace_id,
|
||||
&authed.email,
|
||||
&request.name,
|
||||
request.description.as_deref(),
|
||||
).await?;
|
||||
|
||||
Ok(Json(fork))
|
||||
}
|
||||
|
||||
async fn get_fork_info_handler(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
) -> JsonResult<Option<WorkspaceFork>> {
|
||||
let fork_info = get_fork_info(&db, &workspace_id).await?;
|
||||
Ok(Json(fork_info))
|
||||
}
|
||||
|
||||
async fn list_forks_handler(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
) -> JsonResult<Vec<WorkspaceFork>> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let forks = sqlx::query_as!(
|
||||
WorkspaceFork,
|
||||
"SELECT fork_workspace_id, parent_workspace_id, created_at, created_by, fork_point
|
||||
FROM workspace_fork
|
||||
WHERE parent_workspace_id = $1
|
||||
ORDER BY created_at DESC",
|
||||
workspace_id
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(forks))
|
||||
}
|
||||
|
||||
async fn list_resource_refs_handler(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
) -> JsonResult<Vec<ForkedResourceRef>> {
|
||||
let refs = get_forked_resource_refs(&db, &workspace_id).await?;
|
||||
Ok(Json(refs))
|
||||
}
|
||||
635
backend/windmill-api/src/workspace_merge.rs
Normal file
635
backend/windmill-api/src/workspace_merge.rs
Normal file
@@ -0,0 +1,635 @@
|
||||
/*
|
||||
* Author: Windmill Labs
|
||||
* Copyright: Windmill Labs, Inc 2025
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::error::{Error, JsonResult, Result};
|
||||
use windmill_common::utils::require_admin;
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize)]
|
||||
pub struct WorkspaceMergeRequest {
|
||||
pub id: i64,
|
||||
pub source_workspace_id: String,
|
||||
pub target_workspace_id: String,
|
||||
pub created_by: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub status: String,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub merged_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub merged_by: Option<String>,
|
||||
pub rejected_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub rejected_by: Option<String>,
|
||||
pub rejection_reason: Option<String>,
|
||||
pub auto_merge: bool,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize)]
|
||||
pub struct WorkspaceMergeChange {
|
||||
pub id: i64,
|
||||
pub merge_request_id: i64,
|
||||
pub resource_type: String,
|
||||
pub resource_path: String,
|
||||
pub change_type: String,
|
||||
pub source_content_hash: Option<String>,
|
||||
pub target_content_hash: Option<String>,
|
||||
pub has_conflict: bool,
|
||||
pub conflict_reason: Option<String>,
|
||||
pub resolved: bool,
|
||||
pub resolution_strategy: Option<String>,
|
||||
pub resolved_by: Option<String>,
|
||||
pub resolved_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize)]
|
||||
pub struct WorkspaceMergeContent {
|
||||
pub id: i64,
|
||||
pub merge_change_id: i64,
|
||||
pub content_type: String,
|
||||
pub content_hash: String,
|
||||
pub content_data: Option<serde_json::Value>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CreateMergeRequestRequest {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub auto_merge: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct MergeRequestResponse {
|
||||
pub merge_request: WorkspaceMergeRequest,
|
||||
pub changes: Vec<WorkspaceMergeChange>,
|
||||
pub conflicts_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ResolveMergeConflictRequest {
|
||||
pub change_id: i64,
|
||||
pub resolution_strategy: String, // 'take_source', 'take_target', 'manual'
|
||||
pub manual_content: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Create a merge request to merge a fork back to its parent
|
||||
pub async fn create_merge_request(
|
||||
db: &DB,
|
||||
source_workspace_id: &str,
|
||||
target_workspace_id: &str,
|
||||
created_by: &str,
|
||||
title: &str,
|
||||
description: Option<&str>,
|
||||
auto_merge: bool,
|
||||
) -> Result<MergeRequestResponse> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Verify that source is a fork of target
|
||||
let fork_info = sqlx::query_scalar!(
|
||||
"SELECT parent_workspace_id FROM workspace_fork WHERE fork_workspace_id = $1",
|
||||
source_workspace_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let parent_workspace_id =
|
||||
fork_info.ok_or_else(|| Error::BadRequest("Source workspace is not a fork".to_string()))?;
|
||||
|
||||
if parent_workspace_id != target_workspace_id {
|
||||
return Err(Error::BadRequest(
|
||||
"Source workspace is not a fork of the target workspace".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Create merge request
|
||||
let merge_request_id = sqlx::query_scalar!(
|
||||
"INSERT INTO workspace_merge_request
|
||||
(source_workspace_id, target_workspace_id, created_by, title, description, auto_merge)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id",
|
||||
source_workspace_id,
|
||||
target_workspace_id,
|
||||
created_by,
|
||||
title,
|
||||
description,
|
||||
auto_merge
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Analyze changes and create merge changes
|
||||
let changes = analyze_workspace_changes(
|
||||
&mut tx,
|
||||
source_workspace_id,
|
||||
target_workspace_id,
|
||||
merge_request_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Fetch the created merge request
|
||||
let merge_request = get_merge_request(db, merge_request_id).await?;
|
||||
|
||||
let conflicts_count = changes.iter().filter(|c| c.has_conflict).count();
|
||||
|
||||
Ok(MergeRequestResponse { merge_request, changes, conflicts_count })
|
||||
}
|
||||
|
||||
/// Analyze changes between fork and parent workspace
|
||||
async fn analyze_workspace_changes(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
source_workspace_id: &str,
|
||||
target_workspace_id: &str,
|
||||
merge_request_id: i64,
|
||||
) -> Result<Vec<WorkspaceMergeChange>> {
|
||||
let mut changes = Vec::new();
|
||||
|
||||
// Get all resources that have been modified in the fork (is_reference = false)
|
||||
let modified_resources = sqlx::query!(
|
||||
"SELECT resource_type, resource_path FROM forked_resource_refs
|
||||
WHERE fork_workspace_id = $1 AND is_reference = false",
|
||||
source_workspace_id
|
||||
)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for resource in modified_resources {
|
||||
let resource_type = &resource.resource_type;
|
||||
let resource_path = &resource.resource_path;
|
||||
|
||||
// Get content hashes for comparison
|
||||
let (source_hash, target_hash) = match resource_type.as_str() {
|
||||
"script" => {
|
||||
let source = sqlx::query_scalar!(
|
||||
"SELECT encode(sha256(content::bytea), 'hex') FROM script
|
||||
WHERE workspace_id = $1 AND path = $2",
|
||||
source_workspace_id,
|
||||
resource_path
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
let target = sqlx::query_scalar!(
|
||||
"SELECT encode(sha256(content::bytea), 'hex') FROM script
|
||||
WHERE workspace_id = $1 AND path = $2",
|
||||
target_workspace_id,
|
||||
resource_path
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
(source, target)
|
||||
}
|
||||
"flow" => {
|
||||
let source = sqlx::query_scalar!(
|
||||
"SELECT encode(sha256(value::text::bytea), 'hex') FROM flow
|
||||
WHERE workspace_id = $1 AND path = $2",
|
||||
source_workspace_id,
|
||||
resource_path
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
let target = sqlx::query_scalar!(
|
||||
"SELECT encode(sha256(value::text::bytea), 'hex') FROM flow
|
||||
WHERE workspace_id = $1 AND path = $2",
|
||||
target_workspace_id,
|
||||
resource_path
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
(source, target)
|
||||
}
|
||||
_ => (None, None), // Add more resource types as needed
|
||||
};
|
||||
|
||||
let change_type = match (&source_hash, &target_hash) {
|
||||
(Some(_), None) => "added",
|
||||
(Some(s), Some(t)) if s != t => "modified",
|
||||
(None, Some(_)) => "deleted",
|
||||
_ => continue, // No change
|
||||
};
|
||||
|
||||
// Check for conflicts (if target has been modified since fork point)
|
||||
let fork_point = sqlx::query_scalar!(
|
||||
"SELECT fork_point FROM workspace_fork WHERE fork_workspace_id = $1",
|
||||
source_workspace_id
|
||||
)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let target_modified_after_fork = match resource_type.as_str() {
|
||||
"script" => sqlx::query_scalar!(
|
||||
"SELECT created_at > $1 FROM script
|
||||
WHERE workspace_id = $2 AND path = $3",
|
||||
fork_point,
|
||||
target_workspace_id,
|
||||
resource_path
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or(false),
|
||||
"flow" => sqlx::query_scalar!(
|
||||
"SELECT edited_at > $1 FROM flow
|
||||
WHERE workspace_id = $2 AND path = $3",
|
||||
fork_point,
|
||||
target_workspace_id,
|
||||
resource_path
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let has_conflict = target_modified_after_fork && change_type == "modified";
|
||||
let conflict_reason = if has_conflict {
|
||||
Some("Resource was modified in both source and target workspace".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create merge change record
|
||||
let change_id = sqlx::query_scalar!(
|
||||
"INSERT INTO workspace_merge_change
|
||||
(merge_request_id, resource_type, resource_path, change_type,
|
||||
source_content_hash, target_content_hash, has_conflict, conflict_reason)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id",
|
||||
merge_request_id,
|
||||
resource_type,
|
||||
resource_path,
|
||||
change_type,
|
||||
source_hash,
|
||||
target_hash,
|
||||
has_conflict,
|
||||
conflict_reason
|
||||
)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
|
||||
changes.push(WorkspaceMergeChange {
|
||||
id: change_id,
|
||||
merge_request_id,
|
||||
resource_type: resource_type.clone(),
|
||||
resource_path: resource_path.clone(),
|
||||
change_type: change_type.to_string(),
|
||||
source_content_hash: source_hash,
|
||||
target_content_hash: target_hash,
|
||||
has_conflict,
|
||||
conflict_reason,
|
||||
resolved: false,
|
||||
resolution_strategy: None,
|
||||
resolved_by: None,
|
||||
resolved_at: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// Get a merge request by ID
|
||||
pub async fn get_merge_request(db: &DB, merge_request_id: i64) -> Result<WorkspaceMergeRequest> {
|
||||
let merge_request = sqlx::query_as!(
|
||||
WorkspaceMergeRequest,
|
||||
"SELECT id, source_workspace_id, target_workspace_id, created_by, created_at,
|
||||
status, title, description, merged_at, merged_by, rejected_at, rejected_by,
|
||||
rejection_reason, auto_merge
|
||||
FROM workspace_merge_request WHERE id = $1",
|
||||
merge_request_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
Ok(merge_request)
|
||||
}
|
||||
|
||||
/// Execute a merge request (apply changes to target workspace)
|
||||
pub async fn execute_merge(db: &DB, merge_request_id: i64, merged_by: &str) -> Result<()> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Get merge request details
|
||||
let merge_request = get_merge_request(db, merge_request_id).await?;
|
||||
|
||||
if merge_request.status != "pending" && merge_request.status != "approved" {
|
||||
return Err(Error::BadRequest(
|
||||
"Merge request is not in a mergeable state".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Get all unresolved conflicts
|
||||
let unresolved_conflicts = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM workspace_merge_change
|
||||
WHERE merge_request_id = $1 AND has_conflict = true AND resolved = false",
|
||||
merge_request_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
if unresolved_conflicts > 0 {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Cannot merge: {} unresolved conflicts remain",
|
||||
unresolved_conflicts
|
||||
)));
|
||||
}
|
||||
|
||||
// Apply all changes to target workspace
|
||||
let changes = sqlx::query_as!(
|
||||
WorkspaceMergeChange,
|
||||
"SELECT id, merge_request_id, resource_type, resource_path, change_type,
|
||||
source_content_hash, target_content_hash, has_conflict, conflict_reason,
|
||||
resolved, resolution_strategy, resolved_by, resolved_at, created_at
|
||||
FROM workspace_merge_change WHERE merge_request_id = $1",
|
||||
merge_request_id
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for change in changes {
|
||||
apply_change_to_workspace(
|
||||
&mut tx,
|
||||
&merge_request.source_workspace_id,
|
||||
&merge_request.target_workspace_id,
|
||||
&change,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Mark merge request as merged
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_merge_request
|
||||
SET status = 'merged', merged_at = NOW(), merged_by = $1
|
||||
WHERE id = $2",
|
||||
merged_by,
|
||||
merge_request_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a single change to the target workspace
|
||||
async fn apply_change_to_workspace(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
source_workspace_id: &str,
|
||||
target_workspace_id: &str,
|
||||
change: &WorkspaceMergeChange,
|
||||
) -> Result<()> {
|
||||
match change.resource_type.as_str() {
|
||||
"script" => {
|
||||
match change.change_type.as_str() {
|
||||
"added" | "modified" => {
|
||||
// Copy script from source to target
|
||||
sqlx::query!(
|
||||
"INSERT INTO script
|
||||
(workspace_id, hash, path, parent_hashes, summary, description, content,
|
||||
created_by, created_at, archived, schema, deleted, is_template,
|
||||
extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,
|
||||
envs, concurrent_limit, concurrency_time_window_s, cache_ttl,
|
||||
dedicated_worker, ws_error_handler_muted, priority, timeout,
|
||||
delete_after_use, restart_unless_cancelled, concurrency_key,
|
||||
visible_to_runner_only, no_main_func, codebase, has_preprocessor,
|
||||
on_behalf_of_email, schema_validation)
|
||||
SELECT $1 as workspace_id, hash, path, parent_hashes, summary, description, content,
|
||||
created_by, created_at, archived, schema, deleted, is_template,
|
||||
extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,
|
||||
envs, concurrent_limit, concurrency_time_window_s, cache_ttl,
|
||||
dedicated_worker, ws_error_handler_muted, priority, timeout,
|
||||
delete_after_use, restart_unless_cancelled, concurrency_key,
|
||||
visible_to_runner_only, no_main_func, codebase, has_preprocessor,
|
||||
on_behalf_of_email, schema_validation
|
||||
FROM script WHERE workspace_id = $2 AND path = $3
|
||||
ON CONFLICT (workspace_id, hash) DO UPDATE SET
|
||||
path = EXCLUDED.path, summary = EXCLUDED.summary, description = EXCLUDED.description,
|
||||
content = EXCLUDED.content",
|
||||
target_workspace_id,
|
||||
source_workspace_id,
|
||||
change.resource_path
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
"deleted" => {
|
||||
sqlx::query!(
|
||||
"UPDATE script SET deleted = true
|
||||
WHERE workspace_id = $1 AND path = $2",
|
||||
target_workspace_id,
|
||||
change.resource_path
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::InternalErr(format!(
|
||||
"Unknown change type: {}",
|
||||
change.change_type
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
"flow" => match change.change_type.as_str() {
|
||||
"added" | "modified" => {
|
||||
sqlx::query!(
|
||||
"INSERT INTO flow
|
||||
(workspace_id, path, summary, description, value, edited_by, edited_at,
|
||||
archived, schema, extra_perms, dependency_job, draft_only, tag,
|
||||
ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,
|
||||
concurrency_key, versions, on_behalf_of_email, lock_error_logs)
|
||||
SELECT $1 as workspace_id, path, summary, description, value, edited_by, edited_at,
|
||||
archived, schema, extra_perms, dependency_job, draft_only, tag,
|
||||
ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,
|
||||
concurrency_key, versions, on_behalf_of_email, lock_error_logs
|
||||
FROM flow WHERE workspace_id = $2 AND path = $3
|
||||
ON CONFLICT (workspace_id, path) DO UPDATE SET
|
||||
summary = EXCLUDED.summary, description = EXCLUDED.description,
|
||||
value = EXCLUDED.value, edited_by = EXCLUDED.edited_by,
|
||||
edited_at = EXCLUDED.edited_at",
|
||||
target_workspace_id,
|
||||
source_workspace_id,
|
||||
change.resource_path
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
"deleted" => {
|
||||
sqlx::query!(
|
||||
"UPDATE flow SET archived = true
|
||||
WHERE workspace_id = $1 AND path = $2",
|
||||
target_workspace_id,
|
||||
change.resource_path
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::InternalErr(format!(
|
||||
"Unknown change type: {}",
|
||||
change.change_type
|
||||
)))
|
||||
}
|
||||
},
|
||||
// Add more resource types as needed (apps, variables, etc.)
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"Merge not implemented for resource type: {}",
|
||||
change.resource_type
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/create_merge_request", post(create_merge_request_handler))
|
||||
.route("/list_merge_requests", get(list_merge_requests_handler))
|
||||
.route("/merge_request/:id", get(get_merge_request_handler))
|
||||
.route("/merge_request/:id/execute", post(execute_merge_handler))
|
||||
.route("/merge_request/:id/changes", get(get_merge_changes_handler))
|
||||
.route(
|
||||
"/merge_request/:id/resolve_conflict",
|
||||
post(resolve_conflict_handler),
|
||||
)
|
||||
}
|
||||
|
||||
async fn create_merge_request_handler(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
Json(request): Json<CreateMergeRequestRequest>,
|
||||
) -> JsonResult<MergeRequestResponse> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
// Get parent workspace ID for this fork
|
||||
let parent_workspace_id = sqlx::query_scalar!(
|
||||
"SELECT parent_workspace_id FROM workspace_fork WHERE fork_workspace_id = $1",
|
||||
workspace_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::BadRequest("Workspace is not a fork".to_string()))?;
|
||||
|
||||
let merge_response = create_merge_request(
|
||||
&db,
|
||||
&workspace_id,
|
||||
&parent_workspace_id,
|
||||
&authed.email,
|
||||
&request.title,
|
||||
request.description.as_deref(),
|
||||
request.auto_merge.unwrap_or(false),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(merge_response))
|
||||
}
|
||||
|
||||
async fn list_merge_requests_handler(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
) -> JsonResult<Vec<WorkspaceMergeRequest>> {
|
||||
let merge_requests = sqlx::query_as!(
|
||||
WorkspaceMergeRequest,
|
||||
"SELECT id, source_workspace_id, target_workspace_id, created_by, created_at,
|
||||
status, title, description, merged_at, merged_by, rejected_at, rejected_by,
|
||||
rejection_reason, auto_merge
|
||||
FROM workspace_merge_request
|
||||
WHERE source_workspace_id = $1 OR target_workspace_id = $1
|
||||
ORDER BY created_at DESC",
|
||||
workspace_id
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(merge_requests))
|
||||
}
|
||||
|
||||
async fn get_merge_request_handler(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((workspace_id, merge_request_id)): Path<(String, i64)>,
|
||||
) -> JsonResult<WorkspaceMergeRequest> {
|
||||
let merge_request = get_merge_request(&db, merge_request_id).await?;
|
||||
Ok(Json(merge_request))
|
||||
}
|
||||
|
||||
async fn execute_merge_handler(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((workspace_id, merge_request_id)): Path<(String, i64)>,
|
||||
) -> JsonResult<()> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
execute_merge(&db, merge_request_id, &authed.email).await?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
async fn get_merge_changes_handler(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((workspace_id, merge_request_id)): Path<(String, i64)>,
|
||||
) -> JsonResult<Vec<WorkspaceMergeChange>> {
|
||||
let changes = sqlx::query_as!(
|
||||
WorkspaceMergeChange,
|
||||
"SELECT id, merge_request_id, resource_type, resource_path, change_type,
|
||||
source_content_hash, target_content_hash, has_conflict, conflict_reason,
|
||||
resolved, resolution_strategy, resolved_by, resolved_at, created_at
|
||||
FROM workspace_merge_change WHERE merge_request_id = $1
|
||||
ORDER BY resource_type, resource_path",
|
||||
merge_request_id
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(changes))
|
||||
}
|
||||
|
||||
async fn resolve_conflict_handler(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((workspace_id, merge_request_id)): Path<(String, i64)>,
|
||||
Json(request): Json<ResolveMergeConflictRequest>,
|
||||
) -> JsonResult<()> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_merge_change
|
||||
SET resolved = true, resolution_strategy = $1, resolved_by = $2, resolved_at = NOW()
|
||||
WHERE id = $3 AND merge_request_id = $4",
|
||||
request.resolution_strategy,
|
||||
authed.email,
|
||||
request.change_id,
|
||||
merge_request_id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(()))
|
||||
}
|
||||
@@ -141,7 +141,10 @@ pub fn workspaced_service() -> Router {
|
||||
post(acknowledge_all_critical_alerts),
|
||||
)
|
||||
.route("/critical_alerts/mute", post(mute_critical_alerts))
|
||||
.route("/operator_settings", post(update_operator_settings));
|
||||
.route("/operator_settings", post(update_operator_settings))
|
||||
.route("/fork_info", get(get_fork_info))
|
||||
.nest("/fork", crate::workspace_fork::workspaced_service())
|
||||
.nest("/merge", crate::workspace_merge::workspaced_service());
|
||||
|
||||
#[cfg(all(feature = "stripe", feature = "enterprise"))]
|
||||
{
|
||||
@@ -157,6 +160,7 @@ pub fn global_service() -> Router {
|
||||
.route("/list", get(list_workspaces))
|
||||
.route("/users", get(user_workspaces))
|
||||
.route("/create", post(create_workspace))
|
||||
.route("/fork/:workspace_id", post(fork_workspace))
|
||||
.route("/exists", post(exists_workspace))
|
||||
.route("/exists_username", post(exists_username))
|
||||
.route("/allowed_domain_auto_invite", get(is_allowed_auto_domain))
|
||||
@@ -310,6 +314,15 @@ struct CreateWorkspace {
|
||||
color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ForkWorkspace {
|
||||
id: String,
|
||||
name: String,
|
||||
username: Option<String>,
|
||||
color: Option<String>,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EditWorkspace {
|
||||
name: String,
|
||||
@@ -1612,6 +1625,202 @@ async fn create_workspace(
|
||||
Ok(format!("Created workspace {}", &nw.id))
|
||||
}
|
||||
|
||||
async fn fork_workspace(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(parent_workspace_id): Path<String>,
|
||||
Json(fw): Json<ForkWorkspace>,
|
||||
) -> Result<String> {
|
||||
// Check if user has permission to fork the parent workspace
|
||||
// For now, we'll require admin access to the parent workspace
|
||||
let parent_workspace = sqlx::query!(
|
||||
"SELECT id, name, owner FROM workspace WHERE id = $1 AND deleted = false",
|
||||
parent_workspace_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
let parent_workspace = not_found_if_none(parent_workspace, "workspace", &parent_workspace_id)?;
|
||||
|
||||
// TODO: Add proper permission check - for now, only allow workspace owner or super admin
|
||||
let is_super_admin = sqlx::query_scalar!(
|
||||
"SELECT super_admin FROM password WHERE email = $1",
|
||||
&authed.email
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if parent_workspace.owner != authed.email && !is_super_admin {
|
||||
return Err(Error::BadRequest(
|
||||
"You must be the workspace owner or super admin to fork this workspace".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Apply same restrictions as create_workspace
|
||||
if *CREATE_WORKSPACE_REQUIRE_SUPERADMIN {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
_check_nb_of_workspaces(&db).await?;
|
||||
|
||||
if *CLOUD_HOSTED {
|
||||
let nb_workspaces = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM workspace WHERE owner = $1",
|
||||
authed.email
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
if nb_workspaces.unwrap_or(0) >= 10 {
|
||||
return Err(Error::BadRequest(
|
||||
"You have reached the maximum number of workspaces (10) on cloud. Contact support@windmill.dev to increase the limit"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
|
||||
|
||||
// Check for workspace ID conflicts
|
||||
check_w_id_conflict(&mut tx, &fw.id).await?;
|
||||
|
||||
// Create the forked workspace using the same logic as create_workspace
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace (id, name, owner) VALUES ($1, $2, $3)",
|
||||
fw.id,
|
||||
fw.name,
|
||||
authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_settings (workspace_id, color) VALUES ($1, $2)",
|
||||
fw.id,
|
||||
fw.color,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let key = rd_string(64);
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_key (workspace_id, kind, key) VALUES ($1, 'cloud', $2)",
|
||||
fw.id,
|
||||
&key
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Handle username creation
|
||||
let automate_username_creation = sqlx::query_scalar!(
|
||||
"SELECT value FROM global_settings WHERE name = $1",
|
||||
AUTOMATE_USERNAME_CREATION_SETTING,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.map(|v| v.as_bool())
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
|
||||
let username = if automate_username_creation {
|
||||
if fw.username.is_some() && fw.username.unwrap().len() > 0 {
|
||||
return Err(Error::BadRequest(
|
||||
"username is not allowed when username creation is automated".to_string(),
|
||||
));
|
||||
}
|
||||
get_instance_username_or_create_pending(&mut tx, &authed.email).await?
|
||||
} else {
|
||||
fw.username
|
||||
.ok_or(Error::BadRequest("username is required".to_string()))?
|
||||
};
|
||||
|
||||
// Create admin user and default group
|
||||
sqlx::query!(
|
||||
"INSERT INTO usr (workspace_id, email, username, is_admin) VALUES ($1, $2, $3, true)",
|
||||
fw.id,
|
||||
authed.email,
|
||||
username,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO group_ VALUES ($1, 'all', 'The group that always contains all users of this workspace')",
|
||||
fw.id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO usr_to_group VALUES ($1, 'all', $2)",
|
||||
fw.id,
|
||||
username
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Create default folders (same as create_workspace)
|
||||
sqlx::query!(
|
||||
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by, edited_at) VALUES ($1, 'app_themes', 'App Themes', ARRAY[]::TEXT[], '{\"g/all\": false}', $2, now()) ON CONFLICT DO NOTHING",
|
||||
fw.id,
|
||||
username,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by, edited_at) VALUES ($1, 'app_custom', 'App Custom Components', ARRAY[]::TEXT[], '{\"g/all\": false}', $2, now()) ON CONFLICT DO NOTHING",
|
||||
fw.id,
|
||||
username,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by, edited_at) VALUES ($1, 'app_groups', 'App Groups', ARRAY[]::TEXT[], '{\"g/all\": false}', $2, now()) ON CONFLICT DO NOTHING",
|
||||
fw.id,
|
||||
username,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO resource (workspace_id, path, value, description, resource_type, created_by, edited_at) VALUES ($1, 'f/app_themes/theme_0', '{\"name\": \"Default Theme\", \"value\": \"\"}', 'The default app theme', 'app_theme', $2, now()) ON CONFLICT DO NOTHING",
|
||||
fw.id,
|
||||
username,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Record the fork relationship
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_fork (fork_workspace_id, parent_workspace_id, created_by, fork_point) VALUES ($1, $2, $3, NOW())",
|
||||
fw.id,
|
||||
parent_workspace_id,
|
||||
authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Copy or reference resources from parent workspace
|
||||
crate::workspace_fork::copy_workspace_resources(&mut tx, &parent_workspace_id, &fw.id).await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.fork",
|
||||
ActionKind::Create,
|
||||
&fw.id,
|
||||
Some(&format!("Forked from {}", parent_workspace_id)),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(format!("Forked workspace {} from {}", &fw.id, &parent_workspace_id))
|
||||
}
|
||||
|
||||
async fn edit_workspace(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -2207,3 +2416,11 @@ async fn update_operator_settings(
|
||||
|
||||
Ok("Operator settings updated successfully".to_string())
|
||||
}
|
||||
|
||||
async fn get_fork_info(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Option<crate::workspace_fork::WorkspaceFork>> {
|
||||
let fork_info = crate::workspace_fork::get_fork_info(&db, &w_id).await?;
|
||||
Ok(Json(fork_info))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
workspaceUsageStore,
|
||||
workspaceColor
|
||||
} from '$lib/stores'
|
||||
import { Building, Plus, Settings } from 'lucide-svelte'
|
||||
import { Building, Plus, Settings, GitFork } from 'lucide-svelte'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import { Menu, MenuItem } from '$lib/components/meltComponents'
|
||||
import { goto } from '$lib/navigation'
|
||||
@@ -21,6 +21,8 @@
|
||||
import { workspaceAIClients } from '../copilot/lib'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { MenubarBuilders } from '@melt-ui/svelte'
|
||||
import { GitMerge, GitPullRequest, GitBranch } from 'lucide-svelte'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
isCollapsed?: boolean
|
||||
@@ -31,6 +33,53 @@
|
||||
|
||||
let { isCollapsed = false, createMenu, strictWorkspaceSelect = false }: Props = $props()
|
||||
|
||||
// Workspace fork status
|
||||
let currentWorkspaceForkInfo = $state<{ parent_workspace_id: string; fork_workspace_id: string } | null>(null)
|
||||
let pendingMergeRequests = $state<any[]>([])
|
||||
let hasUnmergedChanges = $state(false)
|
||||
|
||||
async function loadWorkspaceForkStatus() {
|
||||
if (!$workspaceStore || !($userStore?.is_admin || $superadmin)) return
|
||||
|
||||
try {
|
||||
// Check if current workspace is a fork
|
||||
const response = await fetch(`/api/w/${$workspaceStore}/workspaces/fork/fork_info`)
|
||||
if (response.ok) {
|
||||
currentWorkspaceForkInfo = await response.json()
|
||||
} else {
|
||||
currentWorkspaceForkInfo = null
|
||||
}
|
||||
|
||||
// Check for pending merge requests
|
||||
const mergeResponse = await fetch(`/api/w/${$workspaceStore}/workspaces/merge/list_merge_requests`)
|
||||
if (mergeResponse.ok) {
|
||||
pendingMergeRequests = await mergeResponse.json()
|
||||
}
|
||||
|
||||
// Check for unmerged changes (if this is a fork)
|
||||
if (currentWorkspaceForkInfo) {
|
||||
const refsResponse = await fetch(`/api/w/${$workspaceStore}/workspaces/fork/resource_refs`)
|
||||
if (refsResponse.ok) {
|
||||
const refs = await refsResponse.json()
|
||||
hasUnmergedChanges = refs.some((ref: any) => !ref.is_reference)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Failed to load workspace fork status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadWorkspaceForkStatus()
|
||||
})
|
||||
|
||||
// Reload fork status when workspace changes
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
loadWorkspaceForkStatus()
|
||||
}
|
||||
})
|
||||
|
||||
async function toggleSwitchWorkspace(id: string) {
|
||||
if ($workspaceStore === id) {
|
||||
return
|
||||
@@ -87,13 +136,26 @@
|
||||
{item}
|
||||
>
|
||||
<div class="flex items-center justify-between min-w-0 w-full">
|
||||
<div>
|
||||
<div class="text-primary pl-4 truncate text-left text-[1.2em]">{workspace.name}</div
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-primary pl-4 truncate text-left text-[1.2em] flex items-center gap-1">
|
||||
{workspace.name}
|
||||
{#if $workspaceStore === workspace.id && currentWorkspaceForkInfo}
|
||||
<GitBranch size={12} class="text-blue-500" title="This is a forked workspace" />
|
||||
{/if}
|
||||
{#if $workspaceStore === workspace.id && hasUnmergedChanges}
|
||||
<div class="w-2 h-2 bg-orange-500 rounded-full" title="Unmerged changes"></div>
|
||||
{/if}
|
||||
{#if $workspaceStore === workspace.id && pendingMergeRequests.length > 0}
|
||||
<div class="w-2 h-2 bg-blue-500 rounded-full" title="{pendingMergeRequests.length} pending merge request(s)"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="text-tertiary font-mono pl-4 text-2xs whitespace-nowrap truncate text-left"
|
||||
>
|
||||
{workspace.id}
|
||||
{#if $workspaceStore === workspace.id && currentWorkspaceForkInfo}
|
||||
<span class="text-blue-500">← {currentWorkspaceForkInfo.parent_workspace_id}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if workspace.color}
|
||||
@@ -119,6 +181,58 @@
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if ($userStore?.is_admin || $superadmin) && !strictWorkspaceSelect && $workspaceStore}
|
||||
<div class="py-1" role="none">
|
||||
<a
|
||||
href="{base}/user/fork_workspace/{$workspaceStore}"
|
||||
class="text-primary px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary flex flex-flow gap-2"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<GitFork size={16} />
|
||||
Fork workspace
|
||||
</a>
|
||||
|
||||
{#if currentWorkspaceForkInfo}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
goto(`${base}/workspace_merge`)
|
||||
}}
|
||||
class={twMerge(
|
||||
'text-primary px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary flex flex-flow gap-2',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary',
|
||||
hasUnmergedChanges ? 'text-orange-600' : ''
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
<GitMerge size={16} />
|
||||
Create merge request
|
||||
{#if hasUnmergedChanges}
|
||||
<div class="w-2 h-2 bg-orange-500 rounded-full ml-auto"></div>
|
||||
{/if}
|
||||
</MenuItem>
|
||||
|
||||
{#if pendingMergeRequests.length > 0}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
goto(`${base}/workspace_merge`)
|
||||
}}
|
||||
class={twMerge(
|
||||
'text-blue-600 px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary flex flex-flow gap-2',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
<GitPullRequest size={16} />
|
||||
View merge requests
|
||||
<div class="bg-blue-500 text-white text-xs px-1 rounded-full ml-auto">
|
||||
{pendingMergeRequests.length}
|
||||
</div>
|
||||
</MenuItem>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !strictWorkspaceSelect}
|
||||
<div class="py-1" role="none">
|
||||
<MenuItem
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import {
|
||||
WorkspaceService,
|
||||
SettingService,
|
||||
UserService
|
||||
} from '$lib/gen'
|
||||
import { validateUsername } from '$lib/utils'
|
||||
import { logoutWithRedirect } from '$lib/logout'
|
||||
import { page } from '$app/stores'
|
||||
import { usersWorkspaceStore, userStore } from '$lib/stores'
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
|
||||
const rd = $page.url.searchParams.get('rd')
|
||||
const parentWorkspaceId = $page.params.workspace_id
|
||||
|
||||
let id = $state('')
|
||||
let name = $state('')
|
||||
let username = $state('')
|
||||
|
||||
let errorId = $state('')
|
||||
let errorUser = $state('')
|
||||
let checking = $state(false)
|
||||
|
||||
let workspaceColor: string | null = $state(null)
|
||||
let colorEnabled = $state(false)
|
||||
|
||||
// Load parent workspace info
|
||||
let parentWorkspace = $state<{ id: string; name: string } | null>(null)
|
||||
|
||||
function generateRandomColor() {
|
||||
const randomColor =
|
||||
'#' +
|
||||
Math.floor(Math.random() * 16777215)
|
||||
.toString(16)
|
||||
.padStart(6, '0')
|
||||
workspaceColor = randomColor
|
||||
}
|
||||
|
||||
async function validateName(id: string): Promise<void> {
|
||||
checking = true
|
||||
let exists = await WorkspaceService.existsWorkspace({ requestBody: { id } })
|
||||
if (exists) {
|
||||
errorId = 'ID already exists'
|
||||
} else if (id != '' && !/^\w+(-\w+)*$/.test(id)) {
|
||||
errorId = 'ID can only contain letters, numbers and dashes and must not finish by a dash'
|
||||
} else {
|
||||
errorId = ''
|
||||
}
|
||||
checking = false
|
||||
}
|
||||
|
||||
async function forkWorkspace(): Promise<void> {
|
||||
try {
|
||||
// Use our new fork API endpoint
|
||||
const response = await fetch(`/api/w/${parentWorkspaceId}/workspaces/fork/create_fork`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: `Forked from ${parentWorkspace?.name || parentWorkspaceId}`
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(error)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Update workspace color if specified
|
||||
if (colorEnabled && workspaceColor) {
|
||||
try {
|
||||
await fetch(`/api/w/${result.fork_workspace_id}/workspaces/change_workspace_color`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ color: workspaceColor })
|
||||
})
|
||||
} catch (colorError) {
|
||||
console.warn('Failed to set workspace color:', colorError)
|
||||
}
|
||||
}
|
||||
|
||||
// Create user in the new workspace if needed
|
||||
if (!automateUsernameCreation && username) {
|
||||
try {
|
||||
await fetch(`/api/w/${result.fork_workspace_id}/workspaces/add_user`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: $userStore?.email,
|
||||
username,
|
||||
is_admin: true
|
||||
})
|
||||
})
|
||||
} catch (userError) {
|
||||
console.warn('Failed to set username:', userError)
|
||||
}
|
||||
}
|
||||
|
||||
sendUserToast(`Forked workspace ${result.fork_workspace_id} from ${parentWorkspaceId}`)
|
||||
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
switchWorkspace(result.fork_workspace_id)
|
||||
|
||||
goto(rd ?? '/')
|
||||
} catch (error) {
|
||||
sendUserToast(`Failed to fork workspace: ${error}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
const key = event.key
|
||||
if (key === 'Enter') {
|
||||
event.preventDefault()
|
||||
forkWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkspaces() {
|
||||
if (!$usersWorkspaceStore) {
|
||||
try {
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
} catch {}
|
||||
}
|
||||
if (!$usersWorkspaceStore) {
|
||||
const url = $page.url
|
||||
console.log('logout 2')
|
||||
await logoutWithRedirect(url.href.replace(url.origin, ''))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadParentWorkspace() {
|
||||
try {
|
||||
// For now, we'll find it from the user's workspaces
|
||||
// In a real implementation, we might want to fetch workspace details
|
||||
const workspaces = $usersWorkspaceStore || await WorkspaceService.listUserWorkspaces()
|
||||
parentWorkspace = workspaces.workspaces.find(w => w.id === parentWorkspaceId) ||
|
||||
{ id: parentWorkspaceId, name: parentWorkspaceId }
|
||||
|
||||
// Set default values based on parent workspace
|
||||
name = `${parentWorkspace.name} (Fork)`
|
||||
} catch {
|
||||
parentWorkspace = { id: parentWorkspaceId, name: parentWorkspaceId }
|
||||
name = `${parentWorkspaceId} (Fork)`
|
||||
}
|
||||
}
|
||||
|
||||
let automateUsernameCreation = $state(false)
|
||||
async function getAutomateUsernameCreationSetting() {
|
||||
automateUsernameCreation =
|
||||
((await SettingService.getGlobal({ key: 'automate_username_creation' })) as any) ?? false
|
||||
|
||||
if (!automateUsernameCreation) {
|
||||
UserService.globalWhoami().then((x) => {
|
||||
let uname = ''
|
||||
if (x.name) {
|
||||
uname = x.name.split(' ')[0]
|
||||
} else {
|
||||
uname = x.email.split('@')[0]
|
||||
}
|
||||
uname = uname.replace(/\./gi, '')
|
||||
username = uname.toLowerCase()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadWorkspaces()
|
||||
loadParentWorkspace()
|
||||
getAutomateUsernameCreationSetting()
|
||||
})
|
||||
|
||||
run(() => {
|
||||
id = name.toLowerCase().replace(/\s/gi, '-')
|
||||
})
|
||||
run(() => {
|
||||
validateName(id)
|
||||
})
|
||||
run(() => {
|
||||
errorUser = validateUsername(username)
|
||||
})
|
||||
run(() => {
|
||||
colorEnabled && !workspaceColor && generateRandomColor()
|
||||
})
|
||||
</script>
|
||||
|
||||
<CenteredModal title="Fork Workspace">
|
||||
{#if parentWorkspace}
|
||||
<div class="mb-4 p-3 bg-surface-secondary rounded-md">
|
||||
<div class="text-sm text-secondary">Forking from:</div>
|
||||
<div class="font-medium">{parentWorkspace.name}</div>
|
||||
<div class="text-xs text-tertiary font-mono">{parentWorkspace.id}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="block pb-4 pt-4">
|
||||
<span class="text-secondary text-sm">Workspace name</span>
|
||||
<span class="ml-4 text-tertiary text-xs">Displayable name</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input autofocus type="text" bind:value={name} />
|
||||
</label>
|
||||
<label class="block pb-4">
|
||||
<span class="text-secondary text-sm">Workspace ID</span>
|
||||
<span class="ml-10 text-tertiary text-xs">Slug to uniquely identify your workspace</span>
|
||||
{#if errorId}
|
||||
<span class="text-red-500 text-xs">{errorId}</span>
|
||||
{/if}
|
||||
<input type="text" bind:value={id} class:input-error={errorId != ''} />
|
||||
</label>
|
||||
<label class="block pb-4">
|
||||
<span class="text-secondary text-sm">Workspace color</span>
|
||||
<span class="ml-5 text-tertiary text-xs"
|
||||
>Color to identify the current workspace in the list of workspaces</span
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
|
||||
{#if colorEnabled}<input
|
||||
class="w-10"
|
||||
type="color"
|
||||
bind:value={workspaceColor}
|
||||
disabled={!colorEnabled}
|
||||
/>{/if}
|
||||
<input
|
||||
type="text"
|
||||
class="w-24 text-sm"
|
||||
bind:value={workspaceColor}
|
||||
disabled={!colorEnabled}
|
||||
/>
|
||||
<Button on:click={generateRandomColor} size="xs" disabled={!colorEnabled}>Random</Button>
|
||||
</div>
|
||||
</label>
|
||||
{#if !automateUsernameCreation}
|
||||
<label class="block pb-4">
|
||||
<span class="text-secondary text-sm">Your username in that workspace</span>
|
||||
<input type="text" bind:value={username} onkeyup={handleKeyUp} />
|
||||
{#if errorUser}
|
||||
<span class="text-red-500 text-xs">{errorUser}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 p-3 bg-surface-secondary rounded-md">
|
||||
<div class="text-sm text-secondary mb-2">Fork Strategy</div>
|
||||
<div class="text-xs text-tertiary">
|
||||
The forked workspace will initially reference all resources from the parent workspace to save space.
|
||||
When you modify any resource, it will be automatically copied to your fork.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap flex-row justify-between pt-10 gap-1">
|
||||
<Button variant="border" size="sm" href="{base}/user/workspaces"
|
||||
>← Back to workspaces</Button
|
||||
>
|
||||
<Button
|
||||
disabled={checking ||
|
||||
errorId != '' ||
|
||||
!name ||
|
||||
(!automateUsernameCreation && (errorUser != '' || !username)) ||
|
||||
!id}
|
||||
on:click={forkWorkspace}
|
||||
>
|
||||
Fork workspace
|
||||
</Button>
|
||||
</div>
|
||||
</CenteredModal>
|
||||
308
frontend/src/routes/(root)/(logged)/workspace_merge/+page.svelte
Normal file
308
frontend/src/routes/(root)/(logged)/workspace_merge/+page.svelte
Normal file
@@ -0,0 +1,308 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore, userStore } from '$lib/stores'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import { onMount } from 'svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Button } from '$lib/components/common'
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { GitBranch, GitMerge, GitPullRequest, ArrowLeft, FileText, AlertTriangle, Check, X } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
let workspaceForkInfo = $state<{ parent_workspace_id: string; fork_workspace_id: string; created_at: string; created_by: string } | null>(null)
|
||||
let pendingMergeRequests = $state<any[]>([])
|
||||
let resourceRefs = $state<any[]>([])
|
||||
let loading = $state(true)
|
||||
let creating = $state(false)
|
||||
|
||||
// Create merge request form
|
||||
let showCreateForm = $state(false)
|
||||
let mergeTitle = $state('')
|
||||
let mergeDescription = $state('')
|
||||
let autoMerge = $state(false)
|
||||
|
||||
async function loadMergeData() {
|
||||
if (!$workspaceStore || !$userStore?.is_admin) {
|
||||
goto(`${base}/`)
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
try {
|
||||
// Check if current workspace is a fork
|
||||
const forkResponse = await fetch(`/api/w/${$workspaceStore}/workspaces/fork/fork_info`)
|
||||
if (forkResponse.ok) {
|
||||
workspaceForkInfo = await forkResponse.json()
|
||||
} else {
|
||||
sendUserToast('This workspace is not a fork. Redirecting...', true)
|
||||
goto(`${base}/`)
|
||||
return
|
||||
}
|
||||
|
||||
// Load pending merge requests
|
||||
const mergeResponse = await fetch(`/api/w/${$workspaceStore}/workspaces/merge/list_merge_requests`)
|
||||
if (mergeResponse.ok) {
|
||||
pendingMergeRequests = await mergeResponse.json()
|
||||
}
|
||||
|
||||
// Load resource references to show what's changed
|
||||
const refsResponse = await fetch(`/api/w/${$workspaceStore}/workspaces/fork/resource_refs`)
|
||||
if (refsResponse.ok) {
|
||||
resourceRefs = await refsResponse.json()
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
sendUserToast('Failed to load merge data', true)
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createMergeRequest() {
|
||||
if (!workspaceForkInfo || !mergeTitle.trim()) return
|
||||
|
||||
creating = true
|
||||
try {
|
||||
const response = await fetch(`/api/w/${$workspaceStore}/workspaces/merge/create_merge_request`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: mergeTitle,
|
||||
description: mergeDescription || undefined,
|
||||
auto_merge: autoMerge
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
sendUserToast(`Merge request created: ${result.merge_request.title}`)
|
||||
showCreateForm = false
|
||||
mergeTitle = ''
|
||||
mergeDescription = ''
|
||||
autoMerge = false
|
||||
await loadMergeData()
|
||||
} else {
|
||||
const error = await response.text()
|
||||
sendUserToast(`Failed to create merge request: ${error}`, true)
|
||||
}
|
||||
} catch (error) {
|
||||
sendUserToast('Failed to create merge request', true)
|
||||
console.error(error)
|
||||
} finally {
|
||||
creating = false
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadMergeData)
|
||||
|
||||
// Computed values
|
||||
let changedResources = $derived(resourceRefs.filter(ref => !ref.is_reference))
|
||||
let referencedResources = $derived(resourceRefs.filter(ref => ref.is_reference))
|
||||
let hasChanges = $derived(changedResources.length > 0)
|
||||
|
||||
// Automatically set merge title based on changes
|
||||
$effect(() => {
|
||||
if (hasChanges && !mergeTitle) {
|
||||
mergeTitle = `Merge ${changedResources.length} changes from ${$workspaceStore}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Workspace Merge - {$workspaceStore}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<CenteredModal title="Loading...">
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
</CenteredModal>
|
||||
{:else if workspaceForkInfo}
|
||||
<div class="container mx-auto p-6 max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<Button variant="border" size="sm" on:click={() => goto(base + '/')}>
|
||||
<ArrowLeft size={16} />
|
||||
Back to workspace
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<GitBranch size={20} class="text-blue-500" />
|
||||
<h1 class="text-2xl font-bold">Workspace Merge</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fork Info -->
|
||||
<div class="bg-surface-secondary rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<GitBranch size={16} class="text-blue-500" />
|
||||
<span class="font-medium">Fork Information</span>
|
||||
</div>
|
||||
<div class="text-sm text-secondary">
|
||||
<div>Forked from: <span class="font-mono text-blue-600">{workspaceForkInfo.parent_workspace_id}</span></div>
|
||||
<div>Created by: {workspaceForkInfo.created_by}</div>
|
||||
<div>Created at: {new Date(workspaceForkInfo.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Changes Summary -->
|
||||
<div class="bg-surface-secondary rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<FileText size={16} />
|
||||
<span class="font-medium">Changes Summary</span>
|
||||
</div>
|
||||
|
||||
{#if hasChanges}
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2 text-orange-600">
|
||||
<div class="w-3 h-3 bg-orange-500 rounded-full"></div>
|
||||
<span class="font-medium">{changedResources.length} Modified Resources</span>
|
||||
</div>
|
||||
<div class="max-h-32 overflow-y-auto space-y-1">
|
||||
{#each changedResources as resource}
|
||||
<div class="text-sm text-secondary font-mono pl-5">
|
||||
{resource.resource_type}: {resource.resource_path}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2 text-blue-600">
|
||||
<div class="w-3 h-3 bg-blue-500 rounded-full"></div>
|
||||
<span class="font-medium">{referencedResources.length} Referenced Resources</span>
|
||||
</div>
|
||||
<div class="text-sm text-secondary">
|
||||
These resources are unchanged and still reference the parent workspace.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-4 text-secondary">
|
||||
<AlertTriangle size={24} class="mx-auto mb-2 text-yellow-500" />
|
||||
<div>No changes to merge</div>
|
||||
<div class="text-sm">All resources are still referencing the parent workspace</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Pending Merge Requests -->
|
||||
{#if pendingMergeRequests.length > 0}
|
||||
<div class="bg-surface-secondary rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<GitPullRequest size={16} class="text-blue-500" />
|
||||
<span class="font-medium">Pending Merge Requests</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each pendingMergeRequests as request}
|
||||
<div class="flex items-center justify-between p-3 bg-surface rounded border">
|
||||
<div>
|
||||
<div class="font-medium">{request.title}</div>
|
||||
<div class="text-sm text-secondary">
|
||||
Created by {request.created_by} • {new Date(request.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
{#if request.description}
|
||||
<div class="text-sm text-tertiary mt-1">{request.description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={twMerge(
|
||||
'px-2 py-1 rounded text-xs font-medium',
|
||||
request.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
|
||||
request.status === 'approved' ? 'bg-green-100 text-green-800' :
|
||||
request.status === 'merged' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
)}>
|
||||
{request.status}
|
||||
</span>
|
||||
<Button size="xs" href="{base}/workspace_merge/{request.id}">
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-4">
|
||||
{#if hasChanges && !showCreateForm}
|
||||
<Button on:click={() => showCreateForm = true}>
|
||||
<GitMerge size={16} />
|
||||
Create Merge Request
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Button variant="border" href="{base}/workspace_settings">
|
||||
Workspace Settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Create Merge Request Form -->
|
||||
{#if showCreateForm}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-surface rounded-lg p-6 max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h3 class="text-lg font-bold mb-4">Create Merge Request</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={mergeTitle}
|
||||
class="w-full p-2 border rounded"
|
||||
placeholder="Describe your changes..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Description (optional)</label>
|
||||
<textarea
|
||||
bind:value={mergeDescription}
|
||||
class="w-full p-2 border rounded h-24"
|
||||
placeholder="Additional details about the changes..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={autoMerge} id="auto-merge" />
|
||||
<label for="auto-merge" class="text-sm">Auto-merge if no conflicts</label>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-secondary p-3 rounded text-sm">
|
||||
<div class="font-medium mb-1">Changes to be merged:</div>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
{#each changedResources.slice(0, 5) as resource}
|
||||
<li class="font-mono text-xs">{resource.resource_type}: {resource.resource_path}</li>
|
||||
{/each}
|
||||
{#if changedResources.length > 5}
|
||||
<li class="text-secondary">...and {changedResources.length - 5} more</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<Button
|
||||
on:click={createMergeRequest}
|
||||
disabled={creating || !mergeTitle.trim()}
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Merge Request'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="border"
|
||||
on:click={() => showCreateForm = false}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,384 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore, userStore } from '$lib/stores'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import { page } from '$app/stores'
|
||||
import { onMount } from 'svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { GitPullRequest, ArrowLeft, GitMerge, AlertTriangle, Check, X, Clock } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import DiffEditor from '$lib/components/DiffEditor.svelte'
|
||||
|
||||
const mergeRequestId = $page.params.merge_request_id
|
||||
|
||||
let mergeRequest = $state<any>(null)
|
||||
let changes = $state<any[]>([])
|
||||
let loading = $state(true)
|
||||
let merging = $state(false)
|
||||
let rejecting = $state(false)
|
||||
let selectedChange = $state<any>(null)
|
||||
let showDiff = $state(false)
|
||||
|
||||
// Conflict resolution
|
||||
let resolvingConflict = $state(false)
|
||||
let conflictResolution = $state('')
|
||||
|
||||
async function loadMergeRequest() {
|
||||
if (!$workspaceStore || !$userStore?.is_admin) {
|
||||
goto(`${base}/`)
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
try {
|
||||
// Load merge request details
|
||||
const requestResponse = await fetch(`/api/w/${$workspaceStore}/workspaces/merge/merge_request/${mergeRequestId}`)
|
||||
if (requestResponse.ok) {
|
||||
mergeRequest = await requestResponse.json()
|
||||
} else {
|
||||
sendUserToast('Merge request not found', true)
|
||||
goto(`${base}/workspace_merge`)
|
||||
return
|
||||
}
|
||||
|
||||
// Load changes
|
||||
const changesResponse = await fetch(`/api/w/${$workspaceStore}/workspaces/merge/merge_request/${mergeRequestId}/changes`)
|
||||
if (changesResponse.ok) {
|
||||
changes = await changesResponse.json()
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
sendUserToast('Failed to load merge request', true)
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function executeMerge() {
|
||||
if (!mergeRequest || merging) return
|
||||
|
||||
merging = true
|
||||
try {
|
||||
const response = await fetch(`/api/w/${$workspaceStore}/workspaces/merge/merge_request/${mergeRequestId}/execute`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
sendUserToast('Merge completed successfully!')
|
||||
goto(`${base}/`)
|
||||
} else {
|
||||
const error = await response.text()
|
||||
sendUserToast(`Failed to merge: ${error}`, true)
|
||||
}
|
||||
} catch (error) {
|
||||
sendUserToast('Failed to execute merge', true)
|
||||
console.error(error)
|
||||
} finally {
|
||||
merging = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveConflict(changeId: number, strategy: string) {
|
||||
if (resolvingConflict) return
|
||||
|
||||
resolvingConflict = true
|
||||
try {
|
||||
const response = await fetch(`/api/w/${$workspaceStore}/workspaces/merge/merge_request/${mergeRequestId}/resolve_conflict`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
change_id: changeId,
|
||||
resolution_strategy: strategy
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
sendUserToast('Conflict resolved')
|
||||
await loadMergeRequest()
|
||||
} else {
|
||||
const error = await response.text()
|
||||
sendUserToast(`Failed to resolve conflict: ${error}`, true)
|
||||
}
|
||||
} catch (error) {
|
||||
sendUserToast('Failed to resolve conflict', true)
|
||||
console.error(error)
|
||||
} finally {
|
||||
resolvingConflict = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResourceContent(change: any) {
|
||||
selectedChange = change
|
||||
// Here we would load the actual content for diff viewing
|
||||
// For now, we'll show a placeholder
|
||||
showDiff = true
|
||||
}
|
||||
|
||||
onMount(loadMergeRequest)
|
||||
|
||||
// Computed values
|
||||
let conflictedChanges = $derived(changes.filter(c => c.has_conflict && !c.resolved))
|
||||
let resolvedChanges = $derived(changes.filter(c => c.resolved))
|
||||
let normalChanges = $derived(changes.filter(c => !c.has_conflict))
|
||||
let canMerge = $derived(mergeRequest?.status === 'pending' && conflictedChanges.length === 0)
|
||||
let statusColor = $derived(
|
||||
mergeRequest?.status === 'pending' ? 'text-yellow-600 bg-yellow-100' :
|
||||
mergeRequest?.status === 'approved' ? 'text-green-600 bg-green-100' :
|
||||
mergeRequest?.status === 'merged' ? 'text-blue-600 bg-blue-100' :
|
||||
mergeRequest?.status === 'conflicted' ? 'text-red-600 bg-red-100' :
|
||||
'text-gray-600 bg-gray-100'
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Merge Request #{mergeRequestId} - {$workspaceStore}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<div class="container mx-auto p-6 max-w-4xl">
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mergeRequest}
|
||||
<div class="container mx-auto p-6 max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<Button variant="border" size="sm" href="{base}/workspace_merge">
|
||||
<ArrowLeft size={16} />
|
||||
Back to merge overview
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<GitPullRequest size={20} class="text-blue-500" />
|
||||
<h1 class="text-2xl font-bold">Merge Request #{mergeRequestId}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merge Request Info -->
|
||||
<div class="bg-surface-secondary rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{mergeRequest.title}</h2>
|
||||
<div class="text-sm text-secondary mt-1">
|
||||
From <span class="font-mono">{mergeRequest.source_workspace_id}</span>
|
||||
to <span class="font-mono">{mergeRequest.target_workspace_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class={twMerge('px-3 py-1 rounded-full text-sm font-medium', statusColor)}>
|
||||
{mergeRequest.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if mergeRequest.description}
|
||||
<div class="mb-4">
|
||||
<h3 class="font-medium mb-2">Description</h3>
|
||||
<p class="text-sm text-secondary whitespace-pre-wrap">{mergeRequest.description}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-6 text-sm text-secondary">
|
||||
<div>Created by {mergeRequest.created_by}</div>
|
||||
<div>Created {new Date(mergeRequest.created_at).toLocaleString()}</div>
|
||||
{#if mergeRequest.auto_merge}
|
||||
<div class="flex items-center gap-1 text-blue-600">
|
||||
<Check size={14} />
|
||||
Auto-merge enabled
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Changes Summary -->
|
||||
<div class="grid md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-surface-secondary rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<span class="font-medium">Normal Changes</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold">{normalChanges.length}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-secondary rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
<span class="font-medium">Conflicts</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold">{conflictedChanges.length}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-secondary rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-3 h-3 bg-blue-500 rounded-full"></div>
|
||||
<span class="font-medium">Resolved</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold">{resolvedChanges.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conflicts Section -->
|
||||
{#if conflictedChanges.length > 0}
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<AlertTriangle size={20} class="text-red-500" />
|
||||
<h3 class="font-bold text-red-800">Conflicts Requiring Resolution</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each conflictedChanges as change}
|
||||
<div class="bg-white border border-red-200 rounded p-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<span class="font-mono text-sm">{change.resource_type}: {change.resource_path}</span>
|
||||
<span class="ml-2 px-2 py-1 bg-orange-100 text-orange-800 text-xs rounded">
|
||||
{change.change_type}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="xs" on:click={() => loadResourceContent(change)}>
|
||||
View Diff
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if change.conflict_reason}
|
||||
<div class="text-sm text-red-600 mb-3">{change.conflict_reason}</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
on:click={() => resolveConflict(change.id, 'take_source')}
|
||||
disabled={resolvingConflict}
|
||||
>
|
||||
Take Source
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
on:click={() => resolveConflict(change.id, 'take_target')}
|
||||
disabled={resolvingConflict}
|
||||
>
|
||||
Take Target
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- All Changes -->
|
||||
<div class="bg-surface-secondary rounded-lg p-4 mb-6">
|
||||
<h3 class="font-bold mb-4">All Changes ({changes.length})</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each changes as change}
|
||||
<div class="flex items-center justify-between p-3 bg-surface rounded border">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class={twMerge(
|
||||
'w-3 h-3 rounded-full',
|
||||
change.has_conflict && !change.resolved ? 'bg-red-500' :
|
||||
change.resolved ? 'bg-blue-500' : 'bg-green-500'
|
||||
)}></div>
|
||||
|
||||
<div>
|
||||
<span class="font-mono text-sm">{change.resource_type}: {change.resource_path}</span>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class={twMerge(
|
||||
'px-2 py-1 text-xs rounded',
|
||||
change.change_type === 'added' ? 'bg-green-100 text-green-800' :
|
||||
change.change_type === 'modified' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
)}>
|
||||
{change.change_type}
|
||||
</span>
|
||||
|
||||
{#if change.has_conflict}
|
||||
{#if change.resolved}
|
||||
<span class="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
|
||||
Resolved: {change.resolution_strategy}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="px-2 py-1 text-xs bg-red-100 text-red-800 rounded">
|
||||
Conflict
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="xs" variant="border" on:click={() => loadResourceContent(change)}>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
{#if mergeRequest.status === 'pending'}
|
||||
<div class="flex gap-4">
|
||||
{#if canMerge}
|
||||
<Button on:click={executeMerge} disabled={merging}>
|
||||
<GitMerge size={16} />
|
||||
{merging ? 'Merging...' : 'Execute Merge'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button disabled title="Resolve all conflicts before merging">
|
||||
<AlertTriangle size={16} />
|
||||
Resolve Conflicts First
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Button variant="border" disabled={rejecting}>
|
||||
<X size={16} />
|
||||
Reject Merge Request
|
||||
</Button>
|
||||
</div>
|
||||
{:else if mergeRequest.status === 'merged'}
|
||||
<div class="flex items-center gap-2 text-green-600">
|
||||
<Check size={20} />
|
||||
<span class="font-medium">Merge completed successfully</span>
|
||||
{#if mergeRequest.merged_at}
|
||||
<span class="text-sm">
|
||||
on {new Date(mergeRequest.merged_at).toLocaleString()}
|
||||
by {mergeRequest.merged_by}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Diff Modal -->
|
||||
{#if showDiff && selectedChange}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-surface rounded-lg w-full h-full max-w-6xl max-h-[90vh] m-4 flex flex-col">
|
||||
<div class="flex items-center justify-between p-4 border-b">
|
||||
<h3 class="font-bold">
|
||||
{selectedChange.resource_type}: {selectedChange.resource_path}
|
||||
</h3>
|
||||
<Button size="sm" variant="border" on:click={() => showDiff = false}>
|
||||
<X size={16} />
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-4">
|
||||
<div class="h-full border rounded">
|
||||
<!-- Placeholder for diff content -->
|
||||
<div class="h-full flex items-center justify-center text-secondary">
|
||||
<div class="text-center">
|
||||
<div class="text-lg mb-2">Diff View</div>
|
||||
<div class="text-sm">
|
||||
Content comparison would be shown here
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
Reference in New Issue
Block a user