Compare commits

...

4 Commits

Author SHA1 Message Date
wendrul
aebb06a1bb Fix errors 2025-07-03 08:00:37 +02:00
wendrul
0a471a77dd New claude iteration on the feature 2025-07-03 07:34:35 +02:00
wendrul
e680e801b0 Fix migration 2025-07-02 11:05:18 +02:00
wendrul
5537d4d6b3 Add workspaces as git worktrees by claude 2025-07-01 06:53:57 +02:00
12 changed files with 2479 additions and 5 deletions

View File

@@ -0,0 +1,4 @@
-- Drop workspace forking tables
DROP TABLE IF EXISTS forked_resource_refs;
DROP TABLE IF EXISTS workspace_fork;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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;

View 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))
}

View 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(()))
}

View File

@@ -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))
}

View File

@@ -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

View File

@@ -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"
>&leftarrow; Back to workspaces</Button
>
<Button
disabled={checking ||
errorId != '' ||
!name ||
(!automateUsernameCreation && (errorUser != '' || !username)) ||
!id}
on:click={forkWorkspace}
>
Fork workspace
</Button>
</div>
</CenteredModal>

View 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}

View File

@@ -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}