mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: add deploy restriction rule and fork review requests (#8804)
* feat: add deploy restriction rule and fork review requests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref.txt for fork review requests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review comments on fork review requests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: rename fork review requests to deployment requests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref.txt for deployment request rename Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: inline deployment request panel into deploy layout Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: place Request deployment button to the left of Deploy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: inline fork triggers into main deploy list Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: open real trigger detail drawer for inline fork triggers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: email notifications for merge completion and reply pings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update deployment_request + protection_rule tables on workspace id rename Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to 972893c3870e4c4a70a35748abed282d88904805 This commit updates the EE repository reference after PR #528 was merged in windmill-ee-private. Previous ee-repo-ref: 5684d1c17d930b17849c1e5d7577891e64682d45 New ee-repo-ref: 972893c3870e4c4a70a35748abed282d88904805 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT EXISTS(\n SELECT 1 FROM workspace_diff\n WHERE source_workspace_id = $1\n AND fork_workspace_id = $2\n AND kind = $3\n AND path = $4\n ) as \"exists!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "0ed4ccab404d5487b87bf2c04abfc3377a9e6e8a616f0aa90ddd49eff4a8e670"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO workspace_fork_deployment_request_comment\n (request_id, parent_id, author, author_email, body, anchor_kind, anchor_path)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, created_at\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1e284b9415a6a5e2485e69cf704a999325a4921dde8039f96712fd4106cae2ed"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_fork_deployment_request_comment c\n SET obsolete = true\n FROM workspace_fork_deployment_request r\n WHERE c.request_id = r.id\n AND r.fork_workspace_id = $1\n AND r.closed_at IS NULL\n AND c.anchor_kind = $2\n AND c.anchor_path = $3\n AND c.obsolete = false\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "25bef98423d075337bc6cdcd88b0a7962fd4e1d96e06a5162d248da21566ccbe"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_fork_deployment_request SET fork_workspace_id = $1 WHERE fork_workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2fe88c8d79ca5dd4b8f806b2fb11a43ecf2b3d70c459cfd7622e5ca82eb20eb0"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT username, email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1 ORDER BY username",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2ff6b0d240b88fd5654bb46f9913583fea35a4653f73c0840584f21af5de2739"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM usr u\n WHERE u.workspace_id = $1\n AND u.email = $2\n AND u.disabled = false\n AND (\n u.is_admin = true\n OR EXISTS (\n SELECT 1 FROM usr_to_group g\n WHERE g.workspace_id = $1\n AND g.group_ = $3\n AND g.usr = u.username\n )\n )\n ) as \"exists!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "330b4697892fcb85741ba13e6620eeeea12d49c38963b611988635a68faf9b0c"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT parent_id, author_email FROM workspace_fork_deployment_request_comment WHERE id = $1 AND request_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "parent_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "author_email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "35cb70e555469fea3a65e199f72b2ffbd6b7330dff606c2f6910951baed551ef"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_fork_deployment_request SET closed_at = now(), closed_reason = 'cancelled' WHERE id = $1 AND closed_at IS NULL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4943323bbcf93bd6b080f946f3fc7497ef63412ce30b634e43eb2c46c78c82ad"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO workspace_fork_deployment_request_assignee (request_id, username, email)\n SELECT $1, u, e\n FROM UNNEST($2::text[], $3::text[]) AS t(u, e)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4af18a140d2eceb07b450077f00ac03d555a56d79c8aa302c893009b5cfdc80d"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_fork_deployment_request SET source_workspace_id = $1 WHERE source_workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "508fd3937cc0cad51852a797deb040034e83e9ee6079de140eebc70bc41c14f0"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "575598f84c6ee7c5b5d961d8fc083fda7a128e714de9380a1be13096ab945602"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_fork_deployment_request SET closed_at = now(), closed_reason = 'merged' WHERE id = $1 AND closed_at IS NULL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5fc750b6e52b7fab11876240206961e2af3f749d66e9e07fc84667bbb685a452"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_fork_deployment_request_comment c\n SET obsolete = true\n FROM workspace_fork_deployment_request r\n WHERE c.request_id = r.id\n AND r.fork_workspace_id = 'fork-ws'\n AND r.closed_at IS NULL\n AND c.anchor_kind = 'script'\n AND c.anchor_path = 'f/shared/script1'\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6272b3b073d26bd11063c1ab00199fb6de9c13f8d02195a425b3fea2cc71c7c5"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_protection_rule SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6edb794d2005c233e621ffd7b0ae67eeebe7332d941baa5e4d210588ee2d9d55"
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, parent_id, author, author_email, body,\n anchor_kind, anchor_path, obsolete, created_at\n FROM workspace_fork_deployment_request_comment\n WHERE request_id = $1\n ORDER BY created_at ASC, id ASC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "parent_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "author",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "author_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "body",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "anchor_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "anchor_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "obsolete",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6f01ccf15df0e73b465dd0fbe3fcb3945a2368d03644fce1cd3bb35faaa18460"
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, source_workspace_id, fork_workspace_id, requested_by,\n requested_by_email, requested_at\n FROM workspace_fork_deployment_request\n WHERE source_workspace_id = $1\n AND fork_workspace_id = $2\n AND closed_at IS NULL\n LIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "source_workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "fork_workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "requested_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "requested_by_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "requested_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "709593e036492fefa7be7ed6e44689090528db9f77ad355278a563bf24da72c3"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT u.username, u.email, u.is_admin\n FROM usr u\n WHERE u.workspace_id = $1\n AND u.disabled = false\n AND (\n u.is_admin = true\n OR EXISTS (\n SELECT 1 FROM usr_to_group g\n WHERE g.workspace_id = $1\n AND g.group_ = $2\n AND g.usr = u.username\n )\n )\n ORDER BY u.username\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "is_admin",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "71d8af5759b4c6af0aa760911094ced0a03d482d7891a61f37dfedfd8fdc66db"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) as \"c!\" FROM workspace_fork_deployment_request_comment WHERE request_id = $1 AND obsolete = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "c!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "826621e07af88f94592143def15c8c27267e3fc19e92725257a487a58e34e7ae"
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT source_workspace_id, requested_by, requested_by_email, closed_at FROM workspace_fork_deployment_request WHERE id = $1 AND fork_workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "source_workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "requested_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "requested_by_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "closed_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "894210b1db20ecf08a57851d845a6c84a8d743b2914ea5b1f3fd93f97f56f2b5"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_fork_deployment_request_comment SET obsolete = true WHERE request_id = $1 AND obsolete = false",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8e4bcfa5e7ccec5d26b5602ed8697d17dd9035854126a3f76a86b613e3975d92"
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, source_workspace_id, closed_at, requested_by_email FROM workspace_fork_deployment_request WHERE id = $1 AND fork_workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "source_workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "closed_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "requested_by_email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "950f109a4cd8d8666f9d3945e41cc7328b149072cd86bea4b6a1e1c09aabe999"
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO workspace_fork_deployment_request\n (source_workspace_id, fork_workspace_id, requested_by, requested_by_email)\n VALUES ($1, $2, $3, $4)\n RETURNING id, requested_at\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "requested_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a9fe245eee63eab4f7c3cbf1cdd0a5d6a1b20cc9067b83c2a3fb2d1e536f5a6a"
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT u.username, u.email\n FROM usr u\n WHERE u.workspace_id = $1\n AND u.disabled = false\n AND u.username = ANY($2::text[])\n AND (\n u.is_admin = true\n OR EXISTS (\n SELECT 1 FROM usr_to_group g\n WHERE g.workspace_id = $1\n AND g.group_ = $3\n AND g.usr = u.username\n )\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b3e9e8c625cf73251e70207d67d78543a91d018048a1ba921dcd0da406c3bbf6"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT requested_by, closed_at FROM workspace_fork_deployment_request WHERE id = $1 AND fork_workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "requested_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "closed_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bccd4d5e772f4967f282d6c014bb4a9888d3db2d5edfa44f87585a4d80cec461"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_fork_deployment_request\n SET closed_at = now(),\n closed_reason = 'merged'\n WHERE source_workspace_id = $1\n AND fork_workspace_id = $2\n AND closed_at IS NULL\n RETURNING id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e7947f973f5542775026edcc03e742043a39726dedfcfdda5aab1629264aab8c"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
5f912375340225876a8c1740c3301f39cd6cbd6d
|
||||
972893c3870e4c4a70a35748abed282d88904805
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS workspace_fork_deployment_request_comment;
|
||||
DROP TABLE IF EXISTS workspace_fork_deployment_request_assignee;
|
||||
DROP TABLE IF EXISTS workspace_fork_deployment_request;
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Fork deployment requests: a lightweight request + comments layer on top
|
||||
-- of the workspace fork/merge flow.
|
||||
--
|
||||
-- When a fork author can't deploy to the parent workspace (because the
|
||||
-- RestrictDeployToDeployers rule is active and they aren't admin / wm_deployers),
|
||||
-- they open a deployment request naming one or more eligible deployers as
|
||||
-- assignees. Assignees comment, the chosen deployer performs the merge,
|
||||
-- and the request auto-closes on success. There can only be one open
|
||||
-- request per (source, fork) pair — enforced by the partial unique index.
|
||||
|
||||
CREATE TABLE workspace_fork_deployment_request (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
source_workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
fork_workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
requested_by VARCHAR(255) NOT NULL,
|
||||
requested_by_email VARCHAR(255) NOT NULL,
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
closed_at TIMESTAMPTZ,
|
||||
closed_reason VARCHAR(20)
|
||||
);
|
||||
|
||||
-- Only one open deployment request per (source, fork). Closed requests
|
||||
-- accumulate freely for history.
|
||||
CREATE UNIQUE INDEX workspace_fork_deployment_request_open_unique
|
||||
ON workspace_fork_deployment_request (source_workspace_id, fork_workspace_id)
|
||||
WHERE closed_at IS NULL;
|
||||
|
||||
CREATE INDEX workspace_fork_deployment_request_fork_idx
|
||||
ON workspace_fork_deployment_request (fork_workspace_id, closed_at);
|
||||
|
||||
-- Assignees for a deployment request — the users asked to merge. Username
|
||||
-- + email are snapshotted at request creation so notification dispatch
|
||||
-- doesn't depend on the user's current workspace membership.
|
||||
CREATE TABLE workspace_fork_deployment_request_assignee (
|
||||
request_id BIGINT NOT NULL REFERENCES workspace_fork_deployment_request(id) ON DELETE CASCADE,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (request_id, username)
|
||||
);
|
||||
|
||||
-- Comments on a deployment request. `parent_id` threads replies under a
|
||||
-- top-level comment (2 levels max, enforced in application code).
|
||||
-- `anchor_kind`/`anchor_path` pin a comment to a specific diff row; both
|
||||
-- NULL = general comment about the whole fork. Anchored comments flip to
|
||||
-- obsolete when the underlying item is updated in the fork, and all
|
||||
-- comments on a request are marked obsolete on successful merge.
|
||||
CREATE TABLE workspace_fork_deployment_request_comment (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
request_id BIGINT NOT NULL REFERENCES workspace_fork_deployment_request(id) ON DELETE CASCADE,
|
||||
parent_id BIGINT REFERENCES workspace_fork_deployment_request_comment(id) ON DELETE CASCADE,
|
||||
author VARCHAR(255) NOT NULL,
|
||||
author_email VARCHAR(255) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
anchor_kind VARCHAR(50),
|
||||
anchor_path VARCHAR(255),
|
||||
obsolete BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT anchor_kind_path_both_or_neither
|
||||
CHECK ((anchor_kind IS NULL) = (anchor_path IS NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX workspace_fork_deployment_request_comment_request_idx
|
||||
ON workspace_fork_deployment_request_comment (request_id);
|
||||
|
||||
-- Anchored-comment lookup: find all open-request comments pinned to a
|
||||
-- specific (kind, path) — used when we mark comments obsolete after an
|
||||
-- item in the fork is updated.
|
||||
CREATE INDEX workspace_fork_deployment_request_comment_anchor_idx
|
||||
ON workspace_fork_deployment_request_comment (request_id, anchor_kind, anchor_path)
|
||||
WHERE anchor_kind IS NOT NULL;
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Fixture for fork deployment request integration tests.
|
||||
-- Two workspaces: 'parent-ws' (source) and 'fork-ws' (fork, with
|
||||
-- parent_workspace_id=parent-ws). wm_deployers exists in the parent with
|
||||
-- one member. Users get distinct emails/usernames/tokens to avoid
|
||||
-- collisions with rows inserted by migrations.
|
||||
|
||||
INSERT INTO workspace (id, name, owner) VALUES
|
||||
('parent-ws', 'Parent WS', 'fdr-admin');
|
||||
INSERT INTO workspace (id, name, owner, parent_workspace_id) VALUES
|
||||
('fork-ws', 'Fork WS', 'fdr-admin', 'parent-ws');
|
||||
|
||||
INSERT INTO workspace_key (workspace_id, kind, key) VALUES
|
||||
('parent-ws', 'cloud', 'parent-key'),
|
||||
('fork-ws', 'cloud', 'fork-key');
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id) VALUES
|
||||
('parent-ws'),
|
||||
('fork-ws');
|
||||
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
|
||||
('parent-ws', 'all', 'All users', '{}'),
|
||||
('parent-ws', 'wm_deployers', 'Deployers', '{}'),
|
||||
('fork-ws', 'all', 'All users', '{}'),
|
||||
('fork-ws', 'wm_deployers', 'Deployers', '{}');
|
||||
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
|
||||
VALUES ('fdr-admin@windmill.dev', 'x', 'password', true, true, 'FDR Admin', 'fdr-admin');
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
|
||||
VALUES ('fdr-deployer@windmill.dev', 'x', 'password', false, true, 'FDR Deployer');
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
|
||||
VALUES ('fdr-owner@windmill.dev', 'x', 'password', false, true, 'FDR Owner');
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
|
||||
VALUES ('fdr-random@windmill.dev', 'x', 'password', false, true, 'FDR Random');
|
||||
|
||||
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
|
||||
('parent-ws', 'fdr-admin@windmill.dev', 'fdr-admin', true, 'Admin'),
|
||||
('parent-ws', 'fdr-deployer@windmill.dev', 'fdr-deployer', false, 'User'),
|
||||
('parent-ws', 'fdr-random@windmill.dev', 'fdr-random', false, 'User'),
|
||||
('fork-ws', 'fdr-admin@windmill.dev', 'fdr-admin', true, 'Admin'),
|
||||
('fork-ws', 'fdr-owner@windmill.dev', 'fdr-owner', false, 'User'),
|
||||
('fork-ws', 'fdr-deployer@windmill.dev', 'fdr-deployer', false, 'User'),
|
||||
('fork-ws', 'fdr-random@windmill.dev', 'fdr-random', false, 'User');
|
||||
|
||||
INSERT INTO usr_to_group(workspace_id, group_, usr) VALUES
|
||||
('parent-ws', 'wm_deployers', 'fdr-deployer');
|
||||
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
|
||||
VALUES (encode(sha256('FDR_ADMIN_TOKEN'::bytea), 'hex'), 'FDR_ADMIN_', 'FDR_ADMIN_TOKEN', 'fdr-admin@windmill.dev', 't', true);
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
|
||||
VALUES (encode(sha256('FDR_DEPLOYER_TOKEN'::bytea), 'hex'), 'FDR_DEPLOY', 'FDR_DEPLOYER_TOKEN', 'fdr-deployer@windmill.dev', 't', false);
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
|
||||
VALUES (encode(sha256('FDR_OWNER_TOKEN'::bytea), 'hex'), 'FDR_OWNER_', 'FDR_OWNER_TOKEN', 'fdr-owner@windmill.dev', 't', false);
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
|
||||
VALUES (encode(sha256('FDR_RANDOM_TOKEN'::bytea), 'hex'), 'FDR_RANDOM', 'FDR_RANDOM_TOKEN', 'fdr-random@windmill.dev', 't', false);
|
||||
|
||||
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin;
|
||||
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user;
|
||||
|
||||
-- Seed a workspace_diff row so anchored comments can validate against an
|
||||
-- existing (kind, path) tuple for this (parent, fork) pair.
|
||||
INSERT INTO workspace_diff
|
||||
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
|
||||
VALUES ('parent-ws', 'fork-ws', 'f/shared/script1', 'script', 1, 0, true);
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Fixture for the RestrictDeployToDeployers protection rule test.
|
||||
-- Uses a dedicated workspace id + dedicated token prefixes so the process-
|
||||
-- global AUTH_CACHE and PROTECTION_RULES_CACHE lazy_statics don't race with
|
||||
-- the parallel test_protection_rules test (which also uses test-workspace).
|
||||
|
||||
INSERT INTO workspace (id, name, owner) VALUES
|
||||
('rdd-ws', 'Restrict Deploy WS', 'rdd-admin');
|
||||
|
||||
INSERT INTO workspace_key (workspace_id, kind, key) VALUES
|
||||
('rdd-ws', 'cloud', 'rdd-key');
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id) VALUES
|
||||
('rdd-ws');
|
||||
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
|
||||
('rdd-ws', 'all', 'All users', '{}'),
|
||||
('rdd-ws', 'wm_deployers', 'Deployers', '{}');
|
||||
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
|
||||
VALUES ('rdd-admin@windmill.dev', 'x', 'password', true, true, 'RDD Admin', 'rdd-admin');
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
|
||||
VALUES ('rdd-deployer@windmill.dev', 'x', 'password', false, true, 'RDD Deployer');
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
|
||||
VALUES ('rdd-user@windmill.dev', 'x', 'password', false, true, 'RDD User');
|
||||
|
||||
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
|
||||
('rdd-ws', 'rdd-admin@windmill.dev', 'rdd-admin', true, 'Admin'),
|
||||
('rdd-ws', 'rdd-deployer@windmill.dev', 'rdd-deployer', false, 'User'),
|
||||
('rdd-ws', 'rdd-user@windmill.dev', 'rdd-user', false, 'User');
|
||||
|
||||
INSERT INTO usr_to_group(workspace_id, group_, usr) VALUES
|
||||
('rdd-ws', 'wm_deployers', 'rdd-deployer');
|
||||
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
|
||||
VALUES (encode(sha256('RDD_ADMIN_TOKEN'::bytea), 'hex'), 'RDD_ADMIN_', 'RDD_ADMIN_TOKEN', 'rdd-admin@windmill.dev', 't', true);
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
|
||||
VALUES (encode(sha256('RDD_DEPLOYER_TOKEN'::bytea), 'hex'), 'RDD_DEPLOY', 'RDD_DEPLOYER_TOKEN', 'rdd-deployer@windmill.dev', 't', false);
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
|
||||
VALUES (encode(sha256('RDD_USER_TOKEN'::bytea), 'hex'), 'RDD_USER_T', 'RDD_USER_TOKEN', 'rdd-user@windmill.dev', 't', false);
|
||||
|
||||
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin;
|
||||
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user;
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Integration tests for fork deployment requests + comments.
|
||||
//!
|
||||
//! Covers: one-open-per-fork constraint, assignee ACL validation, anchor
|
||||
//! obsolescence when an item in the fork changes, merge-close lifecycle,
|
||||
//! replies, reply-to-reply rejection, and cancel ACL.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("fork_deployment_requests"))]
|
||||
async fn test_fork_deployment_request_lifecycle(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let fork_base = format!("http://localhost:{port}/api/w/fork-ws");
|
||||
|
||||
// ---- 1. eligible_deployers returns admin + deployer, not random ----
|
||||
let resp = authed(
|
||||
client().get(format!("{fork_base}/deployment_request/eligible_deployers")),
|
||||
"FDR_OWNER_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let deployers: Vec<Value> = resp.json().await?;
|
||||
let usernames: Vec<&str> = deployers
|
||||
.iter()
|
||||
.filter_map(|d| d.get("username").and_then(|v| v.as_str()))
|
||||
.collect();
|
||||
assert!(usernames.contains(&"fdr-admin"));
|
||||
assert!(usernames.contains(&"fdr-deployer"));
|
||||
assert!(!usernames.contains(&"fdr-random"));
|
||||
|
||||
// ---- 2. createDeploymentRequest with valid assignees succeeds ----
|
||||
let resp = authed(
|
||||
client().post(format!("{fork_base}/deployment_request")),
|
||||
"FDR_OWNER_TOKEN",
|
||||
)
|
||||
.json(&json!({ "assignees": ["fdr-admin", "fdr-deployer"] }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"create: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
let created: Value = resp.json().await?;
|
||||
let request_id = created.get("id").and_then(|v| v.as_i64()).unwrap();
|
||||
assert_eq!(
|
||||
created.get("requested_by").and_then(|v| v.as_str()),
|
||||
Some("fdr-owner")
|
||||
);
|
||||
assert_eq!(
|
||||
created
|
||||
.get("assignees")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len()),
|
||||
Some(2)
|
||||
);
|
||||
|
||||
// ---- 3. second create fails with 409 because one-open constraint ----
|
||||
let resp = authed(
|
||||
client().post(format!("{fork_base}/deployment_request")),
|
||||
"FDR_OWNER_TOKEN",
|
||||
)
|
||||
.json(&json!({ "assignees": ["fdr-admin"] }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
409,
|
||||
"second create should return 409 Conflict: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// ---- 4. getOpenRequest returns the request ----
|
||||
let resp = authed(
|
||||
client().get(format!("{fork_base}/deployment_request/open")),
|
||||
"FDR_RANDOM_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let open: Value = resp.json().await?;
|
||||
assert_eq!(open.get("id").and_then(|v| v.as_i64()), Some(request_id));
|
||||
|
||||
// ---- 5. random user posts a general comment (anyone with fork access) ----
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"{fork_base}/deployment_request/{request_id}/comment"
|
||||
)),
|
||||
"FDR_RANDOM_TOKEN",
|
||||
)
|
||||
.json(&json!({ "body": "Looks good overall" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"general comment: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
let top_comment: Value = resp.json().await?;
|
||||
let top_id = top_comment.get("id").and_then(|v| v.as_i64()).unwrap();
|
||||
|
||||
// ---- 6. admin posts an anchored comment ----
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"{fork_base}/deployment_request/{request_id}/comment"
|
||||
)),
|
||||
"FDR_ADMIN_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"body": "Can you revisit this script?",
|
||||
"anchor_kind": "script",
|
||||
"anchor_path": "f/shared/script1"
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"anchored comment: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
let anchored: Value = resp.json().await?;
|
||||
let anchored_id = anchored.get("id").and_then(|v| v.as_i64()).unwrap();
|
||||
assert_eq!(
|
||||
anchored.get("anchor_kind").and_then(|v| v.as_str()),
|
||||
Some("script")
|
||||
);
|
||||
assert_eq!(
|
||||
anchored.get("obsolete").and_then(|v| v.as_bool()),
|
||||
Some(false)
|
||||
);
|
||||
|
||||
// ---- 7. reply to top-level comment ----
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"{fork_base}/deployment_request/{request_id}/comment"
|
||||
)),
|
||||
"FDR_OWNER_TOKEN",
|
||||
)
|
||||
.json(&json!({ "body": "Thanks!", "parent_id": top_id }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let reply: Value = resp.json().await?;
|
||||
let reply_id = reply.get("id").and_then(|v| v.as_i64()).unwrap();
|
||||
assert_eq!(
|
||||
reply.get("parent_id").and_then(|v| v.as_i64()),
|
||||
Some(top_id)
|
||||
);
|
||||
|
||||
// ---- 7b. reply-to-reply is rejected (2-level max) ----
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"{fork_base}/deployment_request/{request_id}/comment"
|
||||
)),
|
||||
"FDR_OWNER_TOKEN",
|
||||
)
|
||||
.json(&json!({ "body": "nested!", "parent_id": reply_id }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
400,
|
||||
"reply-to-reply should be rejected: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// ---- 8. anchored comment becomes obsolete when item changes in fork ----
|
||||
// The CE stub of `mark_anchor_obsolete` is a no-op (the EE module fills
|
||||
// it in). Simulate the EE effect directly so the CE test can verify
|
||||
// the GET response renders obsolete comments correctly.
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE workspace_fork_deployment_request_comment c
|
||||
SET obsolete = true
|
||||
FROM workspace_fork_deployment_request r
|
||||
WHERE c.request_id = r.id
|
||||
AND r.fork_workspace_id = 'fork-ws'
|
||||
AND r.closed_at IS NULL
|
||||
AND c.anchor_kind = 'script'
|
||||
AND c.anchor_path = 'f/shared/script1'
|
||||
"#
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(
|
||||
client().get(format!("{fork_base}/deployment_request/open")),
|
||||
"FDR_OWNER_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let open: Value = resp.json().await?;
|
||||
let comments = open.get("comments").and_then(|v| v.as_array()).unwrap();
|
||||
let anchored_after = comments
|
||||
.iter()
|
||||
.find(|c| c.get("id").and_then(|v| v.as_i64()) == Some(anchored_id))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
anchored_after.get("obsolete").and_then(|v| v.as_bool()),
|
||||
Some(true),
|
||||
"anchored comment should be obsolete"
|
||||
);
|
||||
let top_after = comments
|
||||
.iter()
|
||||
.find(|c| c.get("id").and_then(|v| v.as_i64()) == Some(top_id))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
top_after.get("obsolete").and_then(|v| v.as_bool()),
|
||||
Some(false),
|
||||
"general comment should NOT be obsolete"
|
||||
);
|
||||
|
||||
// ---- 9. non-requester non-admin cannot cancel ----
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"{fork_base}/deployment_request/{request_id}/cancel"
|
||||
)),
|
||||
"FDR_RANDOM_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
!resp.status().is_success(),
|
||||
"random cancel should fail: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// ---- 10. requester can cancel ----
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"{fork_base}/deployment_request/{request_id}/cancel"
|
||||
)),
|
||||
"FDR_OWNER_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"cancel: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
|
||||
// ---- 11. after cancel a new request can be opened ----
|
||||
let resp = authed(
|
||||
client().post(format!("{fork_base}/deployment_request")),
|
||||
"FDR_OWNER_TOKEN",
|
||||
)
|
||||
.json(&json!({ "assignees": ["fdr-admin"] }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"re-create: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
let new_req: Value = resp.json().await?;
|
||||
let new_req_id = new_req.get("id").and_then(|v| v.as_i64()).unwrap();
|
||||
|
||||
// ---- 12. ineligible assignee rejected ----
|
||||
authed(
|
||||
client().post(format!(
|
||||
"{fork_base}/deployment_request/{new_req_id}/cancel"
|
||||
)),
|
||||
"FDR_OWNER_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let resp = authed(
|
||||
client().post(format!("{fork_base}/deployment_request")),
|
||||
"FDR_OWNER_TOKEN",
|
||||
)
|
||||
.json(&json!({ "assignees": ["fdr-random"] }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
400,
|
||||
"ineligible assignee should fail: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// ---- 13. merge-close: create a request, close as merged, comments flip ----
|
||||
let resp = authed(
|
||||
client().post(format!("{fork_base}/deployment_request")),
|
||||
"FDR_OWNER_TOKEN",
|
||||
)
|
||||
.json(&json!({ "assignees": ["fdr-admin"] }))
|
||||
.send()
|
||||
.await?;
|
||||
let final_req: Value = resp.json().await?;
|
||||
let final_id = final_req.get("id").and_then(|v| v.as_i64()).unwrap();
|
||||
|
||||
authed(
|
||||
client().post(format!("{fork_base}/deployment_request/{final_id}/comment")),
|
||||
"FDR_ADMIN_TOKEN",
|
||||
)
|
||||
.json(&json!({ "body": "lgtm" }))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"{fork_base}/deployment_request/{final_id}/close_merged"
|
||||
)),
|
||||
"FDR_ADMIN_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// After close_merged, getOpenRequest returns null.
|
||||
let resp = authed(
|
||||
client().get(format!("{fork_base}/deployment_request/open")),
|
||||
"FDR_OWNER_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let after_merge = resp.text().await?;
|
||||
assert!(
|
||||
after_merge == "null" || after_merge == "",
|
||||
"open should be null after merge-close, got: {}",
|
||||
after_merge
|
||||
);
|
||||
|
||||
// And every comment on the closed request is now obsolete.
|
||||
let obsolete_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) as \"c!\" FROM workspace_fork_deployment_request_comment WHERE request_id = $1 AND obsolete = false",
|
||||
final_id,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
obsolete_count, 0,
|
||||
"all comments on merged request should be obsolete"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Integration tests for workspace protection rulesets.
|
||||
//!
|
||||
//! Tests verify that DisableDirectDeployment protection rules correctly
|
||||
//! block/allow operations based on user permissions.
|
||||
//! Tests verify that DisableDirectDeployment and RestrictDeployToDeployers
|
||||
//! protection rules correctly block/allow operations based on user permissions.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
@@ -304,3 +304,120 @@ async fn test_protection_rules(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test the `RestrictDeployToDeployers` rule.
|
||||
///
|
||||
/// Admins and members of the `wm_deployers` group can deploy, everyone else
|
||||
/// is blocked. Uses a dedicated workspace + token prefixes so it doesn't
|
||||
/// race against `test_protection_rules` on the shared PROTECTION_RULES_CACHE
|
||||
/// and AUTH_CACHE lazy_statics.
|
||||
#[sqlx::test(fixtures("restrict_deploy_to_deployers"))]
|
||||
async fn test_restrict_deploy_to_deployers(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
invalidate_protection_rules_cache("rdd-ws");
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/rdd-ws");
|
||||
|
||||
// Admin creates the rule.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/protection_rules")),
|
||||
"RDD_ADMIN_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"name": "deployers-only",
|
||||
"rules": ["RestrictDeployToDeployers"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"Admin should create rule: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// Admin can still deploy.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"RDD_ADMIN_TOKEN",
|
||||
)
|
||||
.json(&new_script("u/rdd-admin/admin_deploys", "admin"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"Admin should deploy: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// wm_deployers member can deploy.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"RDD_DEPLOYER_TOKEN",
|
||||
)
|
||||
.json(&new_script("u/rdd-deployer/deployer_deploys", "deployer"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"wm_deployers member should deploy: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// Regular non-deployer is blocked.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"RDD_USER_TOKEN",
|
||||
)
|
||||
.json(&new_script("u/rdd-user/blocked", "blocked"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
!resp.status().is_success(),
|
||||
"non-deployer should be blocked: {}",
|
||||
resp.status()
|
||||
);
|
||||
let body = resp.text().await?;
|
||||
assert!(
|
||||
body.contains("wm_deployers"),
|
||||
"Error should mention wm_deployers: {}",
|
||||
body
|
||||
);
|
||||
|
||||
// Extend the rule with the user as a bypass_user — they can then deploy.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/workspaces/protection_rules/deployers-only")),
|
||||
"RDD_ADMIN_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"rules": ["RestrictDeployToDeployers"],
|
||||
"bypass_users": ["rdd-user"],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "Should update rule");
|
||||
invalidate_protection_rules_cache("rdd-ws");
|
||||
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"RDD_USER_TOKEN",
|
||||
)
|
||||
.json(&new_script("u/rdd-user/bypassed", "bypassed"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"bypass_users should allow deploy: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use windmill_api_auth::{
|
||||
auth::{list_tokens_internal, TruncatedTokenWithEmail},
|
||||
check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed,
|
||||
};
|
||||
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
|
||||
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
|
||||
use windmill_common::{
|
||||
utils::{WithStarredInfoQuery, HTTP_CLIENT},
|
||||
webhook::{WebhookMessage, WebhookShared},
|
||||
@@ -439,9 +439,8 @@ async fn create_flow(
|
||||
}
|
||||
check_scopes(&authed, || format!("flows:write:{}", nf.path))?;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -933,9 +932,8 @@ async fn update_flow(
|
||||
let flow_path = flow_path.to_path();
|
||||
check_scopes(&authed, || format!("flows:write:{}", flow_path))?;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -1564,9 +1562,8 @@ async fn archive_flow_by_path(
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("flows:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -1705,9 +1702,8 @@ async fn delete_flow_by_path(
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("flows:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
|
||||
@@ -28,7 +28,7 @@ use windmill_common::{
|
||||
use windmill_common::{
|
||||
error::Error,
|
||||
webhook::{WebhookMessage, WebhookShared},
|
||||
workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult},
|
||||
workspaces::{check_deploy_rules, RuleCheckResult},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -224,9 +224,8 @@ async fn create_folder(
|
||||
Path(w_id): Path<String>,
|
||||
Json(ng): Json<NewFolder>,
|
||||
) -> Result<String> {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -384,9 +383,8 @@ async fn update_folder(
|
||||
) -> Result<String> {
|
||||
use sql_builder::prelude::*;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -709,9 +707,8 @@ async fn delete_folder(
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
|
||||
@@ -14,7 +14,7 @@ use windmill_api_auth::{
|
||||
use windmill_common::{
|
||||
utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT},
|
||||
webhook::{WebhookMessage, WebhookShared},
|
||||
workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult},
|
||||
workspaces::{check_deploy_rules, RuleCheckResult},
|
||||
DB,
|
||||
};
|
||||
use windmill_queue::schedule::clear_schedule;
|
||||
@@ -573,9 +573,8 @@ async fn create_script(
|
||||
Path(w_id): Path<String>,
|
||||
Json(ns): Json<NewScript>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -2143,9 +2142,8 @@ async fn archive_script_by_path(
|
||||
}
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("scripts:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -2222,9 +2220,8 @@ async fn archive_script_by_hash(
|
||||
"Operators cannot archive scripts for security reasons".to_string(),
|
||||
));
|
||||
}
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -2282,9 +2279,8 @@ async fn delete_script_by_hash(
|
||||
Path((w_id, hash)): Path<(String, ScriptHash)>,
|
||||
) -> JsonResult<Script<ScriptRunnableSettingsInline>> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -2354,9 +2350,8 @@ async fn delete_script_by_path(
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -2518,9 +2513,8 @@ async fn delete_scripts_bulk(
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
|
||||
@@ -0,0 +1,732 @@
|
||||
//! Fork deployment requests + comments.
|
||||
//!
|
||||
//! A deployment request is a one-per-open ask from a fork author to one or
|
||||
//! more assignees (admins or wm_deployers in the parent workspace) to merge
|
||||
//! the fork into its parent. Assignees can leave general or anchored
|
||||
//! comments; anyone with fork access can reply. Anchored comments become
|
||||
//! obsolete when the underlying item changes in the fork, and the whole
|
||||
//! request auto-closes on successful merge.
|
||||
//!
|
||||
//! Email dispatch happens via `send_email_if_possible`, which is a no-op on
|
||||
//! OSS builds.
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::email_oss::send_email_if_possible;
|
||||
use windmill_common::{
|
||||
error::{Error, JsonResult, Result},
|
||||
BASE_URL, DB, WM_DEPLOYERS_GROUP,
|
||||
};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/eligible_deployers", get(list_eligible_deployers))
|
||||
.route("/open", get(get_open_deployment_request))
|
||||
.route("/", post(create_deployment_request))
|
||||
.route("/{id}/cancel", post(cancel_deployment_request))
|
||||
.route("/{id}/close_merged", post(close_deployment_request_merged))
|
||||
.route("/{id}/comment", post(create_deployment_request_comment))
|
||||
}
|
||||
|
||||
// ---- DTOs ---------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EligibleDeployer {
|
||||
username: String,
|
||||
email: String,
|
||||
is_admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DeploymentRequest {
|
||||
id: i64,
|
||||
source_workspace_id: String,
|
||||
fork_workspace_id: String,
|
||||
requested_by: String,
|
||||
requested_by_email: String,
|
||||
requested_at: chrono::DateTime<chrono::Utc>,
|
||||
assignees: Vec<DeploymentRequestAssignee>,
|
||||
comments: Vec<DeploymentRequestComment>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DeploymentRequestAssignee {
|
||||
username: String,
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DeploymentRequestComment {
|
||||
id: i64,
|
||||
parent_id: Option<i64>,
|
||||
author: String,
|
||||
author_email: String,
|
||||
body: String,
|
||||
anchor_kind: Option<String>,
|
||||
anchor_path: Option<String>,
|
||||
obsolete: bool,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateDeploymentRequestBody {
|
||||
assignees: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateDeploymentRequestCommentBody {
|
||||
body: String,
|
||||
#[serde(default)]
|
||||
anchor_kind: Option<String>,
|
||||
#[serde(default)]
|
||||
anchor_path: Option<String>,
|
||||
#[serde(default)]
|
||||
parent_id: Option<i64>,
|
||||
}
|
||||
|
||||
// ---- Endpoints ----------------------------------------------------------
|
||||
|
||||
/// List users eligible to be assignees on a deployment request: admins plus
|
||||
/// members of the wm_deployers group in the *parent* workspace. The `{w_id}`
|
||||
/// path parameter is the fork workspace; we resolve its parent and query
|
||||
/// there.
|
||||
///
|
||||
/// Note: any member of the fork workspace can call this and see parent
|
||||
/// admin/deployer usernames + emails. Intentional for the request UX — the
|
||||
/// fork author has to pick an assignee somehow — but documented here as a
|
||||
/// design choice for the shared-fork privacy model.
|
||||
async fn list_eligible_deployers(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<EligibleDeployer>> {
|
||||
let parent = parent_of_fork(&db, &w_id).await?;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT DISTINCT u.username, u.email, u.is_admin
|
||||
FROM usr u
|
||||
WHERE u.workspace_id = $1
|
||||
AND u.disabled = false
|
||||
AND (
|
||||
u.is_admin = true
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM usr_to_group g
|
||||
WHERE g.workspace_id = $1
|
||||
AND g.group_ = $2
|
||||
AND g.usr = u.username
|
||||
)
|
||||
)
|
||||
ORDER BY u.username
|
||||
"#,
|
||||
&parent,
|
||||
WM_DEPLOYERS_GROUP,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(
|
||||
rows.into_iter()
|
||||
.map(|r| EligibleDeployer {
|
||||
username: r.username,
|
||||
email: r.email,
|
||||
is_admin: r.is_admin,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Fetch the open deployment request (if any) for the fork's parent.
|
||||
/// `{w_id}` is the fork workspace.
|
||||
async fn get_open_deployment_request(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Option<DeploymentRequest>> {
|
||||
let parent = parent_of_fork(&db, &w_id).await?;
|
||||
|
||||
let req = sqlx::query!(
|
||||
r#"
|
||||
SELECT id, source_workspace_id, fork_workspace_id, requested_by,
|
||||
requested_by_email, requested_at
|
||||
FROM workspace_fork_deployment_request
|
||||
WHERE source_workspace_id = $1
|
||||
AND fork_workspace_id = $2
|
||||
AND closed_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
parent,
|
||||
&w_id,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
let Some(req) = req else {
|
||||
return Ok(Json(None));
|
||||
};
|
||||
|
||||
let assignees = sqlx::query!(
|
||||
"SELECT username, email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1 ORDER BY username",
|
||||
req.id,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| DeploymentRequestAssignee { username: r.username, email: r.email })
|
||||
.collect();
|
||||
|
||||
let comments = sqlx::query!(
|
||||
r#"
|
||||
SELECT id, parent_id, author, author_email, body,
|
||||
anchor_kind, anchor_path, obsolete, created_at
|
||||
FROM workspace_fork_deployment_request_comment
|
||||
WHERE request_id = $1
|
||||
ORDER BY created_at ASC, id ASC
|
||||
"#,
|
||||
req.id,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| DeploymentRequestComment {
|
||||
id: r.id,
|
||||
parent_id: r.parent_id,
|
||||
author: r.author,
|
||||
author_email: r.author_email,
|
||||
body: r.body,
|
||||
anchor_kind: r.anchor_kind,
|
||||
anchor_path: r.anchor_path,
|
||||
obsolete: r.obsolete,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(Some(DeploymentRequest {
|
||||
id: req.id,
|
||||
source_workspace_id: req.source_workspace_id,
|
||||
fork_workspace_id: req.fork_workspace_id,
|
||||
requested_by: req.requested_by,
|
||||
requested_by_email: req.requested_by_email,
|
||||
requested_at: req.requested_at,
|
||||
assignees,
|
||||
comments,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Open a new deployment request targeting a list of assignees in the
|
||||
/// parent workspace. At most one open request exists per (parent, fork)
|
||||
/// pair; the DB partial unique index enforces this and we surface a 409 on
|
||||
/// conflict.
|
||||
async fn create_deployment_request(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(body): Json<CreateDeploymentRequestBody>,
|
||||
) -> Result<Json<DeploymentRequest>> {
|
||||
// Dedupe assignees so `["alice","alice"]` isn't falsely rejected: the
|
||||
// `= ANY($...)` lookup returns each match once regardless of input
|
||||
// duplicates, so a direct `.len()` comparison would over-count.
|
||||
let unique_assignees: Vec<String> = body
|
||||
.assignees
|
||||
.iter()
|
||||
.collect::<BTreeSet<&String>>()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if unique_assignees.is_empty() {
|
||||
return Err(Error::BadRequest(
|
||||
"At least one assignee is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let parent = parent_of_fork(&db, &w_id).await?;
|
||||
|
||||
// Validate every requested assignee is admin or wm_deployers in parent.
|
||||
let assignee_rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT u.username, u.email
|
||||
FROM usr u
|
||||
WHERE u.workspace_id = $1
|
||||
AND u.disabled = false
|
||||
AND u.username = ANY($2::text[])
|
||||
AND (
|
||||
u.is_admin = true
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM usr_to_group g
|
||||
WHERE g.workspace_id = $1
|
||||
AND g.group_ = $3
|
||||
AND g.usr = u.username
|
||||
)
|
||||
)
|
||||
"#,
|
||||
&parent,
|
||||
&unique_assignees,
|
||||
WM_DEPLOYERS_GROUP,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
if assignee_rows.len() != unique_assignees.len() {
|
||||
return Err(Error::BadRequest(
|
||||
"All assignees must be admins or members of wm_deployers in the parent workspace"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let request_id_row = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO workspace_fork_deployment_request
|
||||
(source_workspace_id, fork_workspace_id, requested_by, requested_by_email)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, requested_at
|
||||
"#,
|
||||
parent,
|
||||
&w_id,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.email,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => Error::Generic(
|
||||
StatusCode::CONFLICT,
|
||||
"A deployment request is already open for this fork; cancel it first".to_string(),
|
||||
),
|
||||
_ => Error::from(e),
|
||||
})?;
|
||||
|
||||
let request_id = request_id_row.id;
|
||||
|
||||
let assignee_usernames: Vec<String> =
|
||||
assignee_rows.iter().map(|r| r.username.clone()).collect();
|
||||
let assignee_emails: Vec<String> = assignee_rows.iter().map(|r| r.email.clone()).collect();
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO workspace_fork_deployment_request_assignee (request_id, username, email)
|
||||
SELECT $1, u, e
|
||||
FROM UNNEST($2::text[], $3::text[]) AS t(u, e)
|
||||
"#,
|
||||
request_id,
|
||||
&assignee_usernames,
|
||||
&assignee_emails,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"fork_deployment_request.create",
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&request_id.to_string()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Send a deployment-request email to each assignee.
|
||||
let base_url = BASE_URL.read().await.clone();
|
||||
let subject = format!(
|
||||
"[Windmill] @{} requested a deployment on fork {w_id}",
|
||||
authed.username
|
||||
);
|
||||
let body_text = format!(
|
||||
"@{} asked you to deploy the fork {w_id} → {parent}.\n\nOpen the compare view: {base_url}/?workspace={w_id}",
|
||||
authed.username
|
||||
);
|
||||
for r in &assignee_rows {
|
||||
if r.email != authed.email {
|
||||
send_email_if_possible(&subject, &body_text, &r.email);
|
||||
}
|
||||
}
|
||||
|
||||
let assignees: Vec<DeploymentRequestAssignee> = assignee_rows
|
||||
.into_iter()
|
||||
.map(|r| DeploymentRequestAssignee { username: r.username, email: r.email })
|
||||
.collect();
|
||||
|
||||
Ok(Json(DeploymentRequest {
|
||||
id: request_id,
|
||||
source_workspace_id: parent,
|
||||
fork_workspace_id: w_id,
|
||||
requested_by: authed.username.clone(),
|
||||
requested_by_email: authed.email.clone(),
|
||||
requested_at: request_id_row.requested_at,
|
||||
assignees,
|
||||
comments: vec![],
|
||||
}))
|
||||
}
|
||||
|
||||
/// Cancel an open deployment request. Only the original requester or an
|
||||
/// admin may cancel — anyone else gets 403.
|
||||
async fn cancel_deployment_request(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, id)): Path<(String, i64)>,
|
||||
) -> Result<String> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT requested_by, closed_at FROM workspace_fork_deployment_request WHERE id = $1 AND fork_workspace_id = $2",
|
||||
id,
|
||||
&w_id,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("deployment request {id} not found")))?;
|
||||
|
||||
if row.closed_at.is_some() {
|
||||
return Err(Error::BadRequest(
|
||||
"Deployment request is already closed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !authed.is_admin && row.requested_by != authed.username {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Only the requester or an admin can cancel this request".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let rows_affected = sqlx::query!(
|
||||
"UPDATE workspace_fork_deployment_request SET closed_at = now(), closed_reason = 'cancelled' WHERE id = $1 AND closed_at IS NULL",
|
||||
id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if rows_affected == 0 {
|
||||
// Lost the race to another cancel / close_merged call.
|
||||
return Ok("already-closed".to_string());
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"fork_deployment_request.cancel",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&id.to_string()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Notify assignees that the request was cancelled.
|
||||
let assignees = sqlx::query!(
|
||||
"SELECT email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1",
|
||||
id,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
let subject = format!("[Windmill] Deployment request on fork {w_id} cancelled");
|
||||
let body_text = format!(
|
||||
"@{} cancelled the open deployment request on fork {w_id}.",
|
||||
authed.username
|
||||
);
|
||||
for r in assignees {
|
||||
if r.email != authed.email {
|
||||
send_email_if_possible(&subject, &body_text, &r.email);
|
||||
}
|
||||
}
|
||||
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
/// Called by the UI after a successful merge loop. Closes the open
|
||||
/// deployment request for this fork and marks every comment obsolete. Only
|
||||
/// admins and members of wm_deployers *in the parent workspace* may close —
|
||||
/// same set that can actually merge. `authed.groups` here reflects the fork
|
||||
/// workspace's groups, so we query the parent explicitly.
|
||||
async fn close_deployment_request_merged(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, id)): Path<(String, i64)>,
|
||||
) -> Result<String> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT source_workspace_id, requested_by, requested_by_email, closed_at FROM workspace_fork_deployment_request WHERE id = $1 AND fork_workspace_id = $2",
|
||||
id,
|
||||
&w_id,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("deployment request {id} not found")))?;
|
||||
|
||||
// Check deployer membership against the parent workspace, not the fork.
|
||||
let is_parent_deployer = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM usr u
|
||||
WHERE u.workspace_id = $1
|
||||
AND u.email = $2
|
||||
AND u.disabled = false
|
||||
AND (
|
||||
u.is_admin = true
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM usr_to_group g
|
||||
WHERE g.workspace_id = $1
|
||||
AND g.group_ = $3
|
||||
AND g.usr = u.username
|
||||
)
|
||||
)
|
||||
) as "exists!"
|
||||
"#,
|
||||
row.source_workspace_id,
|
||||
&authed.email,
|
||||
WM_DEPLOYERS_GROUP,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
if !authed.is_admin && !is_parent_deployer {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Only admins or members of wm_deployers in the parent workspace can close a deployment request as merged"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let rows_affected = sqlx::query!(
|
||||
"UPDATE workspace_fork_deployment_request SET closed_at = now(), closed_reason = 'merged' WHERE id = $1 AND closed_at IS NULL",
|
||||
id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if rows_affected == 0 {
|
||||
return Ok("already-closed".to_string());
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_fork_deployment_request_comment SET obsolete = true WHERE request_id = $1 AND obsolete = false",
|
||||
id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"fork_deployment_request.close_merged",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&id.to_string()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Notify the requester + every assignee that the request was merged.
|
||||
// Skip the merger themselves to avoid self-ping.
|
||||
let assignee_emails: Vec<String> = sqlx::query_scalar!(
|
||||
"SELECT email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1",
|
||||
id,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
let mut recipients: BTreeSet<String> = assignee_emails.into_iter().collect();
|
||||
recipients.insert(row.requested_by_email);
|
||||
recipients.remove(&authed.email);
|
||||
let subject = format!("[Windmill] Deployment request on fork {w_id} merged");
|
||||
let base_url = BASE_URL.read().await.clone();
|
||||
let body_text = format!(
|
||||
"@{} merged the deployment request from @{} on fork {w_id}.\n\n{base_url}/?workspace={w_id}",
|
||||
authed.username, row.requested_by
|
||||
);
|
||||
for email in recipients {
|
||||
send_email_if_possible(&subject, &body_text, &email);
|
||||
}
|
||||
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
/// Append a comment to an open deployment request. Can be a top-level
|
||||
/// comment or a reply (set `parent_id`, must target a top-level comment),
|
||||
/// and can be general or anchored to a diff row. Anyone with access to the
|
||||
/// fork workspace can comment; ACL is enforced by the bearer-token →
|
||||
/// workspace membership check higher up.
|
||||
async fn create_deployment_request_comment(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, id)): Path<(String, i64)>,
|
||||
Json(body): Json<CreateDeploymentRequestCommentBody>,
|
||||
) -> JsonResult<DeploymentRequestComment> {
|
||||
if body.body.trim().is_empty() {
|
||||
return Err(Error::BadRequest("Comment body is empty".to_string()));
|
||||
}
|
||||
if body.anchor_kind.is_some() != body.anchor_path.is_some() {
|
||||
return Err(Error::BadRequest(
|
||||
"anchor_kind and anchor_path must both be set or both omitted".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let req = sqlx::query!(
|
||||
"SELECT id, source_workspace_id, closed_at, requested_by_email FROM workspace_fork_deployment_request WHERE id = $1 AND fork_workspace_id = $2",
|
||||
id,
|
||||
&w_id,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("deployment request {id} not found")))?;
|
||||
|
||||
if req.closed_at.is_some() {
|
||||
return Err(Error::BadRequest(
|
||||
"Cannot comment on a closed deployment request".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// If anchor is set, validate the (kind, path) exists on the workspace_diff
|
||||
// for this (source, fork) pair. This prevents comments against phantom
|
||||
// rows.
|
||||
if let (Some(kind), Some(path)) = (body.anchor_kind.as_ref(), body.anchor_path.as_ref()) {
|
||||
let exists: bool = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM workspace_diff
|
||||
WHERE source_workspace_id = $1
|
||||
AND fork_workspace_id = $2
|
||||
AND kind = $3
|
||||
AND path = $4
|
||||
) as "exists!"
|
||||
"#,
|
||||
req.source_workspace_id,
|
||||
&w_id,
|
||||
kind,
|
||||
path,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
if !exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"No diff row exists for {kind}:{path} in this fork",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a reply, look up the parent comment — must exist on the
|
||||
// same request, must itself be top-level. The parent's author_email is
|
||||
// captured here so we can include them in the notification recipients.
|
||||
let parent_author_email: Option<String> = if let Some(parent_id) = body.parent_id {
|
||||
let parent_row = sqlx::query!(
|
||||
"SELECT parent_id, author_email FROM workspace_fork_deployment_request_comment WHERE id = $1 AND request_id = $2",
|
||||
parent_id,
|
||||
id,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest("parent_id does not belong to this request".to_string())
|
||||
})?;
|
||||
if parent_row.parent_id.is_some() {
|
||||
return Err(Error::BadRequest(
|
||||
"Replies can only target top-level comments".to_string(),
|
||||
));
|
||||
}
|
||||
Some(parent_row.author_email)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO workspace_fork_deployment_request_comment
|
||||
(request_id, parent_id, author, author_email, body, anchor_kind, anchor_path)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, created_at
|
||||
"#,
|
||||
id,
|
||||
body.parent_id,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.email,
|
||||
&body.body,
|
||||
body.anchor_kind,
|
||||
body.anchor_path,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"fork_deployment_request.comment",
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&row.id.to_string()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Recipient set: requester + every assignee + (for replies) the
|
||||
// parent-comment author, minus the comment author.
|
||||
let assignee_emails: Vec<String> = sqlx::query_scalar!(
|
||||
"SELECT email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1",
|
||||
id,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
let mut recipients: BTreeSet<String> = assignee_emails.into_iter().collect();
|
||||
recipients.insert(req.requested_by_email.clone());
|
||||
if let Some(e) = parent_author_email {
|
||||
recipients.insert(e);
|
||||
}
|
||||
recipients.remove(&authed.email);
|
||||
|
||||
let subject = format!(
|
||||
"[Windmill] New comment on deployment request for fork {w_id} by @{}",
|
||||
authed.username
|
||||
);
|
||||
let base_url = BASE_URL.read().await.clone();
|
||||
let body_text = format!(
|
||||
"@{} commented on the deployment request for fork {w_id}:\n\n{}\n\n{base_url}/?workspace={w_id}",
|
||||
authed.username, body.body
|
||||
);
|
||||
for email in recipients {
|
||||
send_email_if_possible(&subject, &body_text, &email);
|
||||
}
|
||||
|
||||
Ok(Json(DeploymentRequestComment {
|
||||
id: row.id,
|
||||
parent_id: body.parent_id,
|
||||
author: authed.username.clone(),
|
||||
author_email: authed.email.clone(),
|
||||
body: body.body,
|
||||
anchor_kind: body.anchor_kind,
|
||||
anchor_path: body.anchor_path,
|
||||
obsolete: false,
|
||||
created_at: row.created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
async fn parent_of_fork(db: &DB, w_id: &str) -> Result<String> {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
|
||||
w_id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten()
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!(
|
||||
"workspace {w_id} is not a fork (no parent_workspace_id)"
|
||||
))
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod deployment_requests;
|
||||
pub mod workspaces;
|
||||
pub mod workspaces_extra;
|
||||
pub mod workspaces_oss;
|
||||
|
||||
@@ -331,6 +331,31 @@ pub(crate) async fn change_workspace_id(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating workspace_fork_deployment_request table");
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_fork_deployment_request SET source_workspace_id = $1 WHERE source_workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_fork_deployment_request SET fork_workspace_id = $1 WHERE fork_workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating workspace_protection_rule table");
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_protection_rule SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating workspace_integrations table");
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_integrations SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
|
||||
@@ -5314,6 +5314,168 @@ paths:
|
||||
type: string
|
||||
"404":
|
||||
description: protection rule not found
|
||||
|
||||
/w/{workspace}/deployment_request/eligible_deployers:
|
||||
get:
|
||||
summary: list users eligible to be deployment request assignees
|
||||
description: Returns admins and members of wm_deployers in the parent workspace of the fork at `{workspace}`.
|
||||
operationId: listDeploymentRequestEligibleDeployers
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: list of eligible deployers
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DeploymentRequestEligibleDeployer"
|
||||
|
||||
/w/{workspace}/deployment_request/open:
|
||||
get:
|
||||
summary: get the currently open deployment request for this fork
|
||||
operationId: getOpenDeploymentRequest
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: the open request or null if none exists
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/DeploymentRequest"
|
||||
- type: "null"
|
||||
|
||||
/w/{workspace}/deployment_request:
|
||||
post:
|
||||
summary: create a new deployment request
|
||||
operationId: createDeploymentRequest
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- assignees
|
||||
properties:
|
||||
assignees:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Usernames in the parent workspace. Must be admin or wm_deployers.
|
||||
responses:
|
||||
"200":
|
||||
description: request created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeploymentRequest"
|
||||
"400":
|
||||
description: invalid assignees
|
||||
"409":
|
||||
description: a deployment request is already open for this fork
|
||||
|
||||
/w/{workspace}/deployment_request/{id}/cancel:
|
||||
post:
|
||||
summary: cancel an open deployment request
|
||||
operationId: cancelDeploymentRequest
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
responses:
|
||||
"200":
|
||||
description: cancelled
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/deployment_request/{id}/close_merged:
|
||||
post:
|
||||
summary: close a deployment request after a successful merge
|
||||
description: Called by the UI after the deploy loop completes. Closes the request and marks every comment obsolete. Idempotent.
|
||||
operationId: closeDeploymentRequestMerged
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
responses:
|
||||
"200":
|
||||
description: closed
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/deployment_request/{id}/comment:
|
||||
post:
|
||||
summary: append a comment to an open deployment request
|
||||
operationId: createDeploymentRequestComment
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- body
|
||||
properties:
|
||||
body:
|
||||
type: string
|
||||
parent_id:
|
||||
type: integer
|
||||
format: int64
|
||||
nullable: true
|
||||
anchor_kind:
|
||||
type: string
|
||||
nullable: true
|
||||
anchor_path:
|
||||
type: string
|
||||
nullable: true
|
||||
responses:
|
||||
"200":
|
||||
description: comment created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeploymentRequestComment"
|
||||
"400":
|
||||
description: invalid input or request closed
|
||||
|
||||
/w/{workspace}/workspaces/log_chat:
|
||||
post:
|
||||
summary: log AI chat message
|
||||
@@ -26493,6 +26655,7 @@ components:
|
||||
enum:
|
||||
- DisableDirectDeployment
|
||||
- DisableWorkspaceForking
|
||||
- RestrictDeployToDeployers
|
||||
RuleBypasserGroups:
|
||||
type: array
|
||||
description: Groups that can bypass this ruleset
|
||||
@@ -26503,6 +26666,97 @@ components:
|
||||
description: Users that can bypass this ruleset
|
||||
items:
|
||||
type: string
|
||||
DeploymentRequestEligibleDeployer:
|
||||
type: object
|
||||
required:
|
||||
- username
|
||||
- email
|
||||
- is_admin
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
is_admin:
|
||||
type: boolean
|
||||
DeploymentRequestAssignee:
|
||||
type: object
|
||||
required:
|
||||
- username
|
||||
- email
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
DeploymentRequestComment:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- author
|
||||
- author_email
|
||||
- body
|
||||
- obsolete
|
||||
- created_at
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
parent_id:
|
||||
type: integer
|
||||
format: int64
|
||||
nullable: true
|
||||
author:
|
||||
type: string
|
||||
author_email:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
anchor_kind:
|
||||
type: string
|
||||
nullable: true
|
||||
anchor_path:
|
||||
type: string
|
||||
nullable: true
|
||||
obsolete:
|
||||
type: boolean
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
DeploymentRequest:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- source_workspace_id
|
||||
- fork_workspace_id
|
||||
- requested_by
|
||||
- requested_by_email
|
||||
- requested_at
|
||||
- assignees
|
||||
- comments
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
source_workspace_id:
|
||||
type: string
|
||||
fork_workspace_id:
|
||||
type: string
|
||||
requested_by:
|
||||
type: string
|
||||
requested_by_email:
|
||||
type: string
|
||||
requested_at:
|
||||
type: string
|
||||
format: date-time
|
||||
assignees:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DeploymentRequestAssignee"
|
||||
comments:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DeploymentRequestComment"
|
||||
QuotaInfo:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -62,7 +62,7 @@ use windmill_common::{
|
||||
},
|
||||
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
|
||||
worker::{to_raw_value, CLOUD_HOSTED},
|
||||
workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult},
|
||||
workspaces::{check_deploy_rules, RuleCheckResult},
|
||||
HUB_BASE_URL,
|
||||
};
|
||||
#[cfg(feature = "parquet")]
|
||||
@@ -1108,9 +1108,8 @@ async fn create_app_raw<'a>(
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -1181,9 +1180,8 @@ async fn create_app(
|
||||
let path = app.path.clone();
|
||||
check_scopes(&authed, || format!("apps:write:{}", &path))?;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -1472,9 +1470,8 @@ async fn delete_app(
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -1634,9 +1631,8 @@ async fn update_app(
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:write:{}", path))?;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -1678,9 +1674,8 @@ async fn update_app_raw<'a>(
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
|
||||
@@ -620,6 +620,10 @@ pub async fn run_server(
|
||||
.nest("/volumes", volumes_oss::workspaced_service())
|
||||
.nest("/workers", windmill_api_workers::workspaced_service())
|
||||
.nest("/workspaces", workspaces::workspaced_service())
|
||||
.nest(
|
||||
"/deployment_request",
|
||||
windmill_api_workspaces::deployment_requests::workspaced_service(),
|
||||
)
|
||||
.nest("/oidc", oidc_oss::workspaced_service())
|
||||
.nest("/openapi", {
|
||||
#[cfg(feature = "http_trigger")]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! OSS stubs for the fork deployment requests feature. The EE build
|
||||
//! overrides these via `deployment_requests_ee.rs` when the `private`
|
||||
//! feature is active.
|
||||
//!
|
||||
//! These helpers are called from fork item-change hooks in EE code (see
|
||||
//! `windmill-git-sync/src/git_sync_ee.rs::tally_deployed_object_changes`).
|
||||
//! The stub exists so code paths that wire the hook compile on OSS even
|
||||
//! though the obsolescence behavior itself is EE-only.
|
||||
//!
|
||||
//! On OSS the call is a no-op, so anchored-comment obsolescence after an
|
||||
//! item change only actually fires when compiled with `--features private`.
|
||||
//! The `fork_deployment_requests` integration test simulates the EE effect
|
||||
//! via a raw SQL UPDATE to keep the lifecycle test meaningful on CE builds.
|
||||
|
||||
#[cfg(feature = "private")]
|
||||
#[allow(unused)]
|
||||
pub use crate::deployment_requests_ee::*;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
use crate::{db::DB, error::Result};
|
||||
|
||||
/// Mark every anchored comment on the currently open deployment request
|
||||
/// for the given fork workspace that pins to `(anchor_kind, anchor_path)`
|
||||
/// as obsolete.
|
||||
///
|
||||
/// OSS stub: no-op. The EE build writes to
|
||||
/// `workspace_fork_deployment_request_comment`.
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn mark_anchor_obsolete(
|
||||
_db: &DB,
|
||||
_fork_workspace_id: &str,
|
||||
_anchor_kind: &str,
|
||||
_anchor_path: &str,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the currently open deployment request for `(source_workspace_id,
|
||||
/// fork_workspace_id)` with `closed_reason = 'merged'` and mark all its
|
||||
/// comments obsolete. Called from the frontend deploy success handler.
|
||||
///
|
||||
/// OSS stub: no-op.
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn close_open_request_on_merge(
|
||||
_db: &DB,
|
||||
_source_workspace_id: &str,
|
||||
_fork_workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -48,6 +48,9 @@ mod db_entra_ee;
|
||||
mod db_iam_ee;
|
||||
pub mod db_params;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod deployment_requests_ee;
|
||||
pub mod deployment_requests_oss;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod ee;
|
||||
pub mod ee_oss;
|
||||
#[cfg(feature = "private")]
|
||||
|
||||
@@ -67,6 +67,7 @@ bitflags::bitflags! {
|
||||
pub struct ProtectionRules: i32 {
|
||||
const DISABLE_DIRECT_DEPLOYMENT = 1 << 0;
|
||||
const DISABLE_WORKSPACE_FORKING = 1 << 1;
|
||||
const RESTRICT_DEPLOY_TO_DEPLOYERS = 1 << 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +77,7 @@ sqlx_bitflags!(ProtectionRules => i32);
|
||||
pub enum ProtectionRuleKind {
|
||||
DisableDirectDeployment,
|
||||
DisableWorkspaceForking,
|
||||
RestrictDeployToDeployers,
|
||||
}
|
||||
|
||||
impl ProtectionRuleKind {
|
||||
@@ -87,6 +89,9 @@ impl ProtectionRuleKind {
|
||||
ProtectionRuleKind::DisableWorkspaceForking => {
|
||||
ProtectionRules::DISABLE_WORKSPACE_FORKING
|
||||
}
|
||||
ProtectionRuleKind::RestrictDeployToDeployers => {
|
||||
ProtectionRules::RESTRICT_DEPLOY_TO_DEPLOYERS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +101,9 @@ impl ProtectionRuleKind {
|
||||
"Cannot directly deploy in this workspace. Fork or Pull request required."
|
||||
}
|
||||
ProtectionRuleKind::DisableWorkspaceForking => "Forking this workspace is forbidden",
|
||||
ProtectionRuleKind::RestrictDeployToDeployers => {
|
||||
"Only workspace admins and members of wm_deployers can deploy to this workspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,6 +373,14 @@ pub async fn check_user_against_rule(
|
||||
return Ok(RuleCheckResult::Allowed);
|
||||
}
|
||||
|
||||
// wm_deployers members implicitly satisfy RestrictDeployToDeployers,
|
||||
// regardless of per-ruleset bypass configuration.
|
||||
if matches!(rule, ProtectionRuleKind::RestrictDeployToDeployers)
|
||||
&& user_groups.iter().any(|g| g == crate::WM_DEPLOYERS_GROUP)
|
||||
{
|
||||
return Ok(RuleCheckResult::Allowed);
|
||||
}
|
||||
|
||||
let rulesets = get_protection_rules(workspace_id, db).await?;
|
||||
|
||||
for ruleset in rulesets.iter() {
|
||||
@@ -389,6 +405,34 @@ pub async fn check_user_against_rule(
|
||||
Ok(RuleCheckResult::Allowed)
|
||||
}
|
||||
|
||||
/// Check all deploy-gating protection rules at once.
|
||||
///
|
||||
/// Evaluates `DisableDirectDeployment` first (so its message wins when both
|
||||
/// rules would block), then `RestrictDeployToDeployers`. Returns the first
|
||||
/// `Blocked` result, or `Allowed` if neither rule blocks.
|
||||
///
|
||||
/// Use this at every item create/update endpoint that participates in the
|
||||
/// deploy/merge flow so a single call enforces both rules consistently.
|
||||
pub async fn check_deploy_rules(
|
||||
workspace_id: &str,
|
||||
username: &str,
|
||||
user_groups: &[String],
|
||||
is_admin: bool,
|
||||
db: &DB,
|
||||
) -> Result<RuleCheckResult> {
|
||||
for rule in [
|
||||
ProtectionRuleKind::DisableDirectDeployment,
|
||||
ProtectionRuleKind::RestrictDeployToDeployers,
|
||||
] {
|
||||
let res = check_user_against_rule(workspace_id, &rule, username, user_groups, is_admin, db)
|
||||
.await?;
|
||||
if matches!(res, RuleCheckResult::Blocked(_)) {
|
||||
return Ok(res);
|
||||
}
|
||||
}
|
||||
Ok(RuleCheckResult::Allowed)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DataTableForkBehavior {
|
||||
|
||||
@@ -14,7 +14,7 @@ use windmill_api_auth::{
|
||||
Tokened,
|
||||
};
|
||||
use windmill_common::db::DB;
|
||||
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
|
||||
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
|
||||
|
||||
use crate::secret_backend_ext::rename_vault_secret;
|
||||
use crate::var_resource_cache::{cache_resource, get_cached_resource};
|
||||
@@ -770,9 +770,8 @@ async fn create_resource(
|
||||
Json(resource): Json<CreateResource>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
check_scopes(&authed, || format!("resources:write:{}", resource.path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -893,9 +892,8 @@ async fn delete_resource(
|
||||
let path = path.to_path();
|
||||
|
||||
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -1094,9 +1092,8 @@ async fn delete_resources_bulk(
|
||||
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -1201,9 +1198,8 @@ async fn update_resource(
|
||||
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -1394,9 +1390,8 @@ async fn update_resource_value(
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -1583,9 +1578,8 @@ async fn create_resource_type(
|
||||
Path(w_id): Path<String>,
|
||||
Json(resource_type): Json<CreateResourceType>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -1691,9 +1685,8 @@ async fn delete_resource_type(
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -1757,9 +1750,8 @@ async fn update_resource_type(
|
||||
Json(ns): Json<EditResourceType>,
|
||||
) -> Result<String> {
|
||||
use sql_builder::prelude::*;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed};
|
||||
use windmill_common::db::DB;
|
||||
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
|
||||
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
|
||||
|
||||
use crate::secret_backend_ext::{
|
||||
delete_secret_from_backend, get_secret_value, is_vault_stored_value, rename_vault_secret,
|
||||
@@ -391,9 +391,8 @@ async fn create_variable(
|
||||
Json(variable): Json<CreateVariable>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
check_scopes(&authed, || format!("variables:write:{}", variable.path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -504,9 +503,8 @@ async fn delete_variable(
|
||||
let path = path.to_path();
|
||||
|
||||
check_scopes(&authed, || format!("variables:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -652,9 +650,8 @@ async fn delete_variables_bulk(
|
||||
check_scopes(&authed, || format!("variables:write:{}", path))?;
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -799,9 +796,8 @@ async fn update_variable(
|
||||
) -> Result<String> {
|
||||
use sql_builder::prelude::*;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
|
||||
@@ -10,12 +10,13 @@
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
DiffIcon,
|
||||
Eye,
|
||||
FileJson,
|
||||
FlaskConical,
|
||||
GitFork,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Upload
|
||||
UserPlus
|
||||
} from 'lucide-svelte'
|
||||
import type { CiTestResult } from '$lib/gen'
|
||||
import { Alert, Badge } from './common'
|
||||
@@ -41,10 +42,18 @@
|
||||
} from '$lib/gen'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
import Row from './common/table/Row.svelte'
|
||||
import DiffDrawer from './DiffDrawer.svelte'
|
||||
import DeployWorkspaceDrawer from './DeployWorkspaceDrawer.svelte'
|
||||
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
|
||||
import ScheduleEditor from './triggers/schedules/ScheduleEditor.svelte'
|
||||
import RouteEditor from './triggers/http/RouteEditor.svelte'
|
||||
import WebsocketTriggerEditor from './triggers/websocket/WebsocketTriggerEditor.svelte'
|
||||
import KafkaTriggerEditor from './triggers/kafka/KafkaTriggerEditor.svelte'
|
||||
import PostgresTriggerEditor from './triggers/postgres/PostgresTriggerEditor.svelte'
|
||||
import NatsTriggerEditor from './triggers/nats/NatsTriggerEditor.svelte'
|
||||
import MqttTriggerEditor from './triggers/mqtt/MqttTriggerEditor.svelte'
|
||||
import SqsTriggerEditor from './triggers/sqs/SqsTriggerEditor.svelte'
|
||||
import GcpTriggerEditor from './triggers/gcp/GcpTriggerEditor.svelte'
|
||||
import EmailTriggerEditor from './triggers/email/EmailTriggerEditor.svelte'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
|
||||
import type { Kind } from '$lib/utils_deployable'
|
||||
@@ -64,6 +73,8 @@
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
|
||||
import DeploymentRequestPanel from './deploymentRequest/DeploymentRequestPanel.svelte'
|
||||
import { userStore } from '$lib/stores'
|
||||
import type { TriggerKind } from './triggers'
|
||||
import { triggerDisplayNamesMap, triggerKindToTriggerType } from './triggers/utils'
|
||||
import { getEmailAddress, getEmailDomain } from './triggers/email/utils'
|
||||
@@ -280,7 +291,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
let allSelected = $derived(selectedItems.length == selectableDiffs.length)
|
||||
// All *diff* items selected. Trigger items are opt-in and don't count
|
||||
// toward "all selected" — see item merge below in deployableItems.
|
||||
let allSelected = $derived(
|
||||
selectableDiffs.length > 0 &&
|
||||
selectableDiffs.every((d) => selectedItems.includes(getItemKey(d)))
|
||||
)
|
||||
|
||||
async function selectAll() {
|
||||
selectedItems = selectableDiffs
|
||||
@@ -313,13 +329,14 @@
|
||||
path: string,
|
||||
workspaceToDeployTo: string,
|
||||
workspaceFrom: string,
|
||||
statusPath: string
|
||||
statusKey: string,
|
||||
trigger?: ForkTrigger
|
||||
) {
|
||||
deploymentStatus[statusPath] = { status: 'loading' }
|
||||
deploymentStatus[statusKey] = { status: 'loading' }
|
||||
|
||||
// Check if the item was deleted in the source workspace.
|
||||
// If so, archive/delete it in the target workspace instead of copying.
|
||||
const diff = comparison?.diffs.find((d) => getItemKey(d) === statusPath)
|
||||
const diff = comparison?.diffs.find((d) => getItemKey(d) === statusKey)
|
||||
const itemDeletedInSource = diff
|
||||
? mergeIntoParent
|
||||
? diff.exists_in_fork === false
|
||||
@@ -329,21 +346,30 @@
|
||||
let result: DeployResult
|
||||
if (itemDeletedInSource) {
|
||||
result = await deleteItemInWorkspace(kind, path, workspaceToDeployTo)
|
||||
} else if (trigger) {
|
||||
result = await deployItem({
|
||||
kind: 'trigger',
|
||||
path,
|
||||
workspaceFrom,
|
||||
workspaceTo: workspaceToDeployTo,
|
||||
additionalInformation: { triggers: { kind: trigger.triggerKind } },
|
||||
onBehalfOf: getOnBehalfOfForDeploy(statusKey, 'trigger')
|
||||
})
|
||||
} else {
|
||||
result = await deployItem({
|
||||
kind,
|
||||
path,
|
||||
workspaceFrom,
|
||||
workspaceTo: workspaceToDeployTo,
|
||||
onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind)
|
||||
onBehalfOf: getOnBehalfOfForDeploy(statusKey, kind)
|
||||
})
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
deploymentStatus[statusPath] = { status: 'deployed' }
|
||||
deploymentStatus[statusKey] = { status: 'deployed' }
|
||||
} else {
|
||||
deploymentStatus[statusPath] = { status: 'failed', error: result.error }
|
||||
sendUserToast(`Failed to deploy ${statusPath}: ${result.error}`)
|
||||
deploymentStatus[statusKey] = { status: 'failed', error: result.error }
|
||||
sendUserToast(`Failed to deploy ${statusKey}: ${result.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,26 +419,46 @@
|
||||
if (!aIsFolder && bIsFolder) return 1
|
||||
return 0
|
||||
})
|
||||
let anyFailed = false
|
||||
for (const itemKey of sortedItems) {
|
||||
const diff = selectableDiffs.find((d) => itemKey == getItemKey(d))
|
||||
const deployable = deployableItems.find((d) => d.key === itemKey)
|
||||
|
||||
if (!diff) {
|
||||
if (!deployable) {
|
||||
sendUserToast(`Undeployable item: ${itemKey}`, true)
|
||||
continue
|
||||
}
|
||||
|
||||
if (mergeIntoParent) {
|
||||
await deploy(diff.kind as Kind, diff.path, parent, current, itemKey)
|
||||
} else {
|
||||
await deploy(diff.kind as Kind, diff.path, current, parent, itemKey)
|
||||
const to = mergeIntoParent ? parent : current
|
||||
const from = mergeIntoParent ? current : parent
|
||||
await deploy(deployable.kind, deployable.path, to, from, itemKey, deployable.trigger)
|
||||
if (deploymentStatus[itemKey]?.status === 'failed') {
|
||||
anyFailed = true
|
||||
}
|
||||
}
|
||||
deploying = false
|
||||
deselectAll()
|
||||
|
||||
// If every selected item deployed cleanly and the direction was
|
||||
// merge-into-parent, close any open deployment request for this fork.
|
||||
if (!anyFailed && mergeIntoParent) {
|
||||
try {
|
||||
const open = await WorkspaceService.getOpenDeploymentRequest({
|
||||
workspace: currentWorkspaceId
|
||||
})
|
||||
if (open) {
|
||||
await WorkspaceService.closeDeploymentRequestMerged({
|
||||
workspace: currentWorkspaceId,
|
||||
id: open.id
|
||||
})
|
||||
deploymentRequestPanel?.refresh()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to close open deployment request after merge', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleItem(diff: WorkspaceItemDiff) {
|
||||
const key = getItemKey(diff)
|
||||
function toggleKey(key: string) {
|
||||
if (selectedItems.includes(key)) {
|
||||
selectedItems = selectedItems.filter((i) => i !== key)
|
||||
} else {
|
||||
@@ -478,9 +524,15 @@
|
||||
allowBehindChangesOverride = false
|
||||
})
|
||||
|
||||
// Transform diffs to deployable item format for the shared layout
|
||||
let deployableItems = $derived(
|
||||
(comparison?.diffs ?? [])
|
||||
function getTriggerKey(trigger: ForkTrigger): string {
|
||||
return `trigger:${trigger.triggerKind}:${trigger.path}`
|
||||
}
|
||||
|
||||
// Transform diffs + fork triggers to deployable item format for the
|
||||
// shared layout. Triggers render as inline rows alongside diff items;
|
||||
// they carry no ahead/behind info and are selectable à la carte.
|
||||
let deployableItems = $derived.by(() => {
|
||||
const diffItems = (comparison?.diffs ?? [])
|
||||
.filter((diff) => {
|
||||
const key = getItemKey(diff)
|
||||
const isSelectable = selectableDiffs.includes(diff)
|
||||
@@ -492,9 +544,23 @@
|
||||
key: getItemKey(diff),
|
||||
path: diff.path,
|
||||
kind: diff.kind as Kind,
|
||||
diff
|
||||
diff,
|
||||
trigger: undefined as ForkTrigger | undefined
|
||||
}))
|
||||
)
|
||||
const triggerItems = forkTriggers
|
||||
.filter((t) => {
|
||||
const key = getTriggerKey(t)
|
||||
return deploymentStatus[key]?.status !== 'deployed'
|
||||
})
|
||||
.map((trigger) => ({
|
||||
key: getTriggerKey(trigger),
|
||||
path: trigger.path,
|
||||
kind: 'trigger' as Kind,
|
||||
diff: undefined as WorkspaceItemDiff | undefined,
|
||||
trigger
|
||||
}))
|
||||
return [...diffItems, ...triggerItems]
|
||||
})
|
||||
|
||||
// --- Fork Triggers ---
|
||||
|
||||
@@ -559,9 +625,58 @@
|
||||
})
|
||||
|
||||
let forkTriggers = $state<ForkTrigger[]>([])
|
||||
let loadingTriggers = $state(true)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
|
||||
let triggerToDelete = $state<ForkTrigger | undefined>(undefined)
|
||||
let deploymentRequestPanel: DeploymentRequestPanel | undefined = $state(undefined)
|
||||
let hasOpenDeploymentRequest = $state(false)
|
||||
|
||||
// Trigger detail drawer refs — one per trigger kind. Each is lazy-mounted
|
||||
// on first openEdit() call, so having them all sit here is cheap.
|
||||
let scheduleEditor: ScheduleEditor | undefined = $state()
|
||||
let routeEditor: RouteEditor | undefined = $state()
|
||||
let websocketEditor: WebsocketTriggerEditor | undefined = $state()
|
||||
let kafkaEditor: KafkaTriggerEditor | undefined = $state()
|
||||
let postgresEditor: PostgresTriggerEditor | undefined = $state()
|
||||
let natsEditor: NatsTriggerEditor | undefined = $state()
|
||||
let mqttEditor: MqttTriggerEditor | undefined = $state()
|
||||
let sqsEditor: SqsTriggerEditor | undefined = $state()
|
||||
let gcpEditor: GcpTriggerEditor | undefined = $state()
|
||||
let emailEditor: EmailTriggerEditor | undefined = $state()
|
||||
|
||||
function openTriggerDetails(trigger: ForkTrigger) {
|
||||
const isFlow = trigger.isFlow
|
||||
switch (trigger.triggerKind) {
|
||||
case 'schedules':
|
||||
scheduleEditor?.openEdit(trigger.path, isFlow)
|
||||
break
|
||||
case 'routes':
|
||||
routeEditor?.openEdit(trigger.path, isFlow)
|
||||
break
|
||||
case 'websockets':
|
||||
websocketEditor?.openEdit(trigger.path, isFlow)
|
||||
break
|
||||
case 'kafka':
|
||||
kafkaEditor?.openEdit(trigger.path, isFlow)
|
||||
break
|
||||
case 'postgres':
|
||||
postgresEditor?.openEdit(trigger.path, isFlow)
|
||||
break
|
||||
case 'nats':
|
||||
natsEditor?.openEdit(trigger.path, isFlow)
|
||||
break
|
||||
case 'mqtt':
|
||||
mqttEditor?.openEdit(trigger.path, isFlow)
|
||||
break
|
||||
case 'sqs':
|
||||
sqsEditor?.openEdit(trigger.path, isFlow)
|
||||
break
|
||||
case 'gcp':
|
||||
gcpEditor?.openEdit(trigger.path, isFlow)
|
||||
break
|
||||
case 'emails':
|
||||
emailEditor?.openEdit(trigger.path, isFlow)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** Deployable trigger kinds and their list+delete services */
|
||||
const triggerServices = {
|
||||
@@ -702,7 +817,6 @@
|
||||
let emailDomain = $state<string | undefined>(undefined)
|
||||
|
||||
async function fetchAllTriggers() {
|
||||
loadingTriggers = true
|
||||
try {
|
||||
emailDomain = await getEmailDomain()
|
||||
const entries = Object.values(triggerServices)
|
||||
@@ -716,8 +830,6 @@
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch fork triggers:', e)
|
||||
forkTriggers = []
|
||||
} finally {
|
||||
loadingTriggers = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,24 +863,6 @@
|
||||
return triggerType ? triggerDisplayNamesMap[triggerType] : triggerKind
|
||||
}
|
||||
|
||||
const triggerKindToPagePath: Record<string, string> = {
|
||||
schedules: '/schedules',
|
||||
routes: '/routes',
|
||||
websockets: '/websocket_triggers',
|
||||
kafka: '/kafka_triggers',
|
||||
postgres: '/postgres_triggers',
|
||||
nats: '/nats_triggers',
|
||||
mqtt: '/mqtt_triggers',
|
||||
sqs: '/sqs_triggers',
|
||||
gcp: '/gcp_triggers',
|
||||
emails: '/email_triggers'
|
||||
}
|
||||
|
||||
function getTriggerHref(triggerKind: TriggerKind): string | undefined {
|
||||
const pagePath = triggerKindToPagePath[triggerKind]
|
||||
return pagePath ? `${base}${pagePath}` : undefined
|
||||
}
|
||||
|
||||
// Fetch triggers when workspace is available
|
||||
$effect(() => {
|
||||
if (currentWorkspaceId) {
|
||||
@@ -798,12 +892,10 @@
|
||||
items={deployableItems}
|
||||
{selectedItems}
|
||||
{deploymentStatus}
|
||||
selectablePredicate={(item) => selectableDiffs.some((d) => getItemKey(d) === item.key)}
|
||||
selectablePredicate={(item) =>
|
||||
item.trigger != null || selectableDiffs.some((d) => getItemKey(d) === item.key)}
|
||||
{allSelected}
|
||||
onToggleItem={(item) => {
|
||||
const diff = comparison?.diffs.find((d) => getItemKey(d) === item.key)
|
||||
if (diff) toggleItem(diff)
|
||||
}}
|
||||
onToggleItem={(item) => toggleKey(item.key)}
|
||||
onSelectAll={selectAll}
|
||||
onDeselectAll={deselectAll}
|
||||
emptyMessage="No comparison data available"
|
||||
@@ -975,126 +1067,163 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet itemSummary(item)}
|
||||
{@const diff = item.diff as WorkspaceItemDiff}
|
||||
{@const key = item.key}
|
||||
{@const isSelectable = selectableDiffs.includes(diff)}
|
||||
{@const oldSummary = mergeIntoParent
|
||||
? summaryCache[key]?.parent
|
||||
: summaryCache[key]?.current}
|
||||
{@const newSummary = mergeIntoParent
|
||||
? summaryCache[key]?.current
|
||||
: summaryCache[key]?.parent}
|
||||
{@const existsInBothWorkspaces = !(
|
||||
(diff.exists_in_fork && !diff.exists_in_source) ||
|
||||
(!diff.exists_in_fork && diff.exists_in_source)
|
||||
)}
|
||||
{#if oldSummary != newSummary && isSelectable && existsInBothWorkspaces}
|
||||
<span class="line-through text-secondary">{oldSummary || diff.path}</span>
|
||||
{newSummary || diff.path}
|
||||
{:else if !existsInBothWorkspaces}
|
||||
{newSummary || oldSummary || diff.path}
|
||||
{#if item.trigger}
|
||||
{@const t = item.trigger as ForkTrigger}
|
||||
<span class="text-emphasis">{getTriggerDisplayName(t.triggerKind)}</span>
|
||||
<span class="text-secondary mx-1">→</span>
|
||||
<span class="text-secondary">{t.scriptPath}</span>
|
||||
{:else}
|
||||
{newSummary || diff.path}
|
||||
{@const diff = item.diff as WorkspaceItemDiff}
|
||||
{@const key = item.key}
|
||||
{@const isSelectable = selectableDiffs.includes(diff)}
|
||||
{@const oldSummary = mergeIntoParent
|
||||
? summaryCache[key]?.parent
|
||||
: summaryCache[key]?.current}
|
||||
{@const newSummary = mergeIntoParent
|
||||
? summaryCache[key]?.current
|
||||
: summaryCache[key]?.parent}
|
||||
{@const existsInBothWorkspaces = !(
|
||||
(diff.exists_in_fork && !diff.exists_in_source) ||
|
||||
(!diff.exists_in_fork && diff.exists_in_source)
|
||||
)}
|
||||
{#if oldSummary != newSummary && isSelectable && existsInBothWorkspaces}
|
||||
<span class="line-through text-secondary">{oldSummary || diff.path}</span>
|
||||
{newSummary || diff.path}
|
||||
{:else if !existsInBothWorkspaces}
|
||||
{newSummary || oldSummary || diff.path}
|
||||
{:else}
|
||||
{newSummary || diff.path}
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet itemActions(item)}
|
||||
{@const diff = item.diff as WorkspaceItemDiff}
|
||||
{@const key = item.key}
|
||||
{@const targetOnBehalfOf = getTargetOnBehalfOf(key)}
|
||||
{@const isConflict = diff.ahead > 0 && diff.behind > 0}
|
||||
{@const existsInBothWorkspaces = !(
|
||||
(diff.exists_in_fork && !diff.exists_in_source) ||
|
||||
(!diff.exists_in_fork && diff.exists_in_source)
|
||||
)}
|
||||
<!-- On-behalf-of selector -->
|
||||
{#if itemNeedsOnBehalfOfSelection(key, diff.kind)}
|
||||
<OnBehalfOfSelector
|
||||
targetWorkspace={deployTargetWorkspace}
|
||||
targetValue={targetOnBehalfOf}
|
||||
selected={onBehalfOfChoice[key]}
|
||||
onSelect={(choice, details) => {
|
||||
onBehalfOfChoice[key] = choice
|
||||
if (details) customOnBehalfOf[key] = details
|
||||
}}
|
||||
kind={diff.kind}
|
||||
canPreserve={canPreserveOnBehalfOf}
|
||||
customValue={customOnBehalfOf[key]?.permissionedAs}
|
||||
/>
|
||||
{/if}
|
||||
{#if diff.kind === 'raw_app'}
|
||||
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
|
||||
{/if}
|
||||
<!-- Status badges -->
|
||||
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0}
|
||||
<Badge
|
||||
title="This item was newly created in the parent workspace '{parentWorkspaceId}'"
|
||||
color="indigo"
|
||||
size="xs">New</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead > 0}
|
||||
<Badge title="This item was deleted in '{currentWorkspaceId}'" color="red" size="xs"
|
||||
>Deleted</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if diff.exists_in_fork && !diff.exists_in_source && diff.behind > 0}
|
||||
<Badge
|
||||
title="This item was deleted in the parent workspace '{parentWorkspaceId}'"
|
||||
color="red"
|
||||
size="xs">Deleted</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if diff.exists_in_fork && !diff.exists_in_source && diff.ahead > 0 && diff.behind == 0}
|
||||
<Badge
|
||||
title="This item was newly created in '{currentWorkspaceId}'"
|
||||
color="indigo"
|
||||
size="xs">New</Badge
|
||||
>
|
||||
{/if}
|
||||
{@const ciStatus = getCiTestStatus(diff)}
|
||||
{#if ciStatus === 'pass'}
|
||||
<Badge color="green" size="xs"><CircleCheck size={10} class="mr-0.5" />CI pass</Badge>
|
||||
{:else if ciStatus === 'fail'}
|
||||
<Badge color="red" size="xs"><CircleX size={10} class="mr-0.5" />CI fail</Badge>
|
||||
{:else if ciStatus === 'running'}
|
||||
<Badge color="yellow" size="xs"
|
||||
><Loader2 size={10} class="mr-0.5 animate-spin" />CI</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isConflict || existsInBothWorkspaces}
|
||||
{#if diff.ahead > 0}
|
||||
<Badge color="green" size="xs">
|
||||
<ArrowUpRight class="w-3 h-3 inline" />
|
||||
{diff.ahead} ahead
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if diff.behind > 0}
|
||||
<Badge color="blue" size="xs">
|
||||
<ArrowDownRight class="w-3 h-3 inline" />
|
||||
{diff.behind} behind
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if isConflict}
|
||||
<Badge color="orange" size="xs">
|
||||
<AlertTriangle class="w-3 h-3 inline" />
|
||||
Conflict
|
||||
</Badge>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class:invisible={!existsInBothWorkspaces}>
|
||||
{#if item.trigger}
|
||||
{@const t = item.trigger as ForkTrigger}
|
||||
{@const key = item.key}
|
||||
<Badge color="indigo" size="xs">Fork-only</Badge>
|
||||
{#if t.isFlow}
|
||||
<Badge color="blue" size="xs">flow</Badge>
|
||||
{/if}
|
||||
{#if t.extraLabel}
|
||||
<span class="text-tertiary text-xs">({t.extraLabel})</span>
|
||||
{/if}
|
||||
{#if t.enabled != null}
|
||||
<Badge color={t.enabled ? 'green' : 'gray'} size="xs">
|
||||
{t.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onclick={() => showDiff(diff.kind as Kind, diff.path)}
|
||||
startIcon={{ icon: Eye }}
|
||||
onclick={() => openTriggerDetails(t)}
|
||||
>
|
||||
<DiffIcon class="w-3 h-3" />
|
||||
Show diff
|
||||
Details
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="xs" variant="subtle" color="red" onclick={() => deleteTrigger(t)}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
{@const diff = item.diff as WorkspaceItemDiff}
|
||||
{@const key = item.key}
|
||||
{@const targetOnBehalfOf = getTargetOnBehalfOf(key)}
|
||||
{@const isConflict = diff.ahead > 0 && diff.behind > 0}
|
||||
{@const existsInBothWorkspaces = !(
|
||||
(diff.exists_in_fork && !diff.exists_in_source) ||
|
||||
(!diff.exists_in_fork && diff.exists_in_source)
|
||||
)}
|
||||
<!-- On-behalf-of selector -->
|
||||
{#if itemNeedsOnBehalfOfSelection(key, diff.kind)}
|
||||
<OnBehalfOfSelector
|
||||
targetWorkspace={deployTargetWorkspace}
|
||||
targetValue={targetOnBehalfOf}
|
||||
selected={onBehalfOfChoice[key]}
|
||||
onSelect={(choice, details) => {
|
||||
onBehalfOfChoice[key] = choice
|
||||
if (details) customOnBehalfOf[key] = details
|
||||
}}
|
||||
kind={diff.kind}
|
||||
canPreserve={canPreserveOnBehalfOf}
|
||||
customValue={customOnBehalfOf[key]?.permissionedAs}
|
||||
/>
|
||||
{/if}
|
||||
{#if diff.kind === 'raw_app'}
|
||||
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
|
||||
{/if}
|
||||
<!-- Status badges -->
|
||||
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0}
|
||||
<Badge
|
||||
title="This item was newly created in the parent workspace '{parentWorkspaceId}'"
|
||||
color="indigo"
|
||||
size="xs">New</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead > 0}
|
||||
<Badge title="This item was deleted in '{currentWorkspaceId}'" color="red" size="xs"
|
||||
>Deleted</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if diff.exists_in_fork && !diff.exists_in_source && diff.behind > 0}
|
||||
<Badge
|
||||
title="This item was deleted in the parent workspace '{parentWorkspaceId}'"
|
||||
color="red"
|
||||
size="xs">Deleted</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if diff.exists_in_fork && !diff.exists_in_source && diff.ahead > 0 && diff.behind == 0}
|
||||
<Badge
|
||||
title="This item was newly created in '{currentWorkspaceId}'"
|
||||
color="indigo"
|
||||
size="xs">New</Badge
|
||||
>
|
||||
{/if}
|
||||
{@const ciStatus = getCiTestStatus(diff)}
|
||||
{#if ciStatus === 'pass'}
|
||||
<Badge color="green" size="xs"><CircleCheck size={10} class="mr-0.5" />CI pass</Badge>
|
||||
{:else if ciStatus === 'fail'}
|
||||
<Badge color="red" size="xs"><CircleX size={10} class="mr-0.5" />CI fail</Badge>
|
||||
{:else if ciStatus === 'running'}
|
||||
<Badge color="yellow" size="xs"
|
||||
><Loader2 size={10} class="mr-0.5 animate-spin" />CI</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isConflict || existsInBothWorkspaces}
|
||||
{#if diff.ahead > 0}
|
||||
<Badge color="green" size="xs">
|
||||
<ArrowUpRight class="w-3 h-3 inline" />
|
||||
{diff.ahead} ahead
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if diff.behind > 0}
|
||||
<Badge color="blue" size="xs">
|
||||
<ArrowDownRight class="w-3 h-3 inline" />
|
||||
{diff.behind} behind
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if isConflict}
|
||||
<Badge color="orange" size="xs">
|
||||
<AlertTriangle class="w-3 h-3 inline" />
|
||||
Conflict
|
||||
</Badge>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class:invisible={!existsInBothWorkspaces}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onclick={() => showDiff(diff.kind as Kind, diff.path)}
|
||||
>
|
||||
<DiffIcon class="w-3 h-3" />
|
||||
Show diff
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -1104,8 +1233,18 @@
|
||||
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible}
|
||||
{#if !(mergeIntoParent && !canDeployToParent)}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if mergeIntoParent && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()}
|
||||
<Button
|
||||
variant="default"
|
||||
startIcon={{ icon: UserPlus }}
|
||||
on:click={() => deploymentRequestPanel?.openRequestDialog()}
|
||||
>
|
||||
Request deployment
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={selectedItems.length === 0 ||
|
||||
deploying ||
|
||||
(hasBehindChanges && !allowBehindChangesOverride) ||
|
||||
@@ -1120,16 +1259,16 @@
|
||||
({selectedConflicts} conflicts)
|
||||
{/if}
|
||||
</Button>
|
||||
{#if hasUnselectedOnBehalfOf}
|
||||
<span class="text-xs text-yellow-600">
|
||||
You must set the "on behalf of" user for all items before deploying
|
||||
<Tooltip class="text-yellow-600">
|
||||
The "run on behalf of" field defines which user's permissions will be
|
||||
applied during execution. Make sure this is set to an appropriate user
|
||||
before deploying.
|
||||
</Tooltip>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !(mergeIntoParent && !canDeployToParent) && hasUnselectedOnBehalfOf}
|
||||
<span class="text-xs text-yellow-600">
|
||||
You must set the "on behalf of" user for all items before deploying
|
||||
<Tooltip class="text-yellow-600">
|
||||
The "run on behalf of" field defines which user's permissions will be applied
|
||||
during execution. Make sure this is set to an appropriate user before
|
||||
deploying.
|
||||
</Tooltip>
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1148,89 +1287,17 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
</WorkspaceDeployLayout>
|
||||
</div>
|
||||
|
||||
<!-- Fork Triggers Section -->
|
||||
<div class="bg-surface-tertiary p-4 rounded-md border">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h3 class="text-sm font-semibold">Triggers created in this fork</h3>
|
||||
{#if !loadingTriggers}
|
||||
<Badge color="indigo" size="xs"
|
||||
>{forkTriggers.length} trigger{forkTriggers.length !== 1 ? 's' : ''}</Badge
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Alert title="Deploy and/or delete these triggers" type="info" class="mb-2">
|
||||
When forking a workspace, triggers are not forked to avoid unnecessary executions or
|
||||
collisions. If you created this triggers with the intention of deploying them to the parent
|
||||
workspace, you can do so here. Otherwise it is recommended to delete them or disable them.
|
||||
</Alert>
|
||||
|
||||
{#if loadingTriggers}
|
||||
<div class="flex items-center gap-2 text-secondary text-sm p-4">
|
||||
<Loader2 class="animate-spin w-4 h-4" />
|
||||
Loading triggers...
|
||||
</div>
|
||||
{:else if forkTriggers.length === 0}
|
||||
<div class="text-secondary text-sm p-4 border rounded-md bg-surface-tertiary">
|
||||
No triggers in this fork workspace.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="border rounded-md bg-surface-tertiary">
|
||||
{#each forkTriggers as trigger (trigger.triggerKind + ':' + trigger.path)}
|
||||
<Row
|
||||
kind="trigger"
|
||||
triggerKind={trigger.triggerKind}
|
||||
path={trigger.path}
|
||||
href={getTriggerHref(trigger.triggerKind)}
|
||||
marked={undefined}
|
||||
isSelectable={false}
|
||||
canFavorite={false}
|
||||
workspaceId={currentWorkspaceId}
|
||||
>
|
||||
{#snippet customSummary()}
|
||||
<span>{getTriggerDisplayName(trigger.triggerKind)}</span>
|
||||
<span class="text-secondary mx-1">→</span>
|
||||
<span class="text-secondary">{trigger.scriptPath}</span>
|
||||
{#if trigger.isFlow}
|
||||
<Badge color="blue" size="xs">flow</Badge>
|
||||
{/if}
|
||||
{#if trigger.extraLabel}
|
||||
<span class="text-tertiary text-xs">({trigger.extraLabel})</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{#if trigger.enabled != null}
|
||||
<Badge color={trigger.enabled ? 'green' : 'gray'} size="xs">
|
||||
{trigger.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
{/if}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onclick={() => {
|
||||
deploymentDrawer?.openDrawer(trigger.path, 'trigger', {
|
||||
triggers: { kind: trigger.triggerKind }
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Upload size={12} />
|
||||
Deploy
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onclick={() => deleteTrigger(trigger)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Row>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<DeploymentRequestPanel
|
||||
bind:this={deploymentRequestPanel}
|
||||
forkWorkspaceId={currentWorkspaceId}
|
||||
{parentWorkspaceId}
|
||||
currentUsername={$userStore?.username ?? ''}
|
||||
isAdmin={$userStore?.is_admin ?? false}
|
||||
onStateChange={(open) => {
|
||||
hasOpenDeploymentRequest = open
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-tertiary p-4 rounded-md border">
|
||||
@@ -1238,8 +1305,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<DiffDrawer bind:this={diffDrawer} {isFlow} />
|
||||
|
||||
<ScheduleEditor bind:this={scheduleEditor} />
|
||||
<RouteEditor bind:this={routeEditor} />
|
||||
<WebsocketTriggerEditor bind:this={websocketEditor} />
|
||||
<KafkaTriggerEditor bind:this={kafkaEditor} />
|
||||
<PostgresTriggerEditor bind:this={postgresEditor} />
|
||||
<NatsTriggerEditor bind:this={natsEditor} />
|
||||
<MqttTriggerEditor bind:this={mqttEditor} />
|
||||
<SqsTriggerEditor bind:this={sqsEditor} />
|
||||
<GcpTriggerEditor bind:this={gcpEditor} />
|
||||
<EmailTriggerEditor bind:this={emailEditor} />
|
||||
|
||||
<ConfirmationModal
|
||||
title="Delete trigger"
|
||||
confirmationText="Delete"
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import { Badge, Button } from '$lib/components/common'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { safeSelectItems } from '$lib/components/select/utils.svelte'
|
||||
import {
|
||||
WorkspaceService,
|
||||
type DeploymentRequest,
|
||||
type DeploymentRequestComment,
|
||||
type DeploymentRequestEligibleDeployer
|
||||
} from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Send, Plus, X, ArchiveX } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
forkWorkspaceId: string
|
||||
parentWorkspaceId: string
|
||||
currentUsername: string
|
||||
isAdmin: boolean
|
||||
onStateChange?: (hasOpenRequest: boolean) => void
|
||||
}
|
||||
|
||||
let { forkWorkspaceId, parentWorkspaceId, currentUsername, isAdmin, onStateChange }: Props =
|
||||
$props()
|
||||
|
||||
let request = $state<DeploymentRequest | null>(null)
|
||||
let loading = $state(true)
|
||||
let eligibleDeployers = $state<DeploymentRequestEligibleDeployer[]>([])
|
||||
let showRequestDialog = $state(false)
|
||||
let selectedAssignees = $state<string[]>([])
|
||||
let assigneeToAdd = $state<string | undefined>(undefined)
|
||||
let newComment = $state('')
|
||||
let posting = $state(false)
|
||||
let replyParentId = $state<number | undefined>(undefined)
|
||||
let replyBody = $state('')
|
||||
|
||||
let canCancel = $derived(request != null && (isAdmin || request.requested_by === currentUsername))
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
try {
|
||||
request = await WorkspaceService.getOpenDeploymentRequest({ workspace: forkWorkspaceId })
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load open deployment request', e)
|
||||
request = null
|
||||
} finally {
|
||||
loading = false
|
||||
onStateChange?.(request != null)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEligibleDeployers() {
|
||||
try {
|
||||
eligibleDeployers = await WorkspaceService.listDeploymentRequestEligibleDeployers({
|
||||
workspace: forkWorkspaceId
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load eligible deployers', e)
|
||||
eligibleDeployers = []
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load()
|
||||
loadEligibleDeployers()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (assigneeToAdd && !selectedAssignees.includes(assigneeToAdd)) {
|
||||
const v = assigneeToAdd
|
||||
untrack(() => {
|
||||
selectedAssignees = [...selectedAssignees, v]
|
||||
assigneeToAdd = undefined
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function createRequest() {
|
||||
if (selectedAssignees.length === 0) {
|
||||
sendUserToast('Pick at least one assignee', true)
|
||||
return
|
||||
}
|
||||
posting = true
|
||||
try {
|
||||
request = await WorkspaceService.createDeploymentRequest({
|
||||
workspace: forkWorkspaceId,
|
||||
requestBody: { assignees: selectedAssignees }
|
||||
})
|
||||
sendUserToast(`Deployment request sent to ${selectedAssignees.length} assignee(s)`)
|
||||
showRequestDialog = false
|
||||
selectedAssignees = []
|
||||
onStateChange?.(true)
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to create deployment request: ${e.body || e.message}`, true)
|
||||
} finally {
|
||||
posting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRequest() {
|
||||
if (!request) return
|
||||
if (!confirm('Cancel the open deployment request? Assignees will be notified.')) return
|
||||
try {
|
||||
await WorkspaceService.cancelDeploymentRequest({
|
||||
workspace: forkWorkspaceId,
|
||||
id: request.id
|
||||
})
|
||||
sendUserToast('Deployment request cancelled')
|
||||
request = null
|
||||
onStateChange?.(false)
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to cancel: ${e.body || e.message}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function postComment(body: string, parentId?: number) {
|
||||
if (!request || !body.trim()) return
|
||||
posting = true
|
||||
try {
|
||||
const created = await WorkspaceService.createDeploymentRequestComment({
|
||||
workspace: forkWorkspaceId,
|
||||
id: request.id,
|
||||
requestBody: { body, parent_id: parentId }
|
||||
})
|
||||
request = {
|
||||
...request,
|
||||
comments: [...request.comments, created]
|
||||
}
|
||||
if (parentId == null) {
|
||||
newComment = ''
|
||||
} else {
|
||||
replyBody = ''
|
||||
replyParentId = undefined
|
||||
}
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to post comment: ${e.body || e.message}`, true)
|
||||
} finally {
|
||||
posting = false
|
||||
}
|
||||
}
|
||||
|
||||
let topLevel = $derived(request?.comments.filter((c) => c.parent_id == null) ?? [])
|
||||
function repliesOf(parent: DeploymentRequestComment): DeploymentRequestComment[] {
|
||||
if (!request) return []
|
||||
return request.comments.filter((c) => c.parent_id === parent.id)
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
// Has content to render inline in the deploy panel: dialog open,
|
||||
// request open, or still loading.
|
||||
let hasContent = $derived(loading || showRequestDialog || request != null)
|
||||
|
||||
export function refresh() {
|
||||
load()
|
||||
}
|
||||
|
||||
export function openRequestDialog() {
|
||||
showRequestDialog = true
|
||||
}
|
||||
|
||||
export function isDialogOpen(): boolean {
|
||||
return showRequestDialog
|
||||
}
|
||||
|
||||
export function hasOpenRequest(): boolean {
|
||||
return request != null
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if hasContent}
|
||||
<div class="flex flex-col gap-2 mt-3 pt-3 border-t">
|
||||
{#if loading}
|
||||
<div class="text-secondary text-xs">Loading deployment request…</div>
|
||||
{:else if !request}
|
||||
<!-- showRequestDialog == true -->
|
||||
<div class="flex flex-col gap-2 border rounded-md p-3 bg-surface">
|
||||
<div class="text-xs text-secondary">
|
||||
Pick one or more assignees. They must be admins or members of <code>wm_deployers</code>
|
||||
in <b>{parentWorkspaceId}</b>.
|
||||
</div>
|
||||
<Select
|
||||
bind:value={assigneeToAdd}
|
||||
items={safeSelectItems(
|
||||
eligibleDeployers
|
||||
.filter((d) => !selectedAssignees.includes(d.username))
|
||||
.map((d) => ({
|
||||
label: `${d.username} <${d.email}>`,
|
||||
value: d.username
|
||||
}))
|
||||
)}
|
||||
placeholder="Add assignee…"
|
||||
/>
|
||||
{#if selectedAssignees.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each selectedAssignees as a (a)}
|
||||
<Badge color="blue">
|
||||
{a}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 hover:text-red-600"
|
||||
onclick={() => {
|
||||
selectedAssignees = selectedAssignees.filter((s) => s !== a)
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="accent"
|
||||
size="xs"
|
||||
startIcon={{ icon: Send }}
|
||||
disabled={posting || selectedAssignees.length === 0}
|
||||
onclick={createRequest}
|
||||
>
|
||||
Send request
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
onclick={() => {
|
||||
showRequestDialog = false
|
||||
selectedAssignees = []
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-between flex-wrap gap-2 text-xs">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Badge color="green" size="xs">Deployment request open</Badge>
|
||||
<span class="text-secondary">by</span>
|
||||
<b>{request.requested_by}</b>
|
||||
<span class="text-secondary">at {formatTimestamp(request.requested_at)}</span>
|
||||
</div>
|
||||
{#if canCancel}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: ArchiveX }}
|
||||
onclick={cancelRequest}
|
||||
>
|
||||
Cancel request
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-wrap text-xs">
|
||||
<span class="text-secondary">Assignees:</span>
|
||||
{#each request.assignees as a (a.username)}
|
||||
<Badge color="indigo" size="xs">{a.username}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="border rounded-md bg-surface p-3 flex flex-col gap-3">
|
||||
{#if topLevel.length === 0}
|
||||
<div class="text-secondary text-xs">No comments yet. Be the first to leave feedback.</div>
|
||||
{/if}
|
||||
{#each topLevel as c (c.id)}
|
||||
<div class="border rounded-md p-2 flex flex-col gap-1" class:opacity-60={c.obsolete}>
|
||||
<div class="flex items-center justify-between gap-2 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<b>{c.author}</b>
|
||||
{#if c.anchor_kind && c.anchor_path}
|
||||
<Badge color="blue" size="xs">
|
||||
{c.anchor_kind}:{c.anchor_path}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if c.obsolete}
|
||||
<Badge color="gray" size="xs">obsolete</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-tertiary text-2xs">{formatTimestamp(c.created_at)}</span>
|
||||
</div>
|
||||
<div class="text-sm whitespace-pre-wrap">{c.body}</div>
|
||||
|
||||
{#each repliesOf(c) as r (r.id)}
|
||||
<div
|
||||
class="ml-4 border-l pl-2 flex flex-col gap-1"
|
||||
class:opacity-60={r.obsolete || c.obsolete}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 text-xs">
|
||||
<b>{r.author}</b>
|
||||
<span class="text-tertiary text-2xs">{formatTimestamp(r.created_at)}</span>
|
||||
</div>
|
||||
<div class="text-sm whitespace-pre-wrap">{r.body}</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if replyParentId === c.id}
|
||||
<div class="ml-4 flex flex-col gap-1 mt-1">
|
||||
<TextInput bind:value={replyBody} inputProps={{ placeholder: 'Write a reply…' }} />
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="accent"
|
||||
disabled={posting || !replyBody.trim()}
|
||||
onclick={() => postComment(replyBody, c.id)}
|
||||
>
|
||||
Reply
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onclick={() => {
|
||||
replyParentId = undefined
|
||||
replyBody = ''
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="text-xs text-tertiary hover:text-secondary mt-1 self-start"
|
||||
onclick={() => {
|
||||
replyParentId = c.id
|
||||
replyBody = ''
|
||||
}}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="flex flex-col gap-2 border-t pt-2">
|
||||
<TextInput
|
||||
bind:value={newComment}
|
||||
inputProps={{ placeholder: 'Leave a general comment…' }}
|
||||
/>
|
||||
<div class="self-end">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
disabled={posting || !newComment.trim()}
|
||||
onclick={() => postComment(newComment)}
|
||||
>
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -36,6 +36,7 @@
|
||||
let name = $state(untrack(() => rule)?.name ?? '')
|
||||
let disableDirectDeployment = $state(hasRule('DisableDirectDeployment'))
|
||||
let disableFork = $state(hasRule('DisableWorkspaceForking'))
|
||||
let restrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers'))
|
||||
let selectedGroups = $state<string[]>(
|
||||
untrack(() => rule)?.bypass_groups?.map((g) => g.replace('g/', '')) ?? []
|
||||
)
|
||||
@@ -47,6 +48,7 @@
|
||||
let initialName = $state(untrack(() => rule)?.name ?? '')
|
||||
let initialDisableDirectDeployment = $state(hasRule('DisableDirectDeployment'))
|
||||
let initialDisableFork = $state(hasRule('DisableWorkspaceForking'))
|
||||
let initialRestrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers'))
|
||||
let initialSelectedGroups = $state<string[]>(
|
||||
untrack(() => rule)?.bypass_groups
|
||||
? untrack(() => rule)!.bypass_groups.map((g) => g.replace('g/', ''))
|
||||
@@ -112,11 +114,13 @@
|
||||
? name.trim() !== '' ||
|
||||
disableDirectDeployment ||
|
||||
disableFork ||
|
||||
restrictDeployToDeployers ||
|
||||
selectedGroups.length > 0 ||
|
||||
selectedUsers.length > 0
|
||||
: name !== initialName ||
|
||||
disableDirectDeployment !== initialDisableDirectDeployment ||
|
||||
disableFork !== initialDisableFork ||
|
||||
restrictDeployToDeployers !== initialRestrictDeployToDeployers ||
|
||||
JSON.stringify([...selectedGroups].sort()) !==
|
||||
JSON.stringify([...initialSelectedGroups].sort()) ||
|
||||
JSON.stringify([...selectedUsers].sort()) !==
|
||||
@@ -153,7 +157,10 @@
|
||||
name,
|
||||
rules: [
|
||||
...(disableDirectDeployment ? ['DisableDirectDeployment' as ProtectionRuleKind] : []),
|
||||
...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : [])
|
||||
...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : []),
|
||||
...(restrictDeployToDeployers
|
||||
? ['RestrictDeployToDeployers' as ProtectionRuleKind]
|
||||
: [])
|
||||
],
|
||||
bypass_groups: selectedGroups,
|
||||
bypass_users: selectedUsers
|
||||
@@ -178,7 +185,10 @@
|
||||
requestBody: {
|
||||
rules: [
|
||||
...(disableDirectDeployment ? ['DisableDirectDeployment' as ProtectionRuleKind] : []),
|
||||
...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : [])
|
||||
...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : []),
|
||||
...(restrictDeployToDeployers
|
||||
? ['RestrictDeployToDeployers' as ProtectionRuleKind]
|
||||
: [])
|
||||
],
|
||||
bypass_groups: selectedGroups,
|
||||
bypass_users: selectedUsers
|
||||
@@ -191,6 +201,7 @@
|
||||
initialName = name
|
||||
initialDisableDirectDeployment = disableDirectDeployment
|
||||
initialDisableFork = disableFork
|
||||
initialRestrictDeployToDeployers = restrictDeployToDeployers
|
||||
initialSelectedGroups = clone(selectedGroups)
|
||||
initialSelectedUsers = clone(selectedUsers)
|
||||
|
||||
@@ -310,6 +321,20 @@
|
||||
/>
|
||||
<div class="text-xs text-secondary ml-6">Users cannot create forks of this workspace.</div>
|
||||
</div>
|
||||
|
||||
<!-- Restrict deploy to deployers -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
bind:checked={restrictDeployToDeployers}
|
||||
options={{
|
||||
right: 'Restrict deployment to wm_deployers'
|
||||
}}
|
||||
/>
|
||||
<div class="text-xs text-secondary ml-6">
|
||||
Only workspace admins and members of <code>wm_deployers</code> can deploy to this workspace.
|
||||
Non-deployers can still fork, browse, and request a review.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user