✨ Add the design preview of file branching and pull requests
This commit carries the design-branching preview by Juan de la Cruz: elhombretecla/penpot:branching at 0ac265a3f5, which holds 45 commits and 3 develop merges and was last synced with develop on 2026-09-16. The branch is squashed together with a merge of origin/develop at 7c039231f7, so the tree is the merged result. The merge resolved conflicts in six files: - backend/src/app/migrations.clj: develop's 0155 and 0156 steps run first, then the branching steps; the names differ. - backend/src/app/rpc/commands/viewer.clj: both requires stay. - common/src/app/common/flags.cljc: both flags stay. - frontend/src/app/main/ui/dashboard/file_menu.cljs: "Create branch" becomes a menu-item* with the same id, guard and handler. - frontend/src/app/main/ui/dashboard/grid.cljs: the portal goes with develop's menu rewrite; the branches popover stays in the trigger. - frontend/translations/en.po: both sides' entries stay, with develop's source references. Co-authored-by: Juan de la Cruz <delacruzgarciajuan@gmail.com> AI-assisted-by: mixed models
244
backend/resources/app/email/review-request/en.html
Normal file
@ -0,0 +1,244 @@
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>
|
||||
</title>
|
||||
<!--[if !mso]><!-- -->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mj-column-px-425 {
|
||||
width: 425px !important;
|
||||
max-width: 425px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width:480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="background-color:#E5E5E5;">
|
||||
<div style="background-color:#E5E5E5;">
|
||||
<!--[if mso | IE]>
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
<tr>
|
||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||
<![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
<tr>
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:600px;"
|
||||
>
|
||||
<![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:97px;">
|
||||
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
|
||||
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||
width="97" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
<tr>
|
||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||
<![endif]-->
|
||||
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
<tr>
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:600px;"
|
||||
>
|
||||
<![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
||||
Hello {{name|abbreviate:25}}!</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||
<span style="font-weight:bold;">{{ source-user }}</span> has requested your review on the pull request "{{ pull-request-title }}" ({{ pull-request-route }}).</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;font-style:italic;line-height:150%;text-align:left;color:#212426;
|
||||
border-top: 1px solid #bfbfbf; border-bottom: 1px solid #bfbfbf; padding: 32px 0px;">
|
||||
{{ pull-request-description }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" vertical-align="middle"
|
||||
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:separate;line-height:100%;">
|
||||
<tr>
|
||||
<td align="center" bgcolor="#6911d4" role="presentation"
|
||||
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||
valign="middle">
|
||||
<a href="{{ pull-request-url }}"
|
||||
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||
target="_blank"> REVIEW THE CHANGES </a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||
The Penpot team.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% include "app/email/includes/footer.html" %}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
1
backend/resources/app/email/review-request/en.subj
Normal file
@ -0,0 +1 @@
|
||||
Review requested
|
||||
13
backend/resources/app/email/review-request/en.txt
Normal file
@ -0,0 +1,13 @@
|
||||
Hello {{name|abbreviate:25}}!
|
||||
|
||||
{{ source-user }} has requested your review on the pull request "{{ pull-request-title }}" ({{ pull-request-route }}).
|
||||
|
||||
--
|
||||
|
||||
{{ pull-request-description }}
|
||||
|
||||
--
|
||||
|
||||
{{ pull-request-url }}
|
||||
|
||||
The Penpot team.
|
||||
@ -64,6 +64,7 @@ export PENPOT_FLAGS="\
|
||||
enable-file-schema-validation \
|
||||
enable-redis-cache \
|
||||
enable-link-preview \
|
||||
enable-branching \
|
||||
enable-subscriptions";
|
||||
|
||||
# Uncomment for nexus integration testing
|
||||
|
||||
@ -617,7 +617,8 @@
|
||||
(def ^:private sql:get-project-files
|
||||
"SELECT f.id FROM file AS f
|
||||
WHERE f.project_id = ?
|
||||
AND f.deleted_at IS NULL")
|
||||
AND f.deleted_at IS NULL
|
||||
AND f.is_branch IS FALSE")
|
||||
|
||||
(defn get-project-files
|
||||
"Get a set of file ids for the project"
|
||||
|
||||
@ -235,6 +235,9 @@
|
||||
[:quotes-comments-per-file {:optional true} ::sm/int]
|
||||
[:quotes-snapshots-per-file {:optional true} ::sm/int]
|
||||
[:quotes-snapshots-per-team {:optional true} ::sm/int]
|
||||
[:quotes-branches-per-file {:optional true} ::sm/int]
|
||||
[:quotes-branches-per-team {:optional true} ::sm/int]
|
||||
[:quotes-pull-requests-per-team {:optional true} ::sm/int]
|
||||
[:quotes-team-access-requests-per-team {:optional true} ::sm/int]
|
||||
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
|
||||
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
|
||||
|
||||
@ -554,6 +554,22 @@
|
||||
:id ::request-team-access
|
||||
:schema schema:request-team-access))
|
||||
|
||||
(def ^:private schema:review-request
|
||||
[:map
|
||||
[:name ::sm/text]
|
||||
[:source-user ::sm/text]
|
||||
[:pull-request-title ::sm/text]
|
||||
[:pull-request-route ::sm/text]
|
||||
;; plain :string on purpose: the description is optional and may be empty
|
||||
[:pull-request-description [:string {:max 4000}]]
|
||||
[:pull-request-url ::sm/text]])
|
||||
|
||||
(def review-request
|
||||
"Email sent to the requested reviewers of a pull request."
|
||||
(template-factory
|
||||
:id ::review-request
|
||||
:schema schema:review-request))
|
||||
|
||||
(def ^:private schema:comment-mention
|
||||
[:map
|
||||
[:name ::sm/text]
|
||||
|
||||
@ -517,7 +517,13 @@
|
||||
:fn (mg/resource "app/migrations/sql/0155-drop-http-session-table.sql")}
|
||||
|
||||
{:name "0156-add-storage-object-json-dedup-index"
|
||||
:fn (mg/resource "app/migrations/sql/0156-add-storage-object-json-dedup-index.sql")}])
|
||||
:fn (mg/resource "app/migrations/sql/0156-add-storage-object-json-dedup-index.sql")}
|
||||
|
||||
{:name "0155-add-file-branch-table"
|
||||
:fn (mg/resource "app/migrations/sql/0155-add-file-branch-table.sql")}
|
||||
|
||||
{:name "0156-add-file-pull-request-tables"
|
||||
:fn (mg/resource "app/migrations/sql/0156-add-file-pull-request-tables.sql")}])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
-- Idempotent on purpose: this migration was originally registered as
|
||||
-- "0150-add-file-branch-table" / "0152-add-file-branch-table" during
|
||||
-- development, so instances that already applied it under an old name
|
||||
-- re-apply this one safely (and pick up the later-added
|
||||
-- base_branch_revn column).
|
||||
|
||||
ALTER TABLE file
|
||||
ADD COLUMN IF NOT EXISTS is_branch boolean NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_branch (
|
||||
id uuid PRIMARY KEY,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
|
||||
branch_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
|
||||
source_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
|
||||
|
||||
base_snapshot_id uuid NULL,
|
||||
|
||||
-- main's revn when the base was (re)positioned
|
||||
base_revn bigint NOT NULL,
|
||||
-- the BRANCH file's revn at the same moment; the two files carry
|
||||
-- independent revn counters, so each side's "did it move?" gate must
|
||||
-- compare against its own counter
|
||||
base_branch_revn bigint NOT NULL DEFAULT 0,
|
||||
|
||||
created_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
|
||||
|
||||
name text NOT NULL,
|
||||
description text NULL,
|
||||
|
||||
status text NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'merged', 'archived')),
|
||||
|
||||
merged_at timestamptz NULL,
|
||||
merged_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
|
||||
deleted_at timestamptz NULL,
|
||||
|
||||
CHECK (branch_file_id != source_file_id)
|
||||
);
|
||||
|
||||
-- upgrade path from the 0150 development shape
|
||||
ALTER TABLE file_branch
|
||||
ADD COLUMN IF NOT EXISTS base_branch_revn bigint NOT NULL DEFAULT 0;
|
||||
|
||||
-- backfill: at creation both counters coincide, so base_revn is the best
|
||||
-- available approximation for rows created before the column existed
|
||||
UPDATE file_branch SET base_branch_revn = base_revn WHERE base_branch_revn = 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS file_branch__source_file_id__idx
|
||||
ON file_branch(source_file_id) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS file_branch__branch_file_id__idx
|
||||
ON file_branch(branch_file_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS file_branch__base_snapshot_id__idx
|
||||
ON file_branch(base_snapshot_id) WHERE base_snapshot_id IS NOT NULL;
|
||||
@ -0,0 +1,65 @@
|
||||
CREATE TABLE IF NOT EXISTS file_pull_request (
|
||||
id uuid PRIMARY KEY,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
|
||||
file_branch_id uuid NOT NULL REFERENCES file_branch(id) ON DELETE CASCADE DEFERRABLE,
|
||||
|
||||
-- denormalized from file_branch: source is the BRANCH file, target is main
|
||||
source_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
|
||||
target_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
|
||||
|
||||
title text NOT NULL,
|
||||
description text NULL,
|
||||
|
||||
created_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
|
||||
|
||||
status text NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'merged', 'closed')),
|
||||
|
||||
-- pinned snapshot of the BRANCH file that reviewers see
|
||||
review_snapshot_id uuid NULL,
|
||||
-- the branch file's revn when the review snapshot was (re)positioned;
|
||||
-- branch_revn > review_revn is the cheap "outdated" gate
|
||||
review_revn bigint NOT NULL DEFAULT 0,
|
||||
review_updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
|
||||
closed_at timestamptz NULL,
|
||||
closed_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
|
||||
deleted_at timestamptz NULL,
|
||||
|
||||
CHECK (source_file_id != target_file_id)
|
||||
);
|
||||
|
||||
-- at most one live pull request per branch
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS file_pull_request__file_branch_id__open__idx
|
||||
ON file_pull_request(file_branch_id)
|
||||
WHERE status = 'open' AND deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS file_pull_request__target_file_id__idx
|
||||
ON file_pull_request(target_file_id) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS file_pull_request__review_snapshot_id__idx
|
||||
ON file_pull_request(review_snapshot_id) WHERE review_snapshot_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_pull_request_review (
|
||||
pull_request_id uuid NOT NULL REFERENCES file_pull_request(id) ON DELETE CASCADE DEFERRABLE,
|
||||
profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE,
|
||||
|
||||
state text NOT NULL DEFAULT 'pending'
|
||||
CHECK (state IN ('pending', 'approved', 'changes-requested')),
|
||||
|
||||
-- review_revn of the pull request when the verdict was submitted; a
|
||||
-- verdict is stale when it no longer matches the pr's review_revn
|
||||
reviewed_revn bigint NULL,
|
||||
comment text NULL,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
|
||||
PRIMARY KEY (pull_request_id, profile_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS file_pull_request_review__profile_id__idx
|
||||
ON file_pull_request_review(profile_id);
|
||||
@ -378,6 +378,8 @@
|
||||
'app.rpc.commands.files-share
|
||||
'app.rpc.commands.files-update
|
||||
'app.rpc.commands.files-snapshot
|
||||
'app.rpc.commands.files-branch
|
||||
'app.rpc.commands.files-pull-request
|
||||
'app.rpc.commands.files-thumbnails
|
||||
'app.rpc.commands.ldap
|
||||
'app.rpc.commands.management
|
||||
|
||||
@ -282,7 +282,11 @@
|
||||
f.vern,
|
||||
f.is_shared,
|
||||
ft.media_id AS thumbnail_id,
|
||||
p.team_id
|
||||
p.team_id,
|
||||
(select count(*) from file_branch as fb
|
||||
where fb.source_file_id = f.id
|
||||
and fb.deleted_at is null
|
||||
and fb.status = 'open') as branches_count
|
||||
from file as f
|
||||
inner join project as p on (p.id = f.project_id)
|
||||
left join file_thumbnail as ft on (ft.file_id = f.id
|
||||
@ -290,6 +294,7 @@
|
||||
and ft.deleted_at is null)
|
||||
where f.project_id = ?
|
||||
and f.deleted_at is null
|
||||
and f.is_branch is false
|
||||
order by f.modified_at desc")
|
||||
|
||||
(defn get-project-files
|
||||
@ -776,6 +781,10 @@
|
||||
f.name,
|
||||
f.is_shared,
|
||||
ft.media_id AS thumbnail_id,
|
||||
(select count(*) from file_branch as fb
|
||||
where fb.source_file_id = f.id
|
||||
and fb.deleted_at is null
|
||||
and fb.status = 'open') as branches_count,
|
||||
row_number() over w as row_num,
|
||||
p.team_id
|
||||
from file as f
|
||||
@ -786,6 +795,7 @@
|
||||
where p.team_id = ?
|
||||
and p.deleted_at is null
|
||||
and f.deleted_at is null
|
||||
and f.is_branch is false
|
||||
window w as (partition by f.project_id order by f.modified_at desc)
|
||||
order by f.modified_at desc
|
||||
)
|
||||
@ -830,6 +840,7 @@
|
||||
WHERE p.team_id = ?
|
||||
AND (p.deleted_at > ?::timestamptz OR
|
||||
f.deleted_at > ?::timestamptz)
|
||||
AND f.is_branch IS FALSE
|
||||
WINDOW w AS (PARTITION BY f.project_id
|
||||
ORDER BY f.modified_at DESC)
|
||||
ORDER BY f.modified_at DESC
|
||||
@ -1307,6 +1318,7 @@
|
||||
JOIN team AS t ON (t.id = p.team_id)
|
||||
WHERE t.deleted_at IS NULL
|
||||
AND t.id = ?
|
||||
AND f.is_branch IS FALSE
|
||||
AND f.id = ANY(?::uuid[])")
|
||||
|
||||
(def ^:private sql:restore-files
|
||||
|
||||
1028
backend/src/app/rpc/commands/files_branch.clj
Normal file
887
backend/src/app/rpc/commands/files_pull_request.clj
Normal file
@ -0,0 +1,887 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.rpc.commands.files-pull-request
|
||||
"RPC commands for pull requests on top of file branching: a review
|
||||
request over a pinned snapshot of a branch. A pull request does NOT
|
||||
create a new file; the review sandbox is a virtual read-only view of
|
||||
the branch served from the pinned snapshot (`get-pull-request-bundle`),
|
||||
so it disappears when the pull request is closed and the snapshot pin
|
||||
is released. Merging stays on `app.rpc.commands.files-branch`; approving
|
||||
a pull request is informative and never a gate for the merge."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.features :as-alias cfeat]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uri :as uri]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.email :as eml]
|
||||
[app.features.file-snapshots :as fsnap]
|
||||
[app.features.logical-deletion :as ldel]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.msgbus :as mbus]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.files-branch :as fbranch]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.util.services :as sv]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn check-pull-requests-enabled!
|
||||
"Guard the pull request commands behind the `:branching` product flag
|
||||
(pull requests are part of the branching feature). Public: the viewer
|
||||
bundle command guards its pr-id variant with it too."
|
||||
[]
|
||||
(when-not (contains? cf/flags :branching)
|
||||
(ex/raise :type :restriction
|
||||
:code :branching-disabled
|
||||
:hint "the branching feature is not enabled on this instance")))
|
||||
|
||||
;; --- Helpers
|
||||
|
||||
(defn- get-pull-request*
|
||||
"Fetch a pull request row or raise :not-found."
|
||||
[cfg id]
|
||||
(let [pr (db/get* cfg :file-pull-request {:id id})]
|
||||
(when (or (nil? pr) (some? (:deleted-at pr)))
|
||||
(ex/raise :type :not-found
|
||||
:code :pull-request-not-found
|
||||
:hint "unable to find pull request with the provided id"
|
||||
:pull-request-id id))
|
||||
pr))
|
||||
|
||||
(defn- check-author-or-admin!
|
||||
"Managing a pull request (edit metadata, reviewers, close, reopen) is
|
||||
allowed to its author and to team admins/owners. Raises the standard
|
||||
not-found on failure so object existence is not leaked."
|
||||
[cfg profile-id {:keys [created-by target-file-id]}]
|
||||
(let [perms (perms/get-file-read-permissions cfg profile-id target-file-id)]
|
||||
(files/check-read-permissions! perms)
|
||||
(when-not (or (= created-by profile-id)
|
||||
(:is-admin perms))
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "not found"))))
|
||||
|
||||
(defn- release-review-snapshot!
|
||||
"Reschedule a pull request's pinned review snapshot for normal deletion:
|
||||
it is pinned ~10 years while the pull request is open and must be
|
||||
released when the pull request is closed, merged or its snapshot is
|
||||
repositioned. The snapshot belongs to the BRANCH file (the pull
|
||||
request's source)."
|
||||
[cfg {:keys [review-snapshot-id source-file-id]} deleted-at]
|
||||
(when review-snapshot-id
|
||||
(fsnap/delete! cfg
|
||||
:id review-snapshot-id
|
||||
:file-id source-file-id
|
||||
:deleted-at deleted-at)))
|
||||
|
||||
(defn- pin-review-snapshot!
|
||||
"Materialize the review snapshot of the branch file: what reviewers see
|
||||
in the sandbox, pinned so the snapshot GC does not prune it while the
|
||||
pull request is open."
|
||||
[cfg branch-file title profile-id]
|
||||
(fsnap/create! cfg branch-file
|
||||
{:label (str "pr-review/" title)
|
||||
:profile-id profile-id
|
||||
:created-by "system"
|
||||
:deleted-at (ct/in-future {:days 3650})}))
|
||||
|
||||
(defn- validate-reviewers!
|
||||
"Check every reviewer id is a member of `team-id`; the author is
|
||||
silently dropped (requesting a review from yourself is meaningless).
|
||||
Returns the cleaned vector."
|
||||
[cfg team-id author-id reviewers]
|
||||
(let [reviewers (into [] (comp (distinct) (remove #(= author-id %))) reviewers)
|
||||
members (into #{} (map :id) (teams/get-team-members cfg team-id))
|
||||
invalid (remove members reviewers)]
|
||||
(when (seq invalid)
|
||||
(ex/raise :type :validation
|
||||
:code :reviewer-not-team-member
|
||||
:hint "all reviewers must be members of the team"
|
||||
:profile-ids (vec invalid)))
|
||||
reviewers))
|
||||
|
||||
(def ^:private sql:get-reviews
|
||||
"SELECT r.pull_request_id,
|
||||
r.profile_id,
|
||||
r.state,
|
||||
r.reviewed_revn,
|
||||
r.comment,
|
||||
r.updated_at
|
||||
FROM file_pull_request_review AS r
|
||||
JOIN profile AS p ON (p.id = r.profile_id)
|
||||
WHERE r.pull_request_id = ANY(?)
|
||||
ORDER BY r.created_at ASC")
|
||||
|
||||
(defn- get-reviews-map
|
||||
"{pull-request-id -> [review ...]} for the given pull request ids, each
|
||||
review annotated with `:stale` (verdict submitted over a previous
|
||||
review snapshot)."
|
||||
[cfg prs]
|
||||
(if (empty? prs)
|
||||
{}
|
||||
(let [ids (db/create-array (db/get-connection cfg) "uuid" (mapv :id prs))
|
||||
revn (into {} (map (juxt :id :review-revn)) prs)]
|
||||
(->> (db/exec! cfg [sql:get-reviews ids])
|
||||
(mapv (fn [{:keys [pull-request-id state reviewed-revn] :as row}]
|
||||
(assoc row :stale (and (not= "pending" state)
|
||||
(not= reviewed-revn (get revn pull-request-id))))))
|
||||
(group-by :pull-request-id)))))
|
||||
|
||||
(defn- review-state
|
||||
"Aggregate verdict of a pull request: `changes-requested` beats
|
||||
`approved`; stale verdicts (submitted over a previous snapshot) only
|
||||
count as `in-review`; no verdict at all is `pending`."
|
||||
[reviews]
|
||||
(let [submitted (remove #(= "pending" (:state %)) reviews)
|
||||
current (remove :stale submitted)]
|
||||
(cond
|
||||
(some #(= "changes-requested" (:state %)) current) "changes-requested"
|
||||
(some #(= "approved" (:state %)) current) "approved"
|
||||
(seq submitted) "in-review"
|
||||
:else "pending")))
|
||||
|
||||
(defn- decorate-pull-request
|
||||
"Attach the derived attributes the UI needs: reviews (+ aggregate
|
||||
`review-state`), `outdated` (the branch moved past the review snapshot,
|
||||
cheap revn gate) and entity-level ahead/behind/conflicts counts against
|
||||
main (only for open pull requests, gated like the branches listing)."
|
||||
[cfg main-data reviews-map {:keys [id status source-file-id target-file-id
|
||||
base-snapshot-id review-revn branch-revn]
|
||||
:as row}]
|
||||
(let [reviews (get reviews-map id [])
|
||||
open? (= "open" status)
|
||||
[ahead-revn behind-revn] (fbranch/revn-deltas row)
|
||||
[ahead behind conflicts]
|
||||
(if open?
|
||||
(fbranch/branch-diff-counts cfg main-data
|
||||
{:id id
|
||||
:source-file-id target-file-id
|
||||
:branch-file-id source-file-id
|
||||
:base-snapshot-id base-snapshot-id
|
||||
:ahead-revn ahead-revn
|
||||
:behind-revn behind-revn})
|
||||
[0 0 0])]
|
||||
(-> row
|
||||
(assoc :reviews (mapv #(dissoc % :pull-request-id) reviews))
|
||||
(assoc :review-state (review-state reviews))
|
||||
(assoc :outdated (and open? (> branch-revn review-revn)))
|
||||
(assoc :ahead ahead :behind behind :conflicts conflicts)
|
||||
(dissoc :branch-revn :source-revn :base-snapshot-id
|
||||
:base-revn :base-branch-revn :review-snapshot-id))))
|
||||
|
||||
(def ^:private sql:get-open-pull-request-for-branch
|
||||
"SELECT id FROM file_pull_request
|
||||
WHERE file_branch_id = ?
|
||||
AND status = 'open'
|
||||
AND deleted_at IS NULL")
|
||||
|
||||
;; --- Helpers: reviewer notification emails
|
||||
|
||||
(defn- format-pull-request-url
|
||||
"Deep link into the review sandbox: the branch file's workspace with the
|
||||
pr-id param."
|
||||
[{:keys [id source-file-id]} team-id]
|
||||
(str/join [(cf/get :public-uri)
|
||||
"/#/workspace?"
|
||||
(uri/map->query-string {:file-id source-file-id
|
||||
:team-id team-id
|
||||
:pr-id id})]))
|
||||
|
||||
(defn- send-review-request-emails!
|
||||
"Notify `reviewers` (profile ids) that `actor-id` requested their review
|
||||
on the pull request. Follows the same in-transaction outbox pattern as
|
||||
the comment emails."
|
||||
[conn {:keys [title description] :as pr} {:keys [team-id actor-id branch-name target-name reviewers]}]
|
||||
(when (seq reviewers)
|
||||
(let [users (->> (teams/get-users+props conn team-id)
|
||||
(map profile/decode-row)
|
||||
(d/index-by :id))
|
||||
actor (get users actor-id)
|
||||
route (str/join [branch-name " → " target-name])
|
||||
url (format-pull-request-url pr team-id)]
|
||||
(doseq [reviewer-id reviewers]
|
||||
(when-let [{:keys [fullname email]} (get users reviewer-id)]
|
||||
(eml/send!
|
||||
{::eml/conn conn
|
||||
::eml/factory eml/review-request
|
||||
:public-uri (cf/get :public-uri)
|
||||
:to email
|
||||
:name fullname
|
||||
:source-user (get actor :fullname "")
|
||||
:pull-request-title title
|
||||
:pull-request-route route
|
||||
:pull-request-description (or description "")
|
||||
:pull-request-url url}))))))
|
||||
|
||||
;; --- COMMAND: create-pull-request
|
||||
|
||||
(def ^:private schema:create-pull-request
|
||||
[:map {:title "create-pull-request"}
|
||||
[:branch-id ::sm/uuid]
|
||||
[:title [:string {:max 250}]]
|
||||
[:description {:optional true} [:string {:max 4000}]]
|
||||
[:reviewers {:optional true} [:vector ::sm/uuid]]])
|
||||
|
||||
(sv/defmethod ::create-pull-request
|
||||
"Create a pull request from an open branch towards its source file
|
||||
(main). Pins a review snapshot of the branch (the state reviewers see)
|
||||
and registers the requested reviewers, who must be members of the
|
||||
team. At most one open pull request may exist per branch."
|
||||
{::doc/added "2.16"
|
||||
::webhooks/event? true
|
||||
::sm/params schema:create-pull-request
|
||||
::climit/id [[:create-pull-request/by-profile ::rpc/profile-id]
|
||||
[:create-pull-request/global]]}
|
||||
[{:keys [::mbus/msgbus] :as cfg}
|
||||
{:keys [::rpc/profile-id branch-id title description reviewers]}]
|
||||
(check-pull-requests-enabled!)
|
||||
(when (str/blank? title)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-pull-request-title
|
||||
:hint "pull request title cannot be blank"))
|
||||
(let [branch (db/get* cfg :file-branch {:id branch-id})]
|
||||
(when (or (nil? branch) (some? (:deleted-at branch)))
|
||||
(ex/raise :type :not-found
|
||||
:code :branch-not-found
|
||||
:branch-id branch-id))
|
||||
(when (not= "open" (:status branch))
|
||||
(ex/raise :type :validation
|
||||
:code :branch-not-open
|
||||
:branch-id branch-id))
|
||||
|
||||
(let [branch-file-id (:branch-file-id branch)
|
||||
target-id (:source-file-id branch)]
|
||||
|
||||
;; Requesting a review is an author-side action: it needs edition
|
||||
;; permissions on the BRANCH file (merging keeps requiring edition
|
||||
;; permissions on main, unchanged). Checked BEFORE validating the
|
||||
;; reviewers so team membership cannot be probed without access.
|
||||
(files/check-edition-permissions! cfg profile-id branch-file-id)
|
||||
|
||||
(let [target-row (db/get-by-id cfg :file target-id)
|
||||
project (db/get-by-id cfg :project (:project-id target-row))
|
||||
team-id (:team-id project)
|
||||
reviewers (validate-reviewers! cfg team-id profile-id (or reviewers []))]
|
||||
|
||||
(-> cfg
|
||||
(assoc ::quotes/profile-id profile-id)
|
||||
(assoc ::quotes/team-id team-id)
|
||||
(quotes/check! {::quotes/id ::quotes/pull-requests-per-team}))
|
||||
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
;; Serialize against concurrent update-file on the branch so
|
||||
;; the review snapshot captures a consistent state (same
|
||||
;; advisory lock the save path takes), and against a
|
||||
;; concurrent create-pull-request on the same branch.
|
||||
(db/xact-lock! conn branch-file-id)
|
||||
|
||||
(when (db/exec-one! conn [sql:get-open-pull-request-for-branch branch-id])
|
||||
(ex/raise :type :validation
|
||||
:code :pull-request-already-exists
|
||||
:hint "the branch already has an open pull request"
|
||||
:branch-id branch-id))
|
||||
|
||||
(let [branch-file (bfc/get-file cfg branch-file-id :realize? true)
|
||||
snapshot (pin-review-snapshot! cfg branch-file title profile-id)
|
||||
pr-id (uuid/next)
|
||||
ts (ct/now)]
|
||||
|
||||
(db/insert! conn :file-pull-request
|
||||
{:id pr-id
|
||||
:file-branch-id branch-id
|
||||
:source-file-id branch-file-id
|
||||
:target-file-id target-id
|
||||
:title title
|
||||
:description description
|
||||
:created-by profile-id
|
||||
:status "open"
|
||||
:review-snapshot-id (:id snapshot)
|
||||
:review-revn (:revn branch-file)
|
||||
:review-updated-at ts}
|
||||
{::db/return-keys false})
|
||||
|
||||
(doseq [reviewer-id reviewers]
|
||||
(db/insert! conn :file-pull-request-review
|
||||
{:pull-request-id pr-id
|
||||
:profile-id reviewer-id}
|
||||
{::db/return-keys false}))
|
||||
|
||||
(send-review-request-emails! conn
|
||||
{:id pr-id
|
||||
:source-file-id branch-file-id
|
||||
:title title
|
||||
:description description}
|
||||
{:team-id team-id
|
||||
:actor-id profile-id
|
||||
:branch-name (:name branch)
|
||||
:target-name (:name target-row)
|
||||
:reviewers reviewers})
|
||||
|
||||
(mbus/pub! msgbus
|
||||
:topic target-id
|
||||
:message {:type :pull-request-created
|
||||
:file-id target-id
|
||||
:pull-request-id pr-id
|
||||
:profile-id profile-id})
|
||||
|
||||
{:id pr-id
|
||||
:file-branch-id branch-id
|
||||
:source-file-id branch-file-id
|
||||
:target-file-id target-id
|
||||
:title title
|
||||
:description description
|
||||
:status "open"
|
||||
:review-revn (:revn branch-file)
|
||||
:reviewers reviewers})))))))
|
||||
|
||||
;; --- COMMAND QUERY: get-file-pull-requests
|
||||
|
||||
(def ^:private schema:get-file-pull-requests
|
||||
[:map {:title "get-file-pull-requests"}
|
||||
[:file-id ::sm/uuid]
|
||||
[:include-closed {:optional true} ::sm/boolean]])
|
||||
|
||||
(def ^:private sql:resolve-target-file
|
||||
"SELECT source_file_id FROM file_branch
|
||||
WHERE branch_file_id = ?
|
||||
AND deleted_at IS NULL")
|
||||
|
||||
(def ^:private sql:get-file-pull-requests
|
||||
"SELECT fpr.id,
|
||||
fpr.file_branch_id,
|
||||
fpr.source_file_id,
|
||||
fpr.target_file_id,
|
||||
fpr.title,
|
||||
fpr.description,
|
||||
fpr.created_by,
|
||||
fpr.created_at,
|
||||
fpr.updated_at,
|
||||
fpr.status,
|
||||
fpr.review_snapshot_id,
|
||||
fpr.review_revn,
|
||||
fpr.review_updated_at,
|
||||
fpr.closed_at,
|
||||
fpr.closed_by,
|
||||
fb.name AS branch_name,
|
||||
fb.base_snapshot_id,
|
||||
fb.base_revn,
|
||||
fb.base_branch_revn,
|
||||
bf.revn AS branch_revn,
|
||||
sf.revn AS source_revn,
|
||||
sf.name AS target_name
|
||||
FROM file_pull_request AS fpr
|
||||
JOIN file_branch AS fb ON (fb.id = fpr.file_branch_id)
|
||||
JOIN file AS bf ON (bf.id = fpr.source_file_id)
|
||||
JOIN file AS sf ON (sf.id = fpr.target_file_id)
|
||||
WHERE fpr.target_file_id = ?
|
||||
AND fpr.deleted_at IS NULL
|
||||
AND (?::boolean OR fpr.status = 'open')
|
||||
ORDER BY fpr.created_at DESC")
|
||||
|
||||
(sv/defmethod ::get-file-pull-requests
|
||||
"List the pull requests of a file. Accepts either the target file
|
||||
(main) or a branch file id (it resolves to its source). Every open
|
||||
pull request is annotated with its reviews, the aggregate
|
||||
`review-state`, the `outdated` flag and the entity-level
|
||||
ahead/behind/conflicts counts (gated by the cheap revn deltas, with
|
||||
main realized once and shared, like the branches listing)."
|
||||
{::doc/added "2.16"
|
||||
::sm/params schema:get-file-pull-requests}
|
||||
[cfg {:keys [::rpc/profile-id file-id include-closed]}]
|
||||
(check-pull-requests-enabled!)
|
||||
(db/run! cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [target-id (or (:source-file-id (db/exec-one! conn [sql:resolve-target-file file-id]))
|
||||
file-id)]
|
||||
(files/check-read-permissions! cfg profile-id target-id)
|
||||
(let [rows (db/exec! conn [sql:get-file-pull-requests target-id
|
||||
(boolean include-closed)])
|
||||
reviews-map (get-reviews-map cfg rows)
|
||||
open? (fn [row] (= "open" (:status row)))
|
||||
need? (some (fn [r] (and (open? r)
|
||||
(let [[a b] (fbranch/revn-deltas r)]
|
||||
(or (pos? a) (pos? b)))))
|
||||
rows)
|
||||
main-data (when need? (:data (bfc/get-file cfg target-id :realize? true)))]
|
||||
(mapv (partial decorate-pull-request cfg main-data reviews-map) rows))))))
|
||||
|
||||
;; --- COMMAND QUERY: get-pull-request
|
||||
|
||||
(def ^:private schema:get-pull-request
|
||||
[:map {:title "get-pull-request"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def ^:private sql:get-pull-request
|
||||
"SELECT fpr.id,
|
||||
fpr.file_branch_id,
|
||||
fpr.source_file_id,
|
||||
fpr.target_file_id,
|
||||
fpr.title,
|
||||
fpr.description,
|
||||
fpr.created_by,
|
||||
fpr.created_at,
|
||||
fpr.updated_at,
|
||||
fpr.status,
|
||||
fpr.review_snapshot_id,
|
||||
fpr.review_revn,
|
||||
fpr.review_updated_at,
|
||||
fpr.closed_at,
|
||||
fpr.closed_by,
|
||||
fb.name AS branch_name,
|
||||
fb.base_snapshot_id,
|
||||
fb.base_revn,
|
||||
fb.base_branch_revn,
|
||||
bf.revn AS branch_revn,
|
||||
sf.revn AS source_revn,
|
||||
sf.name AS target_name
|
||||
FROM file_pull_request AS fpr
|
||||
JOIN file_branch AS fb ON (fb.id = fpr.file_branch_id)
|
||||
JOIN file AS bf ON (bf.id = fpr.source_file_id)
|
||||
JOIN file AS sf ON (sf.id = fpr.target_file_id)
|
||||
WHERE fpr.id = ?
|
||||
AND fpr.deleted_at IS NULL")
|
||||
|
||||
(sv/defmethod ::get-pull-request
|
||||
"Fetch a single pull request with its reviews, aggregate review state
|
||||
and diff counts."
|
||||
{::doc/added "2.16"
|
||||
::sm/params schema:get-pull-request}
|
||||
[cfg {:keys [::rpc/profile-id id]}]
|
||||
(check-pull-requests-enabled!)
|
||||
(db/run! cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [row (db/exec-one! conn [sql:get-pull-request id])]
|
||||
(when-not row
|
||||
(ex/raise :type :not-found
|
||||
:code :pull-request-not-found
|
||||
:pull-request-id id))
|
||||
(files/check-read-permissions! cfg profile-id (:target-file-id row))
|
||||
(decorate-pull-request cfg nil (get-reviews-map cfg [row]) row)))))
|
||||
|
||||
;; --- COMMAND QUERY: get-pull-request-bundle
|
||||
|
||||
(def ^:private schema:get-pull-request-bundle
|
||||
[:map {:title "get-pull-request-bundle"}
|
||||
[:id ::sm/uuid]
|
||||
[:features {:optional true} ::cfeat/features]])
|
||||
|
||||
(sv/defmethod ::get-pull-request-bundle
|
||||
"Retrieve the review sandbox of an OPEN pull request: the branch file
|
||||
bundle with the data of the pinned review snapshot overlaid, served
|
||||
with edition permissions stripped so the client always opens it
|
||||
read-only. Does not modify any database state. Refuses when the
|
||||
snapshot cannot be resolved — silently serving the live branch would
|
||||
show reviewers a state nobody asked them to review."
|
||||
{::doc/added "2.16"
|
||||
::sm/params schema:get-pull-request-bundle
|
||||
::sm/result files/schema:file-with-permissions
|
||||
::db/transaction true}
|
||||
[cfg {:keys [::rpc/profile-id id]}]
|
||||
(check-pull-requests-enabled!)
|
||||
(let [pr (get-pull-request* cfg id)]
|
||||
(when (not= "open" (:status pr))
|
||||
(ex/raise :type :validation
|
||||
:code :pull-request-not-open
|
||||
:hint "the review sandbox only exists while the pull request is open"
|
||||
:pull-request-id id))
|
||||
(let [perms (perms/get-file-read-permissions cfg profile-id (:target-file-id pr))]
|
||||
(files/check-read-permissions! perms)
|
||||
(let [snapshot (when (:review-snapshot-id pr)
|
||||
(fsnap/get-snapshot cfg (:source-file-id pr) (:review-snapshot-id pr)))]
|
||||
(when-not snapshot
|
||||
(ex/raise :type :not-found
|
||||
:code :review-snapshot-missing
|
||||
:hint "the pull request review snapshot cannot be resolved"
|
||||
:pull-request-id id))
|
||||
(let [base-file (bfc/get-file cfg (:source-file-id pr) :load-data? false)]
|
||||
(-> base-file
|
||||
(assoc :data (:data snapshot))
|
||||
(assoc :version (:version snapshot))
|
||||
(assoc :features (:features snapshot))
|
||||
(assoc :revn (:revn snapshot))
|
||||
(assoc :vern (rand-int 100000))
|
||||
;; the sandbox is read-only by construction, whatever the
|
||||
;; caller's role on the team is
|
||||
(assoc :permissions (assoc perms
|
||||
:can-edit false
|
||||
:is-admin false
|
||||
:is-owner false))))))))
|
||||
|
||||
;; --- COMMAND: update-pull-request
|
||||
|
||||
(def ^:private schema:update-pull-request
|
||||
[:map {:title "update-pull-request"}
|
||||
[:id ::sm/uuid]
|
||||
[:title {:optional true} [:string {:max 250}]]
|
||||
[:description {:optional true} [:string {:max 4000}]]
|
||||
;; full replacement of the reviewer set; added reviewers start as
|
||||
;; pending, removed ones lose their row (their comments remain)
|
||||
[:reviewers {:optional true} [:vector ::sm/uuid]]])
|
||||
|
||||
(sv/defmethod ::update-pull-request
|
||||
"Edit a pull request's title, description or reviewer list. Allowed to
|
||||
the author and to team admins/owners."
|
||||
{::doc/added "2.16"
|
||||
::sm/params schema:update-pull-request}
|
||||
[{:keys [::mbus/msgbus] :as cfg}
|
||||
{:keys [::rpc/profile-id id title description reviewers]}]
|
||||
(check-pull-requests-enabled!)
|
||||
(when (and (some? title) (str/blank? title))
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-pull-request-title
|
||||
:hint "pull request title cannot be blank"))
|
||||
(let [pr (get-pull-request* cfg id)]
|
||||
(when (not= "open" (:status pr))
|
||||
(ex/raise :type :validation
|
||||
:code :pull-request-not-open
|
||||
:pull-request-id id))
|
||||
(check-author-or-admin! cfg profile-id pr)
|
||||
|
||||
(let [team-id (perms/get-team-id-for-file cfg (:target-file-id pr))
|
||||
reviewers (some->> reviewers
|
||||
(validate-reviewers! cfg team-id (:created-by pr)))]
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(db/update! conn :file-pull-request
|
||||
(cond-> {:updated-at (ct/now)}
|
||||
(some? title) (assoc :title title)
|
||||
(some? description) (assoc :description description))
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
|
||||
(when (some? reviewers)
|
||||
(let [current (->> (db/query conn :file-pull-request-review
|
||||
{:pull-request-id id})
|
||||
(into #{} (map :profile-id)))
|
||||
wanted (set reviewers)
|
||||
added (vec (remove current wanted))]
|
||||
(doseq [reviewer-id added]
|
||||
(db/insert! conn :file-pull-request-review
|
||||
{:pull-request-id id
|
||||
:profile-id reviewer-id}
|
||||
{::db/return-keys false}))
|
||||
(doseq [reviewer-id (remove wanted current)]
|
||||
(db/delete! conn :file-pull-request-review
|
||||
{:pull-request-id id
|
||||
:profile-id reviewer-id}))
|
||||
(when (seq added)
|
||||
(let [row (db/exec-one! conn [sql:get-pull-request id])]
|
||||
(send-review-request-emails! conn row
|
||||
{:team-id team-id
|
||||
:actor-id profile-id
|
||||
:branch-name (:branch-name row)
|
||||
:target-name (:target-name row)
|
||||
:reviewers added})))))
|
||||
|
||||
(mbus/pub! msgbus
|
||||
:topic (:target-file-id pr)
|
||||
:message {:type :pull-request-updated
|
||||
:file-id (:target-file-id pr)
|
||||
:pull-request-id id
|
||||
:profile-id profile-id})
|
||||
|
||||
{:id id
|
||||
:title (or title (:title pr))
|
||||
:description (or description (:description pr))})))))
|
||||
|
||||
;; --- COMMAND: update-pull-request-snapshot
|
||||
|
||||
(def ^:private schema:update-pull-request-snapshot
|
||||
[:map {:title "update-pull-request-snapshot"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::update-pull-request-snapshot
|
||||
"Publish the branch's current state to the reviewers: reposition the
|
||||
review snapshot to the head of the branch (releasing the previous
|
||||
pin). Verdicts submitted over the previous snapshot become stale
|
||||
(`reviewed-revn` no longer matches) but are kept for the history. It
|
||||
is an explicit author action — the sandbox never moves on its own."
|
||||
{::doc/added "2.16"
|
||||
::webhooks/event? true
|
||||
::sm/params schema:update-pull-request-snapshot
|
||||
::climit/id [[:update-pull-request-snapshot/global]]}
|
||||
[{:keys [::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||
(check-pull-requests-enabled!)
|
||||
(let [pr (get-pull-request* cfg id)]
|
||||
(when (not= "open" (:status pr))
|
||||
(ex/raise :type :validation
|
||||
:code :pull-request-not-open
|
||||
:pull-request-id id))
|
||||
|
||||
;; publishing the branch state is an author-side action, like
|
||||
;; creating the pull request
|
||||
(files/check-edition-permissions! cfg profile-id (:source-file-id pr))
|
||||
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
;; serialize against concurrent update-file on the branch so the
|
||||
;; new snapshot captures a consistent state
|
||||
(db/xact-lock! conn (:source-file-id pr))
|
||||
|
||||
(let [branch-file (bfc/get-file cfg (:source-file-id pr) :realize? true)]
|
||||
(if (= (:revn branch-file) (:review-revn pr))
|
||||
;; nothing new to publish: the sandbox already shows this state
|
||||
{:id id :review-revn (:review-revn pr) :updated false}
|
||||
|
||||
(let [team (teams/get-team conn :profile-id profile-id
|
||||
:file-id (:target-file-id pr))
|
||||
delay (ldel/get-deletion-delay team)
|
||||
snapshot (pin-review-snapshot! cfg branch-file (:title pr) profile-id)
|
||||
ts (ct/now)]
|
||||
;; the previous review snapshot is superseded: release its pin
|
||||
(release-review-snapshot! cfg pr (ct/in-future delay))
|
||||
(db/update! conn :file-pull-request
|
||||
{:review-snapshot-id (:id snapshot)
|
||||
:review-revn (:revn branch-file)
|
||||
:review-updated-at ts
|
||||
:updated-at ts}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
|
||||
(mbus/pub! msgbus
|
||||
:topic (:target-file-id pr)
|
||||
:message {:type :pull-request-updated
|
||||
:file-id (:target-file-id pr)
|
||||
:pull-request-id id
|
||||
:profile-id profile-id})
|
||||
|
||||
{:id id :review-revn (:revn branch-file) :updated true})))))))
|
||||
|
||||
;; --- COMMAND: submit-pull-request-review
|
||||
|
||||
(def ^:private schema:submit-pull-request-review
|
||||
[:map {:title "submit-pull-request-review"}
|
||||
[:id ::sm/uuid]
|
||||
[:state [:enum "approved" "changes-requested" "pending"]]
|
||||
[:comment {:optional true} [:string {:max 4000}]]])
|
||||
|
||||
(sv/defmethod ::submit-pull-request-review
|
||||
"Submit (or retract, with `pending`) a verdict on an open pull
|
||||
request. Only assigned reviewers may submit; the verdict records the
|
||||
review revision it was issued over, so it goes stale when the author
|
||||
publishes newer changes. Approval is informative: it never gates the
|
||||
merge, which keeps requiring edition permissions on main."
|
||||
{::doc/added "2.16"
|
||||
::webhooks/event? true
|
||||
::sm/params schema:submit-pull-request-review}
|
||||
[{:keys [::mbus/msgbus] :as cfg}
|
||||
{:keys [::rpc/profile-id id state comment]}]
|
||||
(check-pull-requests-enabled!)
|
||||
(let [pr (get-pull-request* cfg id)]
|
||||
(when (not= "open" (:status pr))
|
||||
(ex/raise :type :validation
|
||||
:code :pull-request-not-open
|
||||
:pull-request-id id))
|
||||
(files/check-read-permissions! cfg profile-id (:target-file-id pr))
|
||||
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(let [review (db/get* conn :file-pull-request-review
|
||||
{:pull-request-id id :profile-id profile-id})]
|
||||
(when-not review
|
||||
(ex/raise :type :validation
|
||||
:code :not-a-reviewer
|
||||
:hint "only assigned reviewers can submit a review"
|
||||
:pull-request-id id))
|
||||
|
||||
(db/update! conn :file-pull-request-review
|
||||
{:state state
|
||||
:reviewed-revn (when (not= "pending" state) (:review-revn pr))
|
||||
:comment comment
|
||||
:updated-at (ct/now)}
|
||||
{:pull-request-id id :profile-id profile-id}
|
||||
{::db/return-keys false})
|
||||
|
||||
(mbus/pub! msgbus
|
||||
:topic (:target-file-id pr)
|
||||
:message {:type :pull-request-review-submitted
|
||||
:file-id (:target-file-id pr)
|
||||
:pull-request-id id
|
||||
:profile-id profile-id
|
||||
:state state})
|
||||
|
||||
{:id id
|
||||
:profile-id profile-id
|
||||
:state state
|
||||
:reviewed-revn (:review-revn pr)})))))
|
||||
|
||||
;; --- COMMAND: close-pull-request
|
||||
|
||||
(def ^:private schema:close-pull-request
|
||||
[:map {:title "close-pull-request"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::close-pull-request
|
||||
"Close (discard) an open pull request: the review sandbox stops
|
||||
resolving and the pinned review snapshot is released to the normal
|
||||
snapshot GC. The branch itself is not touched. Allowed to the author
|
||||
and to team admins/owners."
|
||||
{::doc/added "2.16"
|
||||
::webhooks/event? true
|
||||
::sm/params schema:close-pull-request}
|
||||
[{:keys [::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||
(check-pull-requests-enabled!)
|
||||
(let [pr (get-pull-request* cfg id)]
|
||||
(when (not= "open" (:status pr))
|
||||
(ex/raise :type :validation
|
||||
:code :pull-request-not-open
|
||||
:pull-request-id id))
|
||||
(check-author-or-admin! cfg profile-id pr)
|
||||
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [team (teams/get-team conn :profile-id profile-id
|
||||
:file-id (:target-file-id pr))
|
||||
delay (ldel/get-deletion-delay team)
|
||||
ts (ct/now)]
|
||||
(db/update! conn :file-pull-request
|
||||
{:status "closed"
|
||||
:closed-at ts
|
||||
:closed-by profile-id
|
||||
:updated-at ts}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(release-review-snapshot! cfg pr (ct/in-future delay))
|
||||
|
||||
(mbus/pub! msgbus
|
||||
:topic (:target-file-id pr)
|
||||
:message {:type :pull-request-closed
|
||||
:file-id (:target-file-id pr)
|
||||
:pull-request-id id
|
||||
:profile-id profile-id})
|
||||
|
||||
{:id id :status "closed"})))))
|
||||
|
||||
;; --- COMMAND: reopen-pull-request
|
||||
|
||||
(def ^:private schema:reopen-pull-request
|
||||
[:map {:title "reopen-pull-request"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::reopen-pull-request
|
||||
"Reopen a closed pull request, as long as its branch is still open and
|
||||
the branch has no other open pull request. The review snapshot is
|
||||
re-pinned at the CURRENT head of the branch (the previous one may
|
||||
already be garbage-collected). Allowed to the author and to team
|
||||
admins/owners."
|
||||
{::doc/added "2.16"
|
||||
::sm/params schema:reopen-pull-request}
|
||||
[{:keys [::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id id]}]
|
||||
(check-pull-requests-enabled!)
|
||||
(let [pr (get-pull-request* cfg id)]
|
||||
(when (not= "closed" (:status pr))
|
||||
(ex/raise :type :validation
|
||||
:code :pull-request-not-closed
|
||||
:hint "only closed pull requests can be reopened"
|
||||
:pull-request-id id))
|
||||
(check-author-or-admin! cfg profile-id pr)
|
||||
|
||||
(let [branch (db/get* cfg :file-branch {:id (:file-branch-id pr)})]
|
||||
(when (or (nil? branch)
|
||||
(some? (:deleted-at branch))
|
||||
(not= "open" (:status branch)))
|
||||
(ex/raise :type :validation
|
||||
:code :branch-not-open
|
||||
:hint "the pull request's branch is no longer open"
|
||||
:pull-request-id id))
|
||||
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(db/xact-lock! conn (:source-file-id pr))
|
||||
|
||||
(when (db/exec-one! conn [sql:get-open-pull-request-for-branch (:file-branch-id pr)])
|
||||
(ex/raise :type :validation
|
||||
:code :pull-request-already-exists
|
||||
:hint "the branch already has an open pull request"
|
||||
:branch-id (:file-branch-id pr)))
|
||||
|
||||
(let [branch-file (bfc/get-file cfg (:source-file-id pr) :realize? true)
|
||||
snapshot (pin-review-snapshot! cfg branch-file (:title pr) profile-id)
|
||||
ts (ct/now)]
|
||||
(db/update! conn :file-pull-request
|
||||
{:status "open"
|
||||
:closed-at nil
|
||||
:closed-by nil
|
||||
:review-snapshot-id (:id snapshot)
|
||||
:review-revn (:revn branch-file)
|
||||
:review-updated-at ts
|
||||
:updated-at ts}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
|
||||
(mbus/pub! msgbus
|
||||
:topic (:target-file-id pr)
|
||||
:message {:type :pull-request-updated
|
||||
:file-id (:target-file-id pr)
|
||||
:pull-request-id id
|
||||
:profile-id profile-id})
|
||||
|
||||
{:id id :status "open" :review-revn (:revn branch-file)}))))))
|
||||
|
||||
;; --- COMMAND QUERY: get-profile-pending-reviews
|
||||
|
||||
(def ^:private schema:get-profile-pending-reviews
|
||||
[:map {:title "get-profile-pending-reviews"}
|
||||
[:team-id ::sm/uuid]])
|
||||
|
||||
(def ^:private sql:get-profile-pending-reviews
|
||||
"SELECT fpr.id,
|
||||
fpr.title,
|
||||
fpr.created_by,
|
||||
fpr.created_at,
|
||||
fpr.review_updated_at,
|
||||
fpr.source_file_id,
|
||||
fpr.target_file_id,
|
||||
fb.name AS branch_name,
|
||||
sf.name AS target_name
|
||||
FROM file_pull_request_review AS r
|
||||
JOIN file_pull_request AS fpr ON (fpr.id = r.pull_request_id)
|
||||
JOIN file_branch AS fb ON (fb.id = fpr.file_branch_id)
|
||||
JOIN file AS sf ON (sf.id = fpr.target_file_id)
|
||||
JOIN project AS p ON (p.id = sf.project_id)
|
||||
WHERE r.profile_id = ?
|
||||
AND p.team_id = ?
|
||||
AND fpr.status = 'open'
|
||||
AND fpr.deleted_at IS NULL
|
||||
AND sf.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL
|
||||
AND (r.state = 'pending'
|
||||
OR r.reviewed_revn IS DISTINCT FROM fpr.review_revn)
|
||||
ORDER BY fpr.review_updated_at DESC")
|
||||
|
||||
(sv/defmethod ::get-profile-pending-reviews
|
||||
"The open pull requests of a team where the calling profile is a
|
||||
reviewer without a CURRENT verdict (never reviewed, or the author
|
||||
published newer changes since their last verdict). Feeds the dashboard
|
||||
notifications area."
|
||||
{::doc/added "2.16"
|
||||
::sm/params schema:get-profile-pending-reviews}
|
||||
[cfg {:keys [::rpc/profile-id team-id]}]
|
||||
(if-not (contains? cf/flags :branching)
|
||||
[]
|
||||
(db/run! cfg
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(db/exec! conn [sql:get-profile-pending-reviews profile-id team-id])))))
|
||||
@ -50,6 +50,7 @@
|
||||
inner join projects as pr on (f.project_id = pr.id)
|
||||
where f.name ilike ('%' || ? || '%')
|
||||
and (f.deleted_at is null)
|
||||
and (f.is_branch is false)
|
||||
order by f.created_at asc")
|
||||
|
||||
(defn search-files
|
||||
|
||||
@ -15,7 +15,9 @@
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.features.fdata :as fdata]
|
||||
[app.features.file-snapshots :as fsnap]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files-pull-request :as fpr]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.cond :as-alias cond]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
@ -131,6 +133,16 @@
|
||||
[{:keys [::db/conn] :as cfg} {:keys [profile-id file-id share-id ::perms] :as params}]
|
||||
(let [file (bfc/get-file cfg file-id)
|
||||
|
||||
;; pull request review sandbox: the viewer shows the pinned
|
||||
;; review snapshot of the branch, not its live state
|
||||
file (if-let [snapshot (::snapshot params)]
|
||||
(-> file
|
||||
(assoc :data (:data snapshot))
|
||||
(assoc :version (:version snapshot))
|
||||
(assoc :features (:features snapshot))
|
||||
(assoc :revn (:revn snapshot)))
|
||||
file)
|
||||
|
||||
project (db/get conn :project
|
||||
{:id (:project-id file)}
|
||||
{:columns [:id :name :team-id]})
|
||||
@ -210,23 +222,62 @@
|
||||
:team (assoc team :permissions perms)
|
||||
:permissions perms}))
|
||||
|
||||
(defn- get-pull-request-view-context
|
||||
"Resolve the review-sandbox context of `pr-id`: permissions taken from
|
||||
the pull request's TARGET file (share-links do not apply here) with
|
||||
edition stripped, plus the pinned review snapshot whose data replaces
|
||||
the branch file's live state. Refuses when the pull request is not
|
||||
open or does not belong to `file-id` — the sandbox must never silently
|
||||
degrade to the live branch."
|
||||
[system profile-id file-id pr-id]
|
||||
(fpr/check-pull-requests-enabled!)
|
||||
(let [pr (db/get* system :file-pull-request {:id pr-id})]
|
||||
(when (or (nil? pr)
|
||||
(some? (:deleted-at pr))
|
||||
(not= file-id (:source-file-id pr)))
|
||||
(ex/raise :type :not-found
|
||||
:code :pull-request-not-found
|
||||
:hint "unable to find pull request with the provided id"
|
||||
:pull-request-id pr-id))
|
||||
(when (not= "open" (:status pr))
|
||||
(ex/raise :type :validation
|
||||
:code :pull-request-not-open
|
||||
:hint "the review sandbox only exists while the pull request is open"
|
||||
:pull-request-id pr-id))
|
||||
(let [perms (perms/get-file-read-permissions system profile-id (:target-file-id pr))
|
||||
snapshot (when (:review-snapshot-id pr)
|
||||
(fsnap/get-snapshot system (:source-file-id pr) (:review-snapshot-id pr)))]
|
||||
(when (and perms (nil? snapshot))
|
||||
(ex/raise :type :not-found
|
||||
:code :review-snapshot-missing
|
||||
:hint "the pull request review snapshot cannot be resolved"
|
||||
:pull-request-id pr-id))
|
||||
{:perms (some-> perms (assoc :can-edit false :is-admin false :is-owner false))
|
||||
:snapshot snapshot})))
|
||||
|
||||
(def schema:get-view-only-bundle
|
||||
[:map {:title "get-view-only-bundle"}
|
||||
[:file-id ::sm/uuid]
|
||||
[:share-id {:optional true} ::sm/uuid]
|
||||
[:pr-id {:optional true} ::sm/uuid]
|
||||
[:features {:optional true} ::cfeat/features]])
|
||||
|
||||
(sv/defmethod ::get-view-only-bundle
|
||||
{::rpc/auth false
|
||||
::doc/added "1.17"
|
||||
::sm/params schema:get-view-only-bundle}
|
||||
[system {:keys [::rpc/profile-id file-id share-id] :as params}]
|
||||
[system {:keys [::rpc/profile-id file-id share-id pr-id] :as params}]
|
||||
(db/run! system
|
||||
(fn [system]
|
||||
(let [perms (perms/get-file-read-permissions system profile-id file-id share-id)
|
||||
(let [{:keys [perms snapshot]}
|
||||
(if (some? pr-id)
|
||||
(get-pull-request-view-context system profile-id file-id pr-id)
|
||||
{:perms (perms/get-file-read-permissions system profile-id file-id share-id)})
|
||||
|
||||
params (-> params
|
||||
(assoc ::perms perms)
|
||||
(assoc :profile-id profile-id))]
|
||||
(assoc :profile-id profile-id)
|
||||
(cond-> (some? snapshot) (assoc ::snapshot snapshot)))]
|
||||
|
||||
;; When we have neither profile nor share, we just return a not
|
||||
;; found response to the user.
|
||||
|
||||
@ -471,6 +471,104 @@
|
||||
(assoc ::count-sql [sql:get-snapshots-per-team team-id])
|
||||
(generic-check!)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: BRANCHES-PER-FILE
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private schema:branches-per-file
|
||||
[:map
|
||||
[::profile-id ::sm/uuid]
|
||||
[::project-id ::sm/uuid]
|
||||
[::team-id ::sm/uuid]
|
||||
[::file-id ::sm/uuid]])
|
||||
|
||||
(def ^:private valid-branches-per-file-quote?
|
||||
(sm/lazy-validator schema:branches-per-file))
|
||||
|
||||
(def ^:private sql:get-branches-per-file
|
||||
"SELECT count(*) AS total
|
||||
FROM file_branch AS fb
|
||||
WHERE fb.source_file_id = ?
|
||||
AND fb.deleted_at IS NULL
|
||||
AND fb.status <> 'merged'")
|
||||
|
||||
(defmethod check-quote ::branches-per-file
|
||||
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
|
||||
(assert (valid-branches-per-file-quote? quote) "invalid quote parameters")
|
||||
(-> quote
|
||||
(assoc ::default (cf/get :quotes-branches-per-file Integer/MAX_VALUE))
|
||||
(assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id
|
||||
profile-id team-id profile-id profile-id])
|
||||
(assoc ::count-sql [sql:get-branches-per-file file-id])
|
||||
(generic-check!)))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: BRANCHES-PER-TEAM
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private schema:branches-per-team
|
||||
[:map
|
||||
[::profile-id ::sm/uuid]
|
||||
[::team-id ::sm/uuid]])
|
||||
|
||||
(def ^:private valid-branches-per-team-quote?
|
||||
(sm/lazy-validator schema:branches-per-team))
|
||||
|
||||
(def ^:private sql:get-branches-per-team
|
||||
"SELECT count(*) AS total
|
||||
FROM file_branch AS fb
|
||||
JOIN file AS f ON (f.id = fb.source_file_id)
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
WHERE p.team_id = ?
|
||||
AND fb.deleted_at IS NULL
|
||||
AND fb.status <> 'merged'
|
||||
AND f.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL")
|
||||
|
||||
(defmethod check-quote ::branches-per-team
|
||||
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
||||
(assert (valid-branches-per-team-quote? quote) "invalid quote parameters")
|
||||
(-> quote
|
||||
(assoc ::default (cf/get :quotes-branches-per-team Integer/MAX_VALUE))
|
||||
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
||||
(assoc ::count-sql [sql:get-branches-per-team team-id])
|
||||
(generic-check!)))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: PULL-REQUESTS-PER-TEAM
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private schema:pull-requests-per-team
|
||||
[:map
|
||||
[::profile-id ::sm/uuid]
|
||||
[::team-id ::sm/uuid]])
|
||||
|
||||
(def ^:private valid-pull-requests-per-team-quote?
|
||||
(sm/lazy-validator schema:pull-requests-per-team))
|
||||
|
||||
(def ^:private sql:get-pull-requests-per-team
|
||||
"SELECT count(*) AS total
|
||||
FROM file_pull_request AS fpr
|
||||
JOIN file AS f ON (f.id = fpr.target_file_id)
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
WHERE p.team_id = ?
|
||||
AND fpr.deleted_at IS NULL
|
||||
AND fpr.status = 'open'
|
||||
AND f.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL")
|
||||
|
||||
(defmethod check-quote ::pull-requests-per-team
|
||||
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
||||
(assert (valid-pull-requests-per-team-quote? quote) "invalid quote parameters")
|
||||
(-> quote
|
||||
(assoc ::default (cf/get :quotes-pull-requests-per-team Integer/MAX_VALUE))
|
||||
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
||||
(assoc ::count-sql [sql:get-pull-requests-per-team team-id])
|
||||
(generic-check!)))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: TEAM-ACCESS-REQUESTS-PER-TEAM
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@ -38,6 +38,12 @@
|
||||
{:id id :file-id file-id :type "snapshot"}
|
||||
{::db/return-keys false}))
|
||||
|
||||
(def ^:private sql:get-file-branches
|
||||
"SELECT id, branch_file_id
|
||||
FROM file_branch
|
||||
WHERE source_file_id = ?
|
||||
AND deleted_at IS NULL")
|
||||
|
||||
(defmethod delete-object :file
|
||||
[{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}]
|
||||
(when-let [file (db/get* conn :file {:id id}
|
||||
@ -47,6 +53,46 @@
|
||||
(l/trc :obj "file" :id (str id)
|
||||
:deleted-at (ct/format-inst deleted-at))
|
||||
|
||||
;; Branching: keep the branch metadata in sync when the deleted file
|
||||
;; IS a branch copy (deleted through paths other than
|
||||
;; delete-file-branch, e.g. a project deletion) ...
|
||||
(db/update! conn :file-branch
|
||||
{:deleted-at deleted-at}
|
||||
{:branch-file-id id}
|
||||
{::db/return-keys false})
|
||||
|
||||
;; Pull requests: when the deleted file is a branch copy, any open
|
||||
;; pull request over it can no longer be reviewed — close it (the
|
||||
;; row is kept: pull request history outlives its branch, and the
|
||||
;; review snapshot rows live in this file's file_change/file_data,
|
||||
;; which this same cascade marks deleted below). When the deleted
|
||||
;; file is a pull request TARGET, cascade the logical deletion: with
|
||||
;; main gone the pull request is unreachable forever.
|
||||
(db/update! conn :file-pull-request
|
||||
{:status "closed"
|
||||
:closed-at (ct/now)
|
||||
:updated-at (ct/now)}
|
||||
{:source-file-id id :status "open"}
|
||||
{::db/return-keys false})
|
||||
(db/update! conn :file-pull-request
|
||||
{:deleted-at deleted-at}
|
||||
{:target-file-id id}
|
||||
{::db/return-keys false})
|
||||
|
||||
;; ... and when it is the SOURCE of branches, cascade the logical
|
||||
;; deletion to them: branch files are hidden from every listing, so
|
||||
;; once the source is gone nothing else could ever reach (or GC)
|
||||
;; them. A branch cannot be a source itself, so this recurses at
|
||||
;; most one level.
|
||||
(doseq [branch (db/exec! conn [sql:get-file-branches id])]
|
||||
(db/update! conn :file-branch
|
||||
{:deleted-at deleted-at}
|
||||
{:id (:id branch)}
|
||||
{::db/return-keys false})
|
||||
(delete-object cfg {:object :file
|
||||
:id (:branch-file-id branch)
|
||||
:deleted-at deleted-at}))
|
||||
|
||||
(db/update! conn :file
|
||||
{:deleted-at deleted-at
|
||||
:is-shared false}
|
||||
|
||||
865
backend/test/backend_tests/rpc_file_branch_test.clj
Normal file
@ -0,0 +1,865 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.rpc-file-branch-test
|
||||
(:require
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.storage :as sto]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.string :as str]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(t/deftest create-and-list-branches
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
branch-file-id (volatile! nil)
|
||||
branch-meta-id (volatile! nil)]
|
||||
|
||||
(t/testing "create branch"
|
||||
(let [params {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "redesign-checkout"
|
||||
:description "A/B test de checkout"}
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(let [result (:result out)]
|
||||
(t/is (uuid? (:id result)))
|
||||
(t/is (uuid? (:branch-file-id result)))
|
||||
(t/is (= (:id file) (:source-file-id result)))
|
||||
(t/is (= "redesign-checkout" (:name result)))
|
||||
(t/is (= "open" (:status result)))
|
||||
(vreset! branch-file-id (:branch-file-id result))
|
||||
(vreset! branch-meta-id (:id result)))))
|
||||
|
||||
(t/testing "branch file is flagged and hidden from project listing"
|
||||
(let [[row] (th/db-query :file {:id @branch-file-id})]
|
||||
(t/is (true? (:is-branch row))))
|
||||
|
||||
(let [out (th/command! {::th/type :get-project-files
|
||||
::rpc/profile-id (:id profile)
|
||||
:project-id proj-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(let [rows (:result out)
|
||||
ids (set (map :id rows))
|
||||
src (first (filter #(= (:id %) (:id file)) rows))]
|
||||
(t/is (contains? ids (:id file)))
|
||||
(t/is (not (contains? ids @branch-file-id)))
|
||||
;; the source file card reports its open branch count
|
||||
(t/is (= 1 (:branches-count src))))))
|
||||
|
||||
(t/testing "list branches"
|
||||
(let [out (th/command! {::th/type :get-file-branches
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)})]
|
||||
(t/is (nil? (:error out)))
|
||||
(let [[row :as result] (:result out)]
|
||||
(t/is (= 1 (count result)))
|
||||
(t/is (= "redesign-checkout" (:name row)))
|
||||
(t/is (= "open" (:status row)))
|
||||
(t/is (= 0 (:ahead row)))
|
||||
(t/is (= 0 (:behind row)))
|
||||
(t/is (= @branch-file-id (:branch-file-id row))))))
|
||||
|
||||
(t/testing "branch context info"
|
||||
;; the branch file reports its branch metadata
|
||||
(let [out (th/command! {::th/type :get-file-branch-info
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id @branch-file-id})
|
||||
info (:result out)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= "redesign-checkout" (:name info)))
|
||||
(t/is (= "open" (:status info)))
|
||||
(t/is (= (:id file) (:source-file-id info)))
|
||||
(t/is (= (:name file) (:source-name info)))
|
||||
(t/is (= 0 (:ahead info)))
|
||||
(t/is (= 0 (:behind info))))
|
||||
;; the main file is not a branch -> nil
|
||||
(let [out (th/command! {::th/type :get-file-branch-info
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out)))))
|
||||
|
||||
(t/testing "merge base snapshot created on main"
|
||||
(let [rows (th/db-query :file-change {:file-id (:id file)})]
|
||||
(t/is (pos? (count rows)))
|
||||
(t/is (some #(= "system" (:created-by %)) rows))))
|
||||
|
||||
(t/testing "diff right after creation has no conflicts"
|
||||
(let [out (th/command! {::th/type :get-branch-diff
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id @branch-meta-id})
|
||||
stats (-> out :result :stats)
|
||||
meta (-> out :result :meta)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? stats))
|
||||
;; base == main == branch at fork -> nothing to merge, no conflicts
|
||||
(t/is (= 0 (:conflicts stats)))
|
||||
;; the resolution UI gets the "when" of each side
|
||||
(t/is (some? (:main-at meta)))
|
||||
(t/is (some? (:branch-at meta)))
|
||||
(t/is (some? (:base-at meta)))))
|
||||
|
||||
(t/testing "merge branch into main (clean, no-op)"
|
||||
(let [out (th/command! {::th/type :merge-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id @branch-meta-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :merged (-> out :result :status))))
|
||||
(let [[row] (th/db-query :file-branch {:id @branch-meta-id})]
|
||||
(t/is (= "merged" (:status row))))))))
|
||||
|
||||
(t/deftest merge-applies-branch-changes
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
|
||||
create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "feature-colors"}))
|
||||
branch-file-id (:branch-file-id create)
|
||||
branch-id (:id create)
|
||||
|
||||
color-id (uuid/random)
|
||||
color {:id color-id :name "Brand" :color "#ff0000" :opacity 1}]
|
||||
|
||||
(t/testing "add a color on the branch"
|
||||
(let [bf (th/db-get :file {:id branch-file-id})
|
||||
out (th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id branch-file-id
|
||||
:session-id (uuid/random)
|
||||
:revn (:revn bf)
|
||||
:vern (:vern bf)
|
||||
:features cfeat/supported-features
|
||||
:changes [{:type :add-color :color color}]})]
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(t/testing "branch info reports diff-based ahead/behind"
|
||||
;; one logical change on the branch (the added color), main untouched
|
||||
(let [info (:result (th/command! {::th/type :get-file-branch-info
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id branch-file-id}))]
|
||||
(t/is (= 1 (:ahead info)))
|
||||
(t/is (= 0 (:behind info)))
|
||||
(t/is (= 0 (:conflicts info)))))
|
||||
|
||||
(t/testing "branch list reports the same diff-based counts"
|
||||
(let [branches (:result (th/command! {::th/type :get-file-branches
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)}))
|
||||
row (first (filter #(= branch-id (:id %)) branches))]
|
||||
(t/is (= 1 (:ahead row)))
|
||||
(t/is (= 0 (:behind row)))
|
||||
(t/is (= 0 (:conflicts row)))))
|
||||
|
||||
(t/testing "merge brings the new color into main"
|
||||
(let [out (th/command! {::th/type :merge-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :merged (-> out :result :status))))
|
||||
|
||||
(let [out (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)})
|
||||
colors (-> out :result :data :colors)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (contains? colors color-id))
|
||||
(t/is (= "Brand" (get-in colors [color-id :name]))))))))
|
||||
|
||||
(t/deftest update-branch-pulls-main-changes
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
|
||||
create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "long-lived"}))
|
||||
branch-file-id (:branch-file-id create)
|
||||
branch-id (:id create)
|
||||
|
||||
color-id (uuid/random)
|
||||
color {:id color-id :name "MainColor" :color "#00ff00" :opacity 1}]
|
||||
|
||||
(t/testing "add a color on main (after the branch was created)"
|
||||
(let [mf (th/db-get :file {:id (:id file)})
|
||||
out (th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)
|
||||
:session-id (uuid/random)
|
||||
:revn (:revn mf)
|
||||
:vern (:vern mf)
|
||||
:features cfeat/supported-features
|
||||
:changes [{:type :add-color :color color}]})]
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(t/testing "compare in the :main->branch direction shows main's incoming change"
|
||||
(let [diff (:result (th/command! {::th/type :get-branch-diff
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id
|
||||
:direction :main->branch}))
|
||||
added (filterv #(= :added (:status %)) (:changes diff))]
|
||||
(t/is (= 1 (-> diff :stats :added)))
|
||||
(t/is (= color-id (-> added first :id)))))
|
||||
|
||||
(t/testing "update the branch from main"
|
||||
(let [out (th/command! {::th/type :update-branch-from-main
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :updated (-> out :result :status))))
|
||||
|
||||
(let [out (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id branch-file-id})
|
||||
colors (-> out :result :data :colors)]
|
||||
(t/is (contains? colors color-id)))
|
||||
|
||||
;; merge base repositioned to the current state of main
|
||||
(let [[row] (th/db-query :file-branch {:id branch-id})
|
||||
mf (th/db-get :file {:id (:id file)})]
|
||||
(t/is (= (:revn mf) (:base-revn row))))))))
|
||||
|
||||
(t/deftest merge-applies-page-add
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "feature-page"}))
|
||||
branch-id (:id create)
|
||||
branch-file-id (:branch-file-id create)
|
||||
new-page-id (uuid/random)]
|
||||
|
||||
(t/testing "add a page on the branch"
|
||||
(let [bf (th/db-get :file {:id branch-file-id})
|
||||
out (th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id branch-file-id
|
||||
:session-id (uuid/random)
|
||||
:revn (:revn bf)
|
||||
:vern (:vern bf)
|
||||
:features cfeat/supported-features
|
||||
:changes [{:type :add-page :id new-page-id :name "Branch Page"}]})]
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(t/testing "merge brings the new page into main"
|
||||
(let [out (th/command! {::th/type :merge-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :merged (-> out :result :status))))
|
||||
|
||||
(let [out (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)})
|
||||
pages (-> out :result :data :pages-index)]
|
||||
(t/is (contains? pages new-page-id)))))))
|
||||
|
||||
(t/deftest merge-reparents-existing-shape
|
||||
;; main: a rect at the page root. branch: that rect moved into a new board.
|
||||
;; after merge main must have the rect INSIDE the board and NOT loose at
|
||||
;; root (regression: reparenting was applied as :set parent-id, which left
|
||||
;; the shape duplicated/loose).
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
page-id (-> (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)})
|
||||
:result :data :pages first)
|
||||
rect-id (uuid/random)
|
||||
board-id (uuid/random)]
|
||||
|
||||
(t/testing "add a rect at the root of main"
|
||||
(let [mf (th/db-get :file {:id (:id file)})
|
||||
out (th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)
|
||||
:session-id (uuid/random)
|
||||
:revn (:revn mf)
|
||||
:vern (:vern mf)
|
||||
:features cfeat/supported-features
|
||||
:changes [{:type :add-obj
|
||||
:page-id page-id
|
||||
:id rect-id
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:obj (cts/setup-shape
|
||||
{:id rect-id :name "Rect" :type :rect
|
||||
:parent-id uuid/zero :frame-id uuid/zero})}]})]
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(let [create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "into-board"}))
|
||||
branch-id (:id create)
|
||||
branch-file-id (:branch-file-id create)]
|
||||
|
||||
(t/testing "on the branch, move the rect into a new board"
|
||||
(let [bf (th/db-get :file {:id branch-file-id})
|
||||
out (th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id branch-file-id
|
||||
:session-id (uuid/random)
|
||||
:revn (:revn bf)
|
||||
:vern (:vern bf)
|
||||
:features cfeat/supported-features
|
||||
:changes [{:type :add-obj
|
||||
:page-id page-id
|
||||
:id board-id
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:obj (cts/setup-shape
|
||||
{:id board-id :name "Board" :type :frame
|
||||
:parent-id uuid/zero :frame-id uuid/zero})}
|
||||
{:type :mov-objects
|
||||
:page-id page-id
|
||||
:parent-id board-id
|
||||
:shapes [rect-id]
|
||||
:index 0}]})]
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(t/testing "merge places the rect inside the board, not loose"
|
||||
(let [out (th/command! {::th/type :merge-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :merged (-> out :result :status))))
|
||||
|
||||
(let [objects (-> (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)})
|
||||
:result :data :pages-index (get page-id) :objects)]
|
||||
;; the board exists and contains the rect
|
||||
(t/is (contains? objects board-id))
|
||||
(t/is (= [rect-id] (get-in objects [board-id :shapes])))
|
||||
;; the rect points to the board as its parent
|
||||
(t/is (= board-id (get-in objects [rect-id :parent-id])))
|
||||
;; and is NOT left loose at the root
|
||||
(t/is (not (contains? (set (get-in objects [uuid/zero :shapes])) rect-id)))))))))
|
||||
|
||||
(t/deftest merge-component-stays-a-component
|
||||
;; create a component on a branch, merge it into main, and verify the
|
||||
;; merged main instance is still a valid component head (component-id set,
|
||||
;; component-file re-pointed to main) — regression: it was detached.
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
page-id (-> (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)})
|
||||
:result :data :pages first)
|
||||
create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "with-component"}))
|
||||
branch-id (:id create)
|
||||
branch-file-id (:branch-file-id create)
|
||||
comp-id (uuid/random)
|
||||
mi-id (uuid/random)]
|
||||
|
||||
(t/testing "create a component on the branch"
|
||||
(let [bf (th/db-get :file {:id branch-file-id})
|
||||
out (th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id branch-file-id
|
||||
:session-id (uuid/random)
|
||||
:revn (:revn bf)
|
||||
:vern (:vern bf)
|
||||
:features cfeat/supported-features
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id page-id
|
||||
:id mi-id
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:obj (-> (cts/setup-shape
|
||||
{:id mi-id :name "Component 01" :type :frame
|
||||
:parent-id uuid/zero :frame-id uuid/zero})
|
||||
(assoc :component-root true
|
||||
:main-instance true
|
||||
:component-id comp-id
|
||||
:component-file branch-file-id))}
|
||||
{:type :add-component
|
||||
:id comp-id
|
||||
:name "Component 01"
|
||||
:path ""
|
||||
:main-instance-id mi-id
|
||||
:main-instance-page page-id}]})]
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(t/testing "merge integrates the component into main as a real head"
|
||||
(let [out (th/command! {::th/type :merge-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :merged (-> out :result :status))))
|
||||
|
||||
(let [data (-> (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)})
|
||||
:result :data)
|
||||
shape (get-in data [:pages-index page-id :objects mi-id])]
|
||||
;; the component registry row landed in main
|
||||
(t/is (contains? (:components data) comp-id))
|
||||
;; the main instance is NOT detached: still a head pointing at main
|
||||
(t/is (= comp-id (:component-id shape)))
|
||||
(t/is (= (:id file) (:component-file shape)))
|
||||
(t/is (true? (:main-instance shape)))
|
||||
(t/is (true? (:component-root shape))))))))
|
||||
|
||||
(t/deftest branch-lifecycle
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "tmp"}))
|
||||
branch-id (:id create)
|
||||
branch-file-id (:branch-file-id create)]
|
||||
|
||||
(t/testing "rename"
|
||||
(let [out (th/command! {::th/type :update-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:id branch-id
|
||||
:name "renamed"})]
|
||||
(t/is (nil? (:error out))))
|
||||
(let [[row] (th/db-query :file-branch {:id branch-id})]
|
||||
(t/is (= "renamed" (:name row)))))
|
||||
|
||||
(t/testing "archive hides it from the default list"
|
||||
(let [out (th/command! {::th/type :archive-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:id branch-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= "archived" (-> out :result :status))))
|
||||
(let [out (th/command! {::th/type :get-file-branches
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)})]
|
||||
(t/is (empty? (:result out))))
|
||||
(let [out (th/command! {::th/type :get-file-branches
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:include-archived true})]
|
||||
(t/is (= 1 (count (:result out))))))
|
||||
|
||||
(t/testing "delete marks branch and branch file deleted"
|
||||
(let [out (th/command! {::th/type :delete-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:id branch-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :deleted (-> out :result :status))))
|
||||
(let [[row] (th/db-query :file-branch {:id branch-id})
|
||||
[frow] (th/db-query :file {:id branch-file-id})]
|
||||
(t/is (some? (:deleted-at row)))
|
||||
(t/is (some? (:deleted-at frow))))))))
|
||||
|
||||
(t/deftest update-resolves-conflict-to-main
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
cid (uuid/random)
|
||||
apply-change
|
||||
(fn [file-id change]
|
||||
(let [f (th/db-get :file {:id file-id})]
|
||||
(th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn (:revn f)
|
||||
:vern (:vern f)
|
||||
:features cfeat/supported-features
|
||||
:changes [change]})))]
|
||||
|
||||
;; color exists on main before branching (base = red)
|
||||
(apply-change (:id file) {:type :add-color :color {:id cid :name "Brand" :color "#ff0000" :opacity 1}})
|
||||
|
||||
(let [create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "b"}))
|
||||
branch-id (:id create)
|
||||
branch-file-id (:branch-file-id create)]
|
||||
|
||||
;; main edits the color -> green; branch edits it -> blue (conflict)
|
||||
(apply-change (:id file) {:type :mod-color :color {:id cid :name "Brand" :color "#00ff00" :opacity 1}})
|
||||
(apply-change branch-file-id {:type :mod-color :color {:id cid :name "Brand" :color "#0000ff" :opacity 1}})
|
||||
|
||||
(t/testing "update without resolutions reports conflicts"
|
||||
(let [out (th/command! {::th/type :update-branch-from-main
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id})]
|
||||
(t/is (= :conflicts (-> out :result :status)))))
|
||||
|
||||
(t/testing "resolving to main brings main's value into the branch"
|
||||
(let [out (th/command! {::th/type :update-branch-from-main
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id
|
||||
:resolutions {cid :main}})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :updated (-> out :result :status))))
|
||||
(let [out (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id branch-file-id})
|
||||
color (get-in out [:result :data :colors cid])]
|
||||
(t/is (= "#00ff00" (:color color)))))))))
|
||||
|
||||
(t/deftest branching-disabled-raises
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
out (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "nope"})
|
||||
error (:error out)
|
||||
data (ex-data error)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (= :restriction (:type data)))
|
||||
(t/is (= :branching-disabled (:code data)))))
|
||||
|
||||
;;; --- Regression tests for the robustness fixes ---
|
||||
|
||||
(defn- apply-change*
|
||||
[profile file-id change]
|
||||
(let [f (th/db-get :file {:id file-id})]
|
||||
(th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn (:revn f)
|
||||
:vern (:vern f)
|
||||
:features cfeat/supported-features
|
||||
:changes [change]})))
|
||||
|
||||
(t/deftest merge-deletes-branch-by-default-and-releases-base
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id})
|
||||
create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "short-lived"}))
|
||||
branch-id (:id create)
|
||||
branch-file-id (:branch-file-id create)
|
||||
cid (uuid/random)]
|
||||
|
||||
(apply-change* profile branch-file-id
|
||||
{:type :add-color :color {:id cid :name "C" :color "#112233" :opacity 1}})
|
||||
|
||||
(let [out (th/command! {::th/type :merge-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :merged (-> out :result :status))))
|
||||
|
||||
;; branch metadata and branch file logically deleted in the same tx
|
||||
(let [[row] (th/db-query :file-branch {:id branch-id})
|
||||
bf (th/db-get :file {:id branch-file-id} {::db/remove-deleted false})]
|
||||
(t/is (= "merged" (:status row)))
|
||||
(t/is (some? (:deleted-at row)))
|
||||
(t/is (some? (:deleted-at bf))))
|
||||
|
||||
;; the pinned base snapshot is released: no branch-base row with a
|
||||
;; multi-year retention remains
|
||||
(let [rows (->> (th/db-query :file-change {:file-id (:id file)})
|
||||
(filter #(some-> (:label %) (str/starts-with? "branch-base/"))))]
|
||||
(t/is (seq rows))
|
||||
(t/is (every? (fn [{:keys [deleted-at]}]
|
||||
(and (some? deleted-at)
|
||||
(ct/is-before? deleted-at (ct/in-future {:days 400}))))
|
||||
rows))))))
|
||||
|
||||
(t/deftest merge-with-keep-branch-archives-it
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id})
|
||||
create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "kept"}))
|
||||
branch-id (:id create)
|
||||
branch-file-id (:branch-file-id create)]
|
||||
|
||||
(let [out (th/command! {::th/type :merge-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id
|
||||
:keep-branch true})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :merged (-> out :result :status))))
|
||||
|
||||
(let [[row] (th/db-query :file-branch {:id branch-id})
|
||||
bf (th/db-get :file {:id branch-file-id})]
|
||||
(t/is (= "merged" (:status row)))
|
||||
(t/is (nil? (:deleted-at row)))
|
||||
(t/is (nil? (:deleted-at bf)))))))
|
||||
|
||||
(t/deftest merge-requires-main-edition-permissions
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||
profile2 (th/create-profile* 2 {:is-active true})
|
||||
proj-id (:default-project-id profile1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile1)
|
||||
:project-id proj-id})
|
||||
create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile1)
|
||||
:file-id (:id file)
|
||||
:name "perm-check"}))
|
||||
branch-id (:id create)
|
||||
branch-file-id (:branch-file-id create)]
|
||||
|
||||
;; profile2 is a team viewer, editor of the BRANCH file but only a
|
||||
;; viewer of main
|
||||
(th/create-team-role* {:team-id (:default-team-id profile1)
|
||||
:profile-id (:id profile2)
|
||||
:role :viewer})
|
||||
(th/create-file-role* {:file-id branch-file-id
|
||||
:profile-id (:id profile2)
|
||||
:role :editor})
|
||||
(th/create-file-role* {:file-id (:id file)
|
||||
:profile-id (:id profile2)
|
||||
:role :viewer})
|
||||
|
||||
(let [out (th/command! {::th/type :merge-file-branch
|
||||
::rpc/profile-id (:id profile2)
|
||||
:branch-id branch-id})]
|
||||
(t/is (some? (:error out))))
|
||||
|
||||
;; but profile2 CAN update the branch from main (edits the branch)
|
||||
(let [out (th/command! {::th/type :update-branch-from-main
|
||||
::rpc/profile-id (:id profile2)
|
||||
:branch-id branch-id})]
|
||||
(t/is (nil? (:error out)))))))
|
||||
|
||||
(t/deftest merge-refuses-stale-expected-main-revn
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id})
|
||||
create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "stale"}))
|
||||
branch-id (:id create)]
|
||||
|
||||
(let [out (th/command! {::th/type :merge-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id
|
||||
:expected-main-revn 9999})
|
||||
error (:error out)]
|
||||
(t/is (some? error))
|
||||
(t/is (= :file-modified (-> error ex-data :code)))))))
|
||||
|
||||
(t/deftest revn-gate-survives-update-from-main
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id})
|
||||
create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "gate"}))
|
||||
branch-id (:id create)
|
||||
branch-file-id (:branch-file-id create)
|
||||
bid (uuid/random)
|
||||
m1 (uuid/random)
|
||||
m2 (uuid/random)
|
||||
m3 (uuid/random)]
|
||||
|
||||
;; ONE change on the branch...
|
||||
(apply-change* profile branch-file-id
|
||||
{:type :add-color :color {:id bid :name "B" :color "#0000ff" :opacity 1}})
|
||||
;; ...while main advances MORE times than the branch (its revn ends up
|
||||
;; far ahead of the branch's own revn counter)
|
||||
(doseq [[id nm] [[m1 "M1"] [m2 "M2"] [m3 "M3"]]]
|
||||
(apply-change* profile (:id file)
|
||||
{:type :add-color :color {:id id :name nm :color "#00ff00" :opacity 1}}))
|
||||
|
||||
(let [out (th/command! {::th/type :update-branch-from-main
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :updated (-> out :result :status))))
|
||||
|
||||
;; after repositioning the base, the branch's own unmerged change must
|
||||
;; still be reported (before the fix, base-revn was compared against
|
||||
;; the WRONG counter and ahead collapsed to 0)
|
||||
(let [info (:result (th/command! {::th/type :get-file-branch-info
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id branch-file-id}))]
|
||||
(t/is (= 1 (:ahead info)))
|
||||
(t/is (= 0 (:behind info)))))))
|
||||
|
||||
(t/deftest cannot-branch-a-branch
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id})
|
||||
create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "level-1"}))]
|
||||
(let [out (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:branch-file-id create)
|
||||
:name "level-2"})
|
||||
error (:error out)]
|
||||
(t/is (some? error))
|
||||
(t/is (= :cannot-branch-a-branch (-> error ex-data :code)))))))
|
||||
|
||||
(t/deftest deleting-source-cascades-to-branches
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id})
|
||||
create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "orphan-candidate"}))
|
||||
branch-id (:id create)
|
||||
branch-file-id (:branch-file-id create)
|
||||
dt (ct/in-future {:days 7})]
|
||||
|
||||
;; run the delete-object cascade for the SOURCE file
|
||||
(th/run-task! :delete-object {:object :file :id (:id file) :deleted-at dt})
|
||||
|
||||
(let [[row] (th/db-query :file-branch {:id branch-id})
|
||||
bf (th/db-get :file {:id branch-file-id} {::db/remove-deleted false})]
|
||||
(t/is (some? (:deleted-at row)))
|
||||
(t/is (some? (:deleted-at bf)))))))
|
||||
|
||||
(t/deftest merge-copies-branch-media-into-main
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id})
|
||||
|
||||
storage (-> (:app.storage/storage th/*system*)
|
||||
(assoc :app.storage/backend :fs))
|
||||
|
||||
mk-sobj (fn [content]
|
||||
(sto/put-object! storage {::sto/content (sto/content content)
|
||||
:bucket :file-media-object
|
||||
:content-type "image/png"}))
|
||||
|
||||
;; media present on MAIN before branching (will be paired)
|
||||
sobj1 (mk-sobj "shared-image")
|
||||
fmo1 (th/create-file-media-object* {:file-id (:id file)
|
||||
:name "shared.png"
|
||||
:media-id (:id sobj1)})
|
||||
out1 (apply-change* profile (:id file)
|
||||
{:type :add-media
|
||||
:object {:id (:id fmo1) :name "shared.png"
|
||||
:media-id (:id sobj1)
|
||||
:width 100 :height 100 :mtype "image/png"}})
|
||||
|
||||
create (:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name "with-media"}))
|
||||
branch-id (:id create)
|
||||
branch-file-id (:branch-file-id create)
|
||||
|
||||
;; media ADDED on the branch after forking
|
||||
sobj2 (mk-sobj "branch-only-image")
|
||||
fmo2 (th/create-file-media-object* {:file-id branch-file-id
|
||||
:name "new.png"
|
||||
:media-id (:id sobj2)})
|
||||
out2 (apply-change* profile branch-file-id
|
||||
{:type :add-media
|
||||
:object {:id (:id fmo2) :name "new.png"
|
||||
:media-id (:id sobj2)
|
||||
:width 100 :height 100 :mtype "image/png"}})]
|
||||
|
||||
(t/testing "media changes were applied"
|
||||
(t/is (nil? (:error out1)))
|
||||
(t/is (nil? (:error out2)))
|
||||
(let [bf (th/db-get :file {:id branch-file-id})]
|
||||
(t/is (pos? (:revn bf)))))
|
||||
|
||||
(t/testing "pre-existing media does not show up as branch changes"
|
||||
(let [info (:result (th/command! {::th/type :get-file-branch-info
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id branch-file-id}))]
|
||||
;; only the added media counts; the duplicated (paired) one is
|
||||
;; normalized away instead of appearing as deleted+added churn
|
||||
(t/is (= 1 (:ahead info)))
|
||||
(t/is (= 0 (:behind info)))))
|
||||
|
||||
(t/testing "merging brings the new media with a MAIN-owned row"
|
||||
(let [out (th/command! {::th/type :merge-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id
|
||||
:keep-branch true})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :merged (-> out :result :status))))
|
||||
|
||||
(let [out (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)})
|
||||
media (-> out :result :data :media)
|
||||
added (->> (vals media) (filter #(= "new.png" (:name %))) first)]
|
||||
(t/is (some? added))
|
||||
;; the merged entry references a row owned by MAIN (a copy), not
|
||||
;; the branch's row (which dies with the branch file)
|
||||
(t/is (not= (:id fmo2) (:id added)))
|
||||
(let [row (th/db-get :file-media-object {:id (:id added)})]
|
||||
(t/is (= (:id file) (:file-id row)))
|
||||
(t/is (= (:id sobj2) (:media-id row)))))))))
|
||||
550
backend/test/backend_tests/rpc_file_pull_request_test.clj
Normal file
@ -0,0 +1,550 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.rpc-file-pull-request-test
|
||||
(:require
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.rpc :as-alias rpc]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.string :as str]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(def ^:private pr-flags
|
||||
(conj cf/flags :branching))
|
||||
|
||||
(defn- apply-change*
|
||||
[profile file-id change]
|
||||
(let [f (th/db-get :file {:id file-id})]
|
||||
(th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn (:revn f)
|
||||
:vern (:vern f)
|
||||
:features cfeat/supported-features
|
||||
:changes [change]})))
|
||||
|
||||
(defn- add-color*
|
||||
[profile file-id name]
|
||||
(apply-change* profile file-id
|
||||
{:type :add-color
|
||||
:color {:id (uuid/random) :name name :color "#ff0000" :opacity 1}}))
|
||||
|
||||
(defn- create-branch*
|
||||
[profile file name]
|
||||
(:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:name name})))
|
||||
|
||||
(defn- create-pr*
|
||||
[profile branch-id params]
|
||||
(th/command! (merge {::th/type :create-pull-request
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id
|
||||
:title "review me"}
|
||||
params)))
|
||||
|
||||
(t/deftest create-and-list-pull-requests
|
||||
(with-redefs [cf/flags pr-flags]
|
||||
(let [author (th/create-profile* 1 {:is-active true})
|
||||
reviewer (th/create-profile* 2 {:is-active true})
|
||||
proj-id (:default-project-id author)
|
||||
file (th/create-file* 1 {:profile-id (:id author)
|
||||
:project-id proj-id})]
|
||||
|
||||
(th/create-team-role* {:team-id (:default-team-id author)
|
||||
:profile-id (:id reviewer)
|
||||
:role :editor})
|
||||
|
||||
(let [branch (create-branch* author file "feature")
|
||||
branch-file-id (:branch-file-id branch)
|
||||
pr-id (volatile! nil)]
|
||||
|
||||
(add-color* author branch-file-id "Brand")
|
||||
|
||||
(t/testing "create pull request with a reviewer"
|
||||
(let [out (create-pr* author (:id branch)
|
||||
{:description "please check the brand color"
|
||||
:reviewers [(:id reviewer)]})]
|
||||
(t/is (nil? (:error out)))
|
||||
(let [result (:result out)]
|
||||
(t/is (uuid? (:id result)))
|
||||
(t/is (= branch-file-id (:source-file-id result)))
|
||||
(t/is (= (:id file) (:target-file-id result)))
|
||||
(t/is (= "open" (:status result)))
|
||||
(t/is (= [(:id reviewer)] (:reviewers result)))
|
||||
(vreset! pr-id (:id result)))))
|
||||
|
||||
(t/testing "review snapshot is pinned on the branch file"
|
||||
(let [rows (->> (th/db-query :file-change {:file-id branch-file-id})
|
||||
(filter #(some-> (:label %) (str/starts-with? "pr-review/"))))]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (every? (fn [{:keys [deleted-at]}]
|
||||
(and (some? deleted-at)
|
||||
(ct/is-after? deleted-at (ct/in-future {:days 3000}))))
|
||||
rows))))
|
||||
|
||||
(t/testing "list from the target file"
|
||||
(let [out (th/command! {::th/type :get-file-pull-requests
|
||||
::rpc/profile-id (:id author)
|
||||
:file-id (:id file)})]
|
||||
(t/is (nil? (:error out)))
|
||||
(let [[row :as result] (:result out)]
|
||||
(t/is (= 1 (count result)))
|
||||
(t/is (= @pr-id (:id row)))
|
||||
(t/is (= "review me" (:title row)))
|
||||
(t/is (= "feature" (:branch-name row)))
|
||||
(t/is (= (:name file) (:target-name row)))
|
||||
(t/is (= "pending" (:review-state row)))
|
||||
(t/is (false? (:outdated row)))
|
||||
;; the branch carries one change (the added color)
|
||||
(t/is (= 1 (:ahead row)))
|
||||
(t/is (= 0 (:behind row)))
|
||||
(t/is (= 0 (:conflicts row)))
|
||||
(t/is (= 1 (count (:reviews row))))
|
||||
(t/is (= (:id reviewer) (-> row :reviews first :profile-id)))
|
||||
(t/is (= "pending" (-> row :reviews first :state))))))
|
||||
|
||||
(t/testing "list resolves a branch file id to its target"
|
||||
(let [out (th/command! {::th/type :get-file-pull-requests
|
||||
::rpc/profile-id (:id author)
|
||||
:file-id branch-file-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 1 (count (:result out))))))
|
||||
|
||||
(t/testing "get single pull request"
|
||||
(let [out (th/command! {::th/type :get-pull-request
|
||||
::rpc/profile-id (:id reviewer)
|
||||
:id @pr-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= "review me" (-> out :result :title)))))))))
|
||||
|
||||
(t/deftest one-open-pull-request-per-branch
|
||||
(with-redefs [cf/flags pr-flags]
|
||||
(let [author (th/create-profile* 1 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id author)
|
||||
:project-id (:default-project-id author)})
|
||||
branch (create-branch* author file "feature")]
|
||||
|
||||
(t/is (nil? (:error (create-pr* author (:id branch) {}))))
|
||||
|
||||
(let [out (create-pr* author (:id branch) {:title "second"})
|
||||
error (:error out)]
|
||||
(t/is (some? error))
|
||||
(t/is (= :pull-request-already-exists (-> error ex-data :code)))))))
|
||||
|
||||
(t/deftest create-validates-reviewers-and-permissions
|
||||
(with-redefs [cf/flags pr-flags]
|
||||
(let [author (th/create-profile* 1 {:is-active true})
|
||||
stranger (th/create-profile* 2 {:is-active true})
|
||||
viewer (th/create-profile* 3 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id author)
|
||||
:project-id (:default-project-id author)})
|
||||
branch (create-branch* author file "feature")]
|
||||
|
||||
(th/create-team-role* {:team-id (:default-team-id author)
|
||||
:profile-id (:id viewer)
|
||||
:role :viewer})
|
||||
|
||||
(t/testing "a reviewer outside the team is refused"
|
||||
(let [out (create-pr* author (:id branch)
|
||||
{:reviewers [(:id stranger)]})
|
||||
error (:error out)]
|
||||
(t/is (some? error))
|
||||
(t/is (= :reviewer-not-team-member (-> error ex-data :code)))))
|
||||
|
||||
(t/testing "the author is silently dropped from the reviewer list"
|
||||
(let [out (create-pr* author (:id branch)
|
||||
{:reviewers [(:id author) (:id viewer)]})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= [(:id viewer)] (-> out :result :reviewers)))))
|
||||
|
||||
(t/testing "a viewer of the branch file cannot create a pull request"
|
||||
(let [branch2 (create-branch* author file "feature-2")
|
||||
out (create-pr* viewer (:id branch2) {})]
|
||||
(t/is (some? (:error out))))))))
|
||||
|
||||
(t/deftest bundle-serves-the-pinned-snapshot
|
||||
(with-redefs [cf/flags pr-flags]
|
||||
(let [author (th/create-profile* 1 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id author)
|
||||
:project-id (:default-project-id author)})
|
||||
branch (create-branch* author file "feature")
|
||||
bfid (:branch-file-id branch)
|
||||
color-1 (uuid/random)
|
||||
color-2 (uuid/random)]
|
||||
|
||||
(apply-change* author bfid
|
||||
{:type :add-color
|
||||
:color {:id color-1 :name "One" :color "#111111" :opacity 1}})
|
||||
|
||||
(let [pr (:result (create-pr* author (:id branch) {}))]
|
||||
|
||||
;; the branch keeps moving AFTER the pull request was created
|
||||
(apply-change* author bfid
|
||||
{:type :add-color
|
||||
:color {:id color-2 :name "Two" :color "#222222" :opacity 1}})
|
||||
|
||||
(t/testing "the sandbox shows the reviewed state, not the live branch"
|
||||
(let [out (th/command! {::th/type :get-pull-request-bundle
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)})
|
||||
result (:result out)
|
||||
colors (-> result :data :colors)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (contains? colors color-1))
|
||||
(t/is (not (contains? colors color-2)))
|
||||
;; read-only by construction, whatever the caller's role is
|
||||
(t/is (false? (-> result :permissions :can-edit)))))
|
||||
|
||||
(t/testing "the pull request reports the branch moved on"
|
||||
(let [out (th/command! {::th/type :get-pull-request
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)})]
|
||||
(t/is (true? (-> out :result :outdated)))))
|
||||
|
||||
(t/testing "publishing the branch state refreshes the sandbox"
|
||||
(let [out (th/command! {::th/type :update-pull-request-snapshot
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (true? (-> out :result :updated))))
|
||||
|
||||
(let [out (th/command! {::th/type :get-pull-request-bundle
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)})
|
||||
colors (-> out :result :data :colors)]
|
||||
(t/is (contains? colors color-2)))
|
||||
|
||||
(let [out (th/command! {::th/type :get-pull-request
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)})]
|
||||
(t/is (false? (-> out :result :outdated)))))
|
||||
|
||||
(t/testing "publishing again with no new changes is a no-op"
|
||||
(let [out (th/command! {::th/type :update-pull-request-snapshot
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (false? (-> out :result :updated)))))))))
|
||||
|
||||
(t/deftest viewer-bundle-serves-the-pinned-snapshot
|
||||
(with-redefs [cf/flags pr-flags]
|
||||
(let [author (th/create-profile* 1 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id author)
|
||||
:project-id (:default-project-id author)})
|
||||
branch (create-branch* author file "feature")
|
||||
bfid (:branch-file-id branch)
|
||||
color-1 (uuid/random)
|
||||
color-2 (uuid/random)]
|
||||
|
||||
(apply-change* author bfid
|
||||
{:type :add-color
|
||||
:color {:id color-1 :name "One" :color "#111111" :opacity 1}})
|
||||
|
||||
(let [pr (:result (create-pr* author (:id branch) {}))]
|
||||
|
||||
;; the branch keeps moving AFTER the pull request was created
|
||||
(apply-change* author bfid
|
||||
{:type :add-color
|
||||
:color {:id color-2 :name "Two" :color "#222222" :opacity 1}})
|
||||
|
||||
;; the bundle strips file :data down to pages/components, so the
|
||||
;; served state is asserted through :revn (the overlay carries the
|
||||
;; snapshot's revn; the live branch is one save ahead)
|
||||
(t/testing "the viewer bundle shows the reviewed state, read-only"
|
||||
(let [out (th/command! {::th/type :get-view-only-bundle
|
||||
::rpc/profile-id (:id author)
|
||||
:file-id bfid
|
||||
:pr-id (:id pr)})
|
||||
result (:result out)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= (:review-revn pr) (-> result :file :revn)))
|
||||
(t/is (false? (-> result :permissions :can-edit)))))
|
||||
|
||||
(t/testing "the viewer bundle without pr-id keeps the live state"
|
||||
(let [out (th/command! {::th/type :get-view-only-bundle
|
||||
::rpc/profile-id (:id author)
|
||||
:file-id bfid})
|
||||
bf (th/db-get :file {:id bfid})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= (:revn bf) (-> out :result :file :revn)))
|
||||
(t/is (< (:review-revn pr) (:revn bf)))))
|
||||
|
||||
(t/testing "the viewer bundle refuses a closed pull request"
|
||||
(th/command! {::th/type :close-pull-request
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)})
|
||||
(let [out (th/command! {::th/type :get-view-only-bundle
|
||||
::rpc/profile-id (:id author)
|
||||
:file-id bfid
|
||||
:pr-id (:id pr)})
|
||||
error (:error out)]
|
||||
(t/is (some? error))
|
||||
(t/is (= :pull-request-not-open (-> error ex-data :code)))))))))
|
||||
|
||||
(t/deftest review-lifecycle
|
||||
(with-redefs [cf/flags pr-flags]
|
||||
(let [author (th/create-profile* 1 {:is-active true})
|
||||
reviewer (th/create-profile* 2 {:is-active true})
|
||||
other (th/create-profile* 3 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id author)
|
||||
:project-id (:default-project-id author)})]
|
||||
|
||||
(th/create-team-role* {:team-id (:default-team-id author)
|
||||
:profile-id (:id reviewer)
|
||||
:role :editor})
|
||||
(th/create-team-role* {:team-id (:default-team-id author)
|
||||
:profile-id (:id other)
|
||||
:role :editor})
|
||||
|
||||
(let [branch (create-branch* author file "feature")
|
||||
bfid (:branch-file-id branch)
|
||||
_ (add-color* author bfid "Brand")
|
||||
pr (:result (create-pr* author (:id branch)
|
||||
{:reviewers [(:id reviewer)]}))]
|
||||
|
||||
(t/testing "a non-assigned member cannot submit a review"
|
||||
(let [out (th/command! {::th/type :submit-pull-request-review
|
||||
::rpc/profile-id (:id other)
|
||||
:id (:id pr)
|
||||
:state "approved"})
|
||||
error (:error out)]
|
||||
(t/is (some? error))
|
||||
(t/is (= :not-a-reviewer (-> error ex-data :code)))))
|
||||
|
||||
(t/testing "an approval flips the aggregate review state"
|
||||
(let [out (th/command! {::th/type :submit-pull-request-review
|
||||
::rpc/profile-id (:id reviewer)
|
||||
:id (:id pr)
|
||||
:state "approved"
|
||||
:comment "ship it"})]
|
||||
(t/is (nil? (:error out))))
|
||||
(let [out (th/command! {::th/type :get-pull-request
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)})
|
||||
row (:result out)]
|
||||
(t/is (= "approved" (:review-state row)))
|
||||
(t/is (false? (-> row :reviews first :stale)))))
|
||||
|
||||
(t/testing "publishing new changes makes the verdict stale"
|
||||
(add-color* author bfid "Another")
|
||||
(let [out (th/command! {::th/type :update-pull-request-snapshot
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)})]
|
||||
(t/is (nil? (:error out))))
|
||||
(let [row (:result (th/command! {::th/type :get-pull-request
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)}))]
|
||||
;; the verdict is kept for the history but no longer current
|
||||
(t/is (= "in-review" (:review-state row)))
|
||||
(t/is (true? (-> row :reviews first :stale)))))
|
||||
|
||||
(t/testing "requesting changes beats an approval"
|
||||
(let [out (th/command! {::th/type :submit-pull-request-review
|
||||
::rpc/profile-id (:id reviewer)
|
||||
:id (:id pr)
|
||||
:state "changes-requested"
|
||||
:comment "the second color is off-brand"})]
|
||||
(t/is (nil? (:error out))))
|
||||
(let [row (:result (th/command! {::th/type :get-pull-request
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)}))]
|
||||
(t/is (= "changes-requested" (:review-state row)))))))))
|
||||
|
||||
(t/deftest update-pull-request-metadata-and-reviewers
|
||||
(with-redefs [cf/flags pr-flags]
|
||||
(let [author (th/create-profile* 1 {:is-active true})
|
||||
reviewer-1 (th/create-profile* 2 {:is-active true})
|
||||
reviewer-2 (th/create-profile* 3 {:is-active true})
|
||||
editor (th/create-profile* 4 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id author)
|
||||
:project-id (:default-project-id author)})]
|
||||
|
||||
(doseq [[profile role] [[reviewer-1 :editor] [reviewer-2 :editor] [editor :editor]]]
|
||||
(th/create-team-role* {:team-id (:default-team-id author)
|
||||
:profile-id (:id profile)
|
||||
:role role}))
|
||||
|
||||
(let [branch (create-branch* author file "feature")
|
||||
pr (:result (create-pr* author (:id branch)
|
||||
{:reviewers [(:id reviewer-1)]}))]
|
||||
|
||||
(t/testing "a plain editor (not author, not admin) cannot manage it"
|
||||
(let [out (th/command! {::th/type :update-pull-request
|
||||
::rpc/profile-id (:id editor)
|
||||
:id (:id pr)
|
||||
:title "hijacked"})]
|
||||
(t/is (some? (:error out)))))
|
||||
|
||||
(t/testing "the author can replace title, description and reviewers"
|
||||
(let [out (th/command! {::th/type :update-pull-request
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)
|
||||
:title "better title"
|
||||
:description "more context"
|
||||
:reviewers [(:id reviewer-2)]})]
|
||||
(t/is (nil? (:error out))))
|
||||
(let [row (:result (th/command! {::th/type :get-pull-request
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)}))]
|
||||
(t/is (= "better title" (:title row)))
|
||||
(t/is (= "more context" (:description row)))
|
||||
(t/is (= [(:id reviewer-2)] (mapv :profile-id (:reviews row))))))
|
||||
|
||||
(t/testing "a removed reviewer can no longer submit"
|
||||
(let [out (th/command! {::th/type :submit-pull-request-review
|
||||
::rpc/profile-id (:id reviewer-1)
|
||||
:id (:id pr)
|
||||
:state "approved"})
|
||||
error (:error out)]
|
||||
(t/is (some? error))
|
||||
(t/is (= :not-a-reviewer (-> error ex-data :code)))))))))
|
||||
|
||||
(t/deftest close-and-reopen-pull-request
|
||||
(with-redefs [cf/flags pr-flags]
|
||||
(let [author (th/create-profile* 1 {:is-active true})
|
||||
editor (th/create-profile* 2 {:is-active true})
|
||||
admin (th/create-profile* 3 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id author)
|
||||
:project-id (:default-project-id author)})]
|
||||
|
||||
(th/create-team-role* {:team-id (:default-team-id author)
|
||||
:profile-id (:id editor)
|
||||
:role :editor})
|
||||
(th/create-team-role* {:team-id (:default-team-id author)
|
||||
:profile-id (:id admin)
|
||||
:role :admin})
|
||||
|
||||
(let [branch (create-branch* author file "feature")
|
||||
bfid (:branch-file-id branch)
|
||||
pr (:result (create-pr* author (:id branch) {}))]
|
||||
|
||||
(t/testing "a plain editor cannot close someone else's pull request"
|
||||
(let [out (th/command! {::th/type :close-pull-request
|
||||
::rpc/profile-id (:id editor)
|
||||
:id (:id pr)})]
|
||||
(t/is (some? (:error out)))))
|
||||
|
||||
(t/testing "a team admin can close it"
|
||||
(let [out (th/command! {::th/type :close-pull-request
|
||||
::rpc/profile-id (:id admin)
|
||||
:id (:id pr)})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= "closed" (-> out :result :status)))))
|
||||
|
||||
(t/testing "the sandbox stops resolving once closed"
|
||||
(let [out (th/command! {::th/type :get-pull-request-bundle
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)})
|
||||
error (:error out)]
|
||||
(t/is (some? error))
|
||||
(t/is (= :pull-request-not-open (-> error ex-data :code)))))
|
||||
|
||||
(t/testing "the review snapshot pin is released on close"
|
||||
(let [rows (->> (th/db-query :file-change {:file-id bfid})
|
||||
(filter #(some-> (:label %) (str/starts-with? "pr-review/"))))]
|
||||
(t/is (seq rows))
|
||||
(t/is (every? (fn [{:keys [deleted-at]}]
|
||||
(and (some? deleted-at)
|
||||
(ct/is-before? deleted-at (ct/in-future {:days 400}))))
|
||||
rows))))
|
||||
|
||||
(t/testing "the author can reopen it, re-pinned at the current head"
|
||||
(add-color* author bfid "Late")
|
||||
(let [out (th/command! {::th/type :reopen-pull-request
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)})
|
||||
bf (th/db-get :file {:id bfid})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= "open" (-> out :result :status)))
|
||||
(t/is (= (:revn bf) (-> out :result :review-revn)))))))))
|
||||
|
||||
(t/deftest branch-lifecycle-closes-pull-requests
|
||||
(with-redefs [cf/flags pr-flags]
|
||||
(let [author (th/create-profile* 1 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id author)
|
||||
:project-id (:default-project-id author)})]
|
||||
|
||||
(t/testing "merging the branch marks the pull request merged"
|
||||
(let [branch (create-branch* author file "merge-me")
|
||||
_ (add-color* author (:branch-file-id branch) "Brand")
|
||||
pr (:result (create-pr* author (:id branch) {}))
|
||||
out (th/command! {::th/type :merge-file-branch
|
||||
::rpc/profile-id (:id author)
|
||||
:branch-id (:id branch)})]
|
||||
(t/is (nil? (:error out)))
|
||||
(let [[row] (th/db-query :file-pull-request {:id (:id pr)})]
|
||||
(t/is (= "merged" (:status row)))
|
||||
(t/is (some? (:closed-at row)))
|
||||
;; the row survives as history even though the branch is gone
|
||||
(t/is (nil? (:deleted-at row))))))
|
||||
|
||||
(t/testing "deleting the branch closes the pull request"
|
||||
(let [branch (create-branch* author file "delete-me")
|
||||
pr (:result (create-pr* author (:id branch) {}))
|
||||
out (th/command! {::th/type :delete-file-branch
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id branch)})]
|
||||
(t/is (nil? (:error out)))
|
||||
(let [[row] (th/db-query :file-pull-request {:id (:id pr)})]
|
||||
(t/is (= "closed" (:status row))))))
|
||||
|
||||
(t/testing "archiving the branch closes the pull request"
|
||||
(let [branch (create-branch* author file "archive-me")
|
||||
pr (:result (create-pr* author (:id branch) {}))
|
||||
out (th/command! {::th/type :archive-file-branch
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id branch)})]
|
||||
(t/is (nil? (:error out)))
|
||||
(let [[row] (th/db-query :file-pull-request {:id (:id pr)})]
|
||||
(t/is (= "closed" (:status row)))))))))
|
||||
|
||||
(t/deftest closed-pull-requests-listing
|
||||
(with-redefs [cf/flags pr-flags]
|
||||
(let [author (th/create-profile* 1 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id author)
|
||||
:project-id (:default-project-id author)})
|
||||
branch (create-branch* author file "feature")
|
||||
pr (:result (create-pr* author (:id branch) {}))]
|
||||
|
||||
(th/command! {::th/type :close-pull-request
|
||||
::rpc/profile-id (:id author)
|
||||
:id (:id pr)})
|
||||
|
||||
(t/testing "closed pull requests are hidden by default"
|
||||
(let [out (th/command! {::th/type :get-file-pull-requests
|
||||
::rpc/profile-id (:id author)
|
||||
:file-id (:id file)})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 0 (count (:result out))))))
|
||||
|
||||
(t/testing "and visible with include-closed"
|
||||
(let [out (th/command! {::th/type :get-file-pull-requests
|
||||
::rpc/profile-id (:id author)
|
||||
:file-id (:id file)
|
||||
:include-closed true})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 1 (count (:result out))))
|
||||
(t/is (= "closed" (-> out :result first :status))))))))
|
||||
|
||||
(t/deftest branching-flag-gates-pull-request-commands
|
||||
(let [author (th/create-profile* 1 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id author)
|
||||
:project-id (:default-project-id author)})
|
||||
branch (with-redefs [cf/flags pr-flags]
|
||||
(create-branch* author file "feature"))
|
||||
out (create-pr* author (:id branch) {})
|
||||
error (:error out)]
|
||||
(t/is (some? error))
|
||||
(t/is (= :branching-disabled (-> error ex-data :code)))))
|
||||
1173
common/src/app/common/files/branch_merge.cljc
Normal file
@ -197,7 +197,13 @@
|
||||
:link-preview
|
||||
|
||||
;; Shows the "link later" option on the files export modal.
|
||||
:export-link-later})
|
||||
:export-link-later
|
||||
|
||||
;; Enables file branching: isolated file copies ("branches") that
|
||||
;; can be merged back to their source file, including pull requests
|
||||
;; (review requests over a pinned snapshot of a branch). Disabled by
|
||||
;; default.
|
||||
:branching})
|
||||
|
||||
(def all-flags
|
||||
(set/union email login varia))
|
||||
|
||||
996
common/test/common_tests/files_branch_merge_test.cljc
Normal file
@ -0,0 +1,996 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns common-tests.files-branch-merge-test
|
||||
(:require
|
||||
[app.common.files.branch-merge :as bm]
|
||||
[app.common.files.changes :as cfc]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(defn- mkdata
|
||||
[objects & {:keys [colors]}]
|
||||
{:pages-index {:p1 {:id :p1 :name "Page 1" :objects objects}}
|
||||
:colors (or colors {})
|
||||
:typographies {}
|
||||
:components {}
|
||||
:media {}})
|
||||
|
||||
(defn- pages-data
|
||||
[pages-index pages & {:keys [components]}]
|
||||
{:pages-index pages-index
|
||||
:pages pages
|
||||
:colors {} :typographies {} :media {}
|
||||
:components (or components {})})
|
||||
|
||||
(t/deftest identity-no-changes
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
|
||||
r (bm/compute-merge base base base :branch->main)]
|
||||
(t/is (= [] (:changes r)))
|
||||
(t/is (= [] (:conflicts r)))
|
||||
(t/is (= {:added 0 :modified 0 :deleted 0 :conflicts 0} (:stats r)))))
|
||||
|
||||
(t/deftest added-in-branch
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A"}})
|
||||
branch (mkdata {:s1 {:id :s1 :name "A"}
|
||||
:s2 {:id :s2 :name "B"}})
|
||||
r (bm/compute-merge base base branch :branch->main)
|
||||
c (first (filter #(= :added (:status %)) (:changes r)))]
|
||||
(t/is (= 1 (-> r :stats :added)))
|
||||
(t/is (= 0 (-> r :stats :conflicts)))
|
||||
(t/is (= :shape (:kind c)))
|
||||
(t/is (= :s2 (:id c)))
|
||||
(t/is (= :p1 (:page-id c)))
|
||||
(t/is (= "B" (:label c)))))
|
||||
|
||||
(t/deftest modified-in-branch
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
|
||||
branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue"}})
|
||||
r (bm/compute-merge base base branch :branch->main)
|
||||
c (first (:changes r))]
|
||||
(t/is (= 1 (-> r :stats :modified)))
|
||||
(t/is (= :modified (:status c)))
|
||||
(t/is (= {:main "red" :branch "blue"} (get-in c [:changed-attrs :fill])))))
|
||||
|
||||
(t/deftest root-frame-and-shapes-churn-hidden
|
||||
;; adding a top-level shape mutates the root frame's `:shapes`; that churn
|
||||
;; (and the root frame itself) must NOT show up as a change — only the new shape.
|
||||
(let [base (mkdata {uuid/zero {:id uuid/zero :name "Root Frame" :shapes [:s1]}
|
||||
:s1 {:id :s1 :name "A"}})
|
||||
branch (mkdata {uuid/zero {:id uuid/zero :name "Root Frame" :shapes [:s1 :s2]}
|
||||
:s1 {:id :s1 :name "A"}
|
||||
:s2 {:id :s2 :name "B"}})
|
||||
r (bm/compute-merge base base branch :branch->main)]
|
||||
(t/is (= #{:s2} (set (map :id (:changes r)))))
|
||||
(t/is (= 1 (-> r :stats :added)))
|
||||
(t/is (= 0 (-> r :stats :modified)))))
|
||||
|
||||
(t/deftest shape-with-only-structural-change-hidden
|
||||
;; a shape whose ONLY diff is structural/derived (here children reorder)
|
||||
;; is not reported as modified in the summary.
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :shapes [:c1 :c2]}})
|
||||
branch (mkdata {:s1 {:id :s1 :name "A" :shapes [:c2 :c1]}})
|
||||
r (bm/compute-merge base base branch :branch->main)]
|
||||
(t/is (= [] (:changes r)))
|
||||
(t/is (= 0 (-> r :stats :modified)))))
|
||||
|
||||
(t/deftest derived-attrs-stripped-containment-kept-and-merged
|
||||
;; SUMMARY strips only derived/cache attrs (:shapes/:selrect/:points), so
|
||||
;; meaningful attrs incl. reparenting (:parent-id/:frame-id) stay visible.
|
||||
;; MERGE applies scalar attrs via :set, but containment via :mov-objects
|
||||
;; (never :set parent-id/frame-id — that would corrupt the tree).
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"
|
||||
:parent-id :old :frame-id :old
|
||||
:selrect {:x 0} :points [1 2] :shapes [:c1]}})
|
||||
branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue"
|
||||
:parent-id :new :frame-id :new
|
||||
:selrect {:x 9} :points [3 4] :shapes [:c1 :c2]}})
|
||||
r (bm/compute-merge base base branch :branch->main)
|
||||
c (first (:changes r))
|
||||
chg (bm/compute-changes base base branch)
|
||||
ops (->> (:changes chg)
|
||||
(filter #(= :mod-obj (:type %)))
|
||||
first :operations (map :attr) set)
|
||||
movs (filter #(= :mov-objects (:type %)) (:changes chg))]
|
||||
(t/is (= 1 (-> r :stats :modified)))
|
||||
;; summary: meaningful attrs (incl. containment) survive; caches stripped
|
||||
(t/is (= #{:fill :parent-id :frame-id} (set (keys (:changed-attrs c)))))
|
||||
;; merge: scalar attrs are :set; containment is NOT a :set op
|
||||
(t/is (contains? ops :fill))
|
||||
(t/is (not (contains? ops :parent-id)))
|
||||
(t/is (not (contains? ops :frame-id)))
|
||||
(t/is (not (contains? ops :shapes)))
|
||||
;; merge: reparenting is applied via mov-objects to the branch parent
|
||||
(t/is (= 1 (count movs)))
|
||||
(t/is (= :new (:parent-id (first movs))))
|
||||
(t/is (= [:s1] (:shapes (first movs))))))
|
||||
|
||||
(t/deftest move-existing-layer-into-board-is-shown
|
||||
;; reparenting an existing layer into a new board must surface as a change
|
||||
;; on that layer (the user's "I moved the text into the board" action).
|
||||
(let [base (mkdata {:t1 {:id :t1 :name "Text" :type :text
|
||||
:parent-id uuid/zero :frame-id uuid/zero}})
|
||||
branch (mkdata {:t1 {:id :t1 :name "Text" :type :text
|
||||
:parent-id :board :frame-id :board}
|
||||
:board {:id :board :name "Board" :type :frame :shapes [:t1]}})
|
||||
r (bm/compute-merge base base branch :branch->main)
|
||||
moved (first (filter #(and (= :modified (:status %)) (= :t1 (:id %))) (:changes r)))]
|
||||
(t/is (some? moved))
|
||||
(t/is (contains? (:changed-attrs moved) :frame-id))))
|
||||
|
||||
(t/deftest shape-entries-carry-type-metadata
|
||||
;; diff entries expose the shape type / component nature so the compare
|
||||
;; view can pick a type-accurate icon and label.
|
||||
(let [base (mkdata {})
|
||||
branch (mkdata {:t1 {:id :t1 :name "Title" :type :text}
|
||||
:r1 {:id :r1 :name "Box" :type :rect}
|
||||
:c1 {:id :c1 :name "Btn" :type :frame
|
||||
:component-id (uuid/random) :main-instance true}})
|
||||
r (bm/compute-merge base base branch :branch->main)
|
||||
by-id (into {} (map (juxt :id identity)) (:changes r))]
|
||||
(t/is (= :text (get-in by-id [:t1 :shape-type])))
|
||||
(t/is (= :rect (get-in by-id [:r1 :shape-type])))
|
||||
(t/is (true? (get-in by-id [:c1 :component?])))))
|
||||
|
||||
(t/deftest deleted-in-branch
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A"} :s2 {:id :s2 :name "B"}})
|
||||
branch (mkdata {:s1 {:id :s1 :name "A"}})
|
||||
r (bm/compute-merge base base branch :branch->main)
|
||||
c (first (:changes r))]
|
||||
(t/is (= 1 (-> r :stats :deleted)))
|
||||
(t/is (= :deleted (:status c)))
|
||||
(t/is (= :s2 (:id c)))))
|
||||
|
||||
(t/deftest fast-forward-main-untouched
|
||||
;; main == base; branch changed -> all branch changes apply cleanly
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
|
||||
branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue"}})
|
||||
r (bm/compute-merge base base branch :branch->main)]
|
||||
(t/is (= 0 (-> r :stats :conflicts)))
|
||||
(t/is (= 1 (-> r :stats :modified)))))
|
||||
|
||||
(t/deftest main-only-change-not-reported
|
||||
;; branch == base; main changed -> branch contributes nothing
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
|
||||
main (mkdata {:s1 {:id :s1 :name "A" :fill "green"}})
|
||||
r (bm/compute-merge base main base :branch->main)]
|
||||
(t/is (= [] (:changes r)))
|
||||
(t/is (= 0 (-> r :stats :conflicts)))))
|
||||
|
||||
(t/deftest modify-modify-conflict
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
|
||||
main (mkdata {:s1 {:id :s1 :name "A" :fill "green"}})
|
||||
branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue"}})
|
||||
r (bm/compute-merge base main branch :branch->main)
|
||||
cf (first (:conflicts r))]
|
||||
(t/is (= 1 (-> r :stats :conflicts)))
|
||||
(t/is (= :modify-modify (:reason cf)))
|
||||
(t/is (= "green" (get-in cf [:changed-attrs :fill :main])))
|
||||
(t/is (= "blue" (get-in cf [:changed-attrs :fill :branch])))))
|
||||
|
||||
(t/deftest delete-modify-conflict
|
||||
;; deleted in branch, modified in main
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
|
||||
main (mkdata {:s1 {:id :s1 :name "A" :fill "green"}})
|
||||
branch (mkdata {})
|
||||
r (bm/compute-merge base main branch :branch->main)
|
||||
cf (first (:conflicts r))]
|
||||
(t/is (= 1 (-> r :stats :conflicts)))
|
||||
(t/is (= :delete-modify (:reason cf)))))
|
||||
|
||||
(t/deftest color-added-in-branch
|
||||
(let [base (mkdata {} :colors {})
|
||||
branch (mkdata {} :colors {:c1 {:id :c1 :name "Primary"}})
|
||||
r (bm/compute-merge base base branch :branch->main)
|
||||
c (first (filter #(= :color (:kind %)) (:changes r)))]
|
||||
(t/is (= :added (:status c)))
|
||||
(t/is (= "Primary" (:label c)))))
|
||||
|
||||
;; --- component :modified-at noise (bookkeeping timestamp)
|
||||
|
||||
(defn- comp-data
|
||||
[components]
|
||||
{:pages-index {:p1 {:id :p1 :name "Page 1" :objects {}}}
|
||||
:colors {} :typographies {} :media {}
|
||||
:components components})
|
||||
|
||||
(t/deftest component-modified-at-only-change-is-ignored
|
||||
;; editing a component bumps its `:modified-at`; if that timestamp is the
|
||||
;; ONLY difference it must not surface as a change or conflict, and must not
|
||||
;; emit a spurious :mod-component.
|
||||
(let [cid (uuid/random)
|
||||
base (comp-data {cid {:id cid :name "Button" :path "" :modified-at "t0"}})
|
||||
branch (comp-data {cid {:id cid :name "Button" :path "" :modified-at "t1"}})
|
||||
r (bm/compute-merge base base branch :branch->main)
|
||||
{:keys [changes unsupported]} (bm/compute-changes base base branch)]
|
||||
(t/is (= [] (:changes r)))
|
||||
(t/is (= [] (:conflicts r)))
|
||||
(t/is (= 0 (-> r :stats :modified)))
|
||||
(t/is (empty? (filter #(= :mod-component (:type %)) changes)))
|
||||
(t/is (empty? unsupported))))
|
||||
|
||||
(t/deftest component-modified-at-no-spurious-conflict
|
||||
;; both sides edited the component (different timestamps) but nothing else;
|
||||
;; the differing :modified-at must NOT create a modify-modify conflict.
|
||||
(let [cid (uuid/random)
|
||||
base (comp-data {cid {:id cid :name "Button" :path "" :modified-at "t0"}})
|
||||
main (comp-data {cid {:id cid :name "Button" :path "" :modified-at "t1"}})
|
||||
branch (comp-data {cid {:id cid :name "Button" :path "" :modified-at "t2"}})
|
||||
r (bm/compute-merge base main branch :branch->main)]
|
||||
(t/is (= [] (:conflicts r)))
|
||||
(t/is (= 0 (-> r :stats :conflicts)))))
|
||||
|
||||
(t/deftest component-real-change-excludes-modified-at
|
||||
;; a genuine metadata change (rename) is reported, but the timestamp churn
|
||||
;; is stripped from the property changes shown to the user.
|
||||
(let [cid (uuid/random)
|
||||
base (comp-data {cid {:id cid :name "Button" :path "" :modified-at "t0"}})
|
||||
branch (comp-data {cid {:id cid :name "Primary Button" :path "" :modified-at "t1"}})
|
||||
r (bm/compute-merge base base branch :branch->main)
|
||||
c (first (filter #(= :component (:kind %)) (:changes r)))]
|
||||
(t/is (= :modified (:status c)))
|
||||
(t/is (= {:main "Button" :branch "Primary Button"} (get-in c [:changed-attrs :name])))
|
||||
(t/is (not (contains? (:changed-attrs c) :modified-at)))))
|
||||
|
||||
;; --- compute-changes (merge -> change maps)
|
||||
|
||||
(t/deftest compute-changes-colors
|
||||
(let [base (mkdata {} :colors {:c1 {:id :c1 :name "A"}})
|
||||
branch (mkdata {} :colors {:c1 {:id :c1 :name "A2"}
|
||||
:c2 {:id :c2 :name "B"}})
|
||||
{:keys [changes unsupported]} (bm/compute-changes base base branch)
|
||||
by-type (group-by :type changes)]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (= :c2 (-> by-type :add-color first :color :id)))
|
||||
(t/is (= "A2" (-> by-type :mod-color first :color :name)))))
|
||||
|
||||
(t/deftest remap-refs-remaps-local-only
|
||||
;; only ids present in the map are re-pointed; external-library refs and
|
||||
;; shapes with no refs are left untouched. Covers component, library
|
||||
;; color/typography and media references plus the :media/:colors indexes.
|
||||
(let [branch-id (uuid/next)
|
||||
main-id (uuid/next)
|
||||
ext-lib (uuid/next)
|
||||
media-old (uuid/next)
|
||||
media-new (uuid/next)
|
||||
data (-> (mkdata {:s1 {:id :s1 :component-file branch-id}
|
||||
:s2 {:id :s2 :component-file ext-lib}
|
||||
:s3 {:id :s3}
|
||||
:s4 {:id :s4 :fill-color-ref-file branch-id
|
||||
:typography-ref-file branch-id}
|
||||
:s5 {:id :s5 :type :image :metadata {:id media-old}
|
||||
:fill-image {:id media-old}}})
|
||||
(assoc :media {media-old {:id media-old :name "img"}})
|
||||
(assoc :colors {:c1 {:id :c1 :image {:id media-old}}}))
|
||||
data' (bm/remap-refs data {branch-id main-id
|
||||
media-old media-new})
|
||||
objs (get-in data' [:pages-index :p1 :objects])]
|
||||
(t/is (= main-id (get-in objs [:s1 :component-file])))
|
||||
(t/is (= ext-lib (get-in objs [:s2 :component-file])))
|
||||
(t/is (not (contains? (get objs :s3) :component-file)))
|
||||
(t/is (= main-id (get-in objs [:s4 :fill-color-ref-file])))
|
||||
(t/is (= main-id (get-in objs [:s4 :typography-ref-file])))
|
||||
(t/is (= media-new (get-in objs [:s5 :metadata :id])))
|
||||
(t/is (= media-new (get-in objs [:s5 :fill-image :id])))
|
||||
(t/is (= {media-new {:id media-new :name "img"}} (:media data')))
|
||||
(t/is (= media-new (get-in data' [:colors :c1 :image :id])))))
|
||||
|
||||
(t/deftest merge-component-keeps-head-and-remaps-file
|
||||
;; a component created on a branch must merge into main as a VALID head:
|
||||
;; its main instance keeps component-id/main-instance and its
|
||||
;; component-file is re-pointed from the branch id to main's id (else main
|
||||
;; can't resolve the component and repair detaches it).
|
||||
(let [comp-id :c1
|
||||
mi-id :mi
|
||||
branch-id (uuid/next)
|
||||
main-id (uuid/next)
|
||||
base (mkdata {uuid/zero {:id uuid/zero :type :frame :shapes []}})
|
||||
branch (-> (mkdata {uuid/zero {:id uuid/zero :type :frame :shapes [mi-id]}
|
||||
mi-id {:id mi-id :type :frame :name "Comp" :shapes []
|
||||
:parent-id uuid/zero :frame-id uuid/zero
|
||||
:component-root true :main-instance true
|
||||
:component-id comp-id :component-file branch-id}})
|
||||
(assoc :components {comp-id {:id comp-id :name "Comp" :path ""
|
||||
:main-instance-id mi-id :main-instance-page :p1}}))
|
||||
;; emulate the RPC: canonicalize the branch's local file id to main's
|
||||
branch' (bm/remap-refs branch {branch-id main-id})
|
||||
{:keys [changes unsupported]} (bm/compute-changes base base branch')
|
||||
add-mi (first (filter #(and (= :add-obj (:type %)) (= mi-id (:id %))) changes))
|
||||
add-comp (first (filter #(= :add-component (:type %)) changes))]
|
||||
(t/is (empty? unsupported))
|
||||
;; registry row merged
|
||||
(t/is (= comp-id (:id add-comp)))
|
||||
(t/is (= mi-id (:main-instance-id add-comp)))
|
||||
;; the merged main instance is still a head, re-pointed to main's id
|
||||
(t/is (= main-id (get-in add-mi [:obj :component-file])))
|
||||
(t/is (= comp-id (get-in add-mi [:obj :component-id])))
|
||||
(t/is (true? (get-in add-mi [:obj :main-instance])))
|
||||
(t/is (true? (get-in add-mi [:obj :component-root])))))
|
||||
|
||||
(t/deftest compute-changes-shape-mod-del
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}
|
||||
:s2 {:id :s2 :name "B"}})
|
||||
branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue"}})
|
||||
{:keys [changes]} (bm/compute-changes base base branch)
|
||||
by-type (group-by :type changes)]
|
||||
(t/is (= {:type :set :attr :fill :val "blue"}
|
||||
(-> by-type :mod-obj first :operations first)))
|
||||
(t/is (= :p1 (-> by-type :mod-obj first :page-id)))
|
||||
(t/is (= :s2 (-> by-type :del-obj first :id)))))
|
||||
|
||||
(t/deftest compute-changes-shape-add-topological
|
||||
(let [objs {:root {:id :root :name "Root" :shapes []}}
|
||||
base (mkdata objs)
|
||||
branch (mkdata {:root {:id :root :name "Root" :shapes [:f1]}
|
||||
:f1 {:id :f1 :name "Frame" :parent-id :root :frame-id :root :shapes [:c1]}
|
||||
:c1 {:id :c1 :name "Child" :parent-id :f1 :frame-id :f1}})
|
||||
{:keys [changes unsupported]} (bm/compute-changes base base branch)
|
||||
adds (filterv #(= :add-obj (:type %)) changes)]
|
||||
(t/is (empty? unsupported))
|
||||
;; parent added before child
|
||||
(t/is (= [:f1 :c1] (mapv :id adds)))
|
||||
(t/is (= :root (:parent-id (first adds))))
|
||||
(t/is (= 0 (:index (first adds))))
|
||||
;; container shapes keep `:shapes` (reset to []), not dropped — frames
|
||||
;; require the key and would otherwise fail schema validation on apply
|
||||
(t/is (= [] (get-in (first adds) [:obj :shapes])))
|
||||
(t/is (contains? (:obj (first adds)) :shapes))
|
||||
;; leaf shapes that never had `:shapes` don't get the key added
|
||||
(t/is (not (contains? (:obj (second adds)) :shapes)))
|
||||
;; children membership is not re-set via :shapes ops
|
||||
(t/is (empty? (filter #(and (= :mod-obj (:type %)) (= :root (:id %))) changes)))))
|
||||
|
||||
(t/deftest compute-changes-reparent-existing-shape
|
||||
;; main: rect R loose at root. branch: R moved into a new group G.
|
||||
;; the merge must MOVE R into G (mov-objects), not :set its parent-id —
|
||||
;; otherwise R is left loose at root and duplicated by file repair.
|
||||
(let [base (mkdata {uuid/zero {:id uuid/zero :type :frame :shapes [:r]}
|
||||
:r {:id :r :name "Rect" :type :rect
|
||||
:parent-id uuid/zero :frame-id uuid/zero}})
|
||||
branch (mkdata {uuid/zero {:id uuid/zero :type :frame :shapes [:g]}
|
||||
:g {:id :g :name "Group" :type :group :shapes [:r]
|
||||
:parent-id uuid/zero :frame-id uuid/zero}
|
||||
:r {:id :r :name "Rect" :type :rect
|
||||
:parent-id :g :frame-id uuid/zero}})
|
||||
{:keys [changes unsupported]} (bm/compute-changes base base branch)
|
||||
adds (filterv #(= :add-obj (:type %)) changes)
|
||||
movs (filterv #(= :mov-objects (:type %)) changes)
|
||||
r-mod (first (filter #(and (= :mod-obj (:type %)) (= :r (:id %))) changes))]
|
||||
(t/is (empty? unsupported))
|
||||
;; G is added empty
|
||||
(t/is (= [:g] (mapv :id adds)))
|
||||
(t/is (= [] (get-in (first adds) [:obj :shapes])))
|
||||
;; R is MOVED into G at the right index (not re-added)
|
||||
(t/is (= 1 (count movs)))
|
||||
(t/is (= :g (:parent-id (first movs))))
|
||||
(t/is (= [:r] (:shapes (first movs))))
|
||||
(t/is (= 0 (:index (first movs))))
|
||||
;; the reparent is NOT emitted as a plain :set op on R
|
||||
(let [set-attrs (set (map :attr (:operations r-mod)))]
|
||||
(t/is (not (contains? set-attrs :parent-id)))
|
||||
(t/is (not (contains? set-attrs :frame-id))))))
|
||||
|
||||
|
||||
(t/deftest compute-changes-resolve-shape-conflict
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red"}})
|
||||
main (mkdata {:s1 {:id :s1 :name "A" :fill "green"}})
|
||||
branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue"}})]
|
||||
;; resolved to branch -> sets the branch value
|
||||
(let [{:keys [changes]} (bm/compute-changes base main branch {:s1 :branch})
|
||||
op (-> (group-by :type changes) :mod-obj first :operations first)]
|
||||
(t/is (= {:type :set :attr :fill :val "blue"} op)))
|
||||
;; resolved to main -> nothing emitted
|
||||
(let [{:keys [changes]} (bm/compute-changes base main branch {:s1 :main})]
|
||||
(t/is (empty? (filterv #(= :mod-obj (:type %)) changes))))
|
||||
;; unresolved -> nothing emitted (caller refuses the merge)
|
||||
(let [{:keys [changes]} (bm/compute-changes base main branch {})]
|
||||
(t/is (empty? (filterv #(= :mod-obj (:type %)) changes))))))
|
||||
|
||||
(t/deftest compute-changes-resolve-color-conflict
|
||||
(let [base (mkdata {} :colors {:c1 {:id :c1 :name "A"}})
|
||||
main (mkdata {} :colors {:c1 {:id :c1 :name "Main"}})
|
||||
branch (mkdata {} :colors {:c1 {:id :c1 :name "Branch"}})
|
||||
{:keys [changes]} (bm/compute-changes base main branch {:c1 :branch})]
|
||||
(t/is (= "Branch" (-> (group-by :type changes) :mod-color first :color :name)))))
|
||||
|
||||
;; --- per-property (per-attr) conflict resolution
|
||||
|
||||
(t/deftest conflict-resolved?-predicate
|
||||
(let [cf {:changed-attrs {:fill {:main "g" :branch "b"}
|
||||
:rx {:main 0 :branch 8}}}]
|
||||
;; whole-entity keyword always resolves
|
||||
(t/is (true? (bm/conflict-resolved? cf :main)))
|
||||
(t/is (true? (bm/conflict-resolved? cf :branch)))
|
||||
;; a full per-attr map resolves
|
||||
(t/is (true? (bm/conflict-resolved? cf {:fill :branch :rx :main})))
|
||||
;; a partial map does not
|
||||
(t/is (false? (bm/conflict-resolved? cf {:fill :branch})))
|
||||
;; nil / empty map do not
|
||||
(t/is (false? (bm/conflict-resolved? cf nil)))
|
||||
(t/is (false? (bm/conflict-resolved? cf {})))
|
||||
;; a map for a conflict without changed-attrs does not resolve
|
||||
(t/is (false? (bm/conflict-resolved? {} {:fill :branch})))))
|
||||
|
||||
(t/deftest compute-changes-resolve-shape-per-attr
|
||||
;; both fill and rx conflict; keep branch fill, keep main rx -> only the
|
||||
;; fill set op is emitted (rx resolved to main is a no-op against main)
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red" :rx 0}})
|
||||
main (mkdata {:s1 {:id :s1 :name "A" :fill "green" :rx 4}})
|
||||
branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue" :rx 8}})
|
||||
{:keys [changes]} (bm/compute-changes base main branch {:s1 {:fill :branch :rx :main}})
|
||||
ops (-> (group-by :type changes) :mod-obj first :operations)]
|
||||
(t/is (= [{:type :set :attr :fill :val "blue"}] ops))))
|
||||
|
||||
(t/deftest compute-changes-resolve-shape-per-attr-both-branch
|
||||
;; per-attr map selecting branch for every attr equals the whole `:branch`
|
||||
;; resolution
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :fill "red" :rx 0}})
|
||||
main (mkdata {:s1 {:id :s1 :name "A" :fill "green" :rx 4}})
|
||||
branch (mkdata {:s1 {:id :s1 :name "A" :fill "blue" :rx 8}})
|
||||
per (-> (bm/compute-changes base main branch {:s1 {:fill :branch :rx :branch}})
|
||||
:changes (->> (group-by :type)) :mod-obj first :operations set)
|
||||
whole (-> (bm/compute-changes base main branch {:s1 :branch})
|
||||
:changes (->> (group-by :type)) :mod-obj first :operations set)]
|
||||
(t/is (= whole per))
|
||||
(t/is (= #{{:type :set :attr :fill :val "blue"}
|
||||
{:type :set :attr :rx :val 8}} per))))
|
||||
|
||||
(t/deftest compute-changes-resolve-color-per-attr
|
||||
;; color conflict on two attrs; take branch name, keep main description ->
|
||||
;; merged color carries branch name + main description
|
||||
(let [base (mkdata {} :colors {:c1 {:id :c1 :name "A" :opacity 1}})
|
||||
main (mkdata {} :colors {:c1 {:id :c1 :name "Main" :opacity 1}})
|
||||
branch (mkdata {} :colors {:c1 {:id :c1 :name "Brnc" :opacity 0.5}})
|
||||
{:keys [changes]} (bm/compute-changes base main branch {:c1 {:name :branch :opacity :main}})
|
||||
color (-> (group-by :type changes) :mod-color first :color)]
|
||||
(t/is (= "Brnc" (:name color)))
|
||||
(t/is (= 1 (:opacity color)))))
|
||||
|
||||
;; --- tokens
|
||||
|
||||
(defn- token-lib
|
||||
[set-id token-id value]
|
||||
(-> (ctob/make-tokens-lib)
|
||||
(ctob/add-set (ctob/make-token-set {:id set-id :name "core"}))
|
||||
(ctob/add-token set-id (ctob/make-token {:id token-id
|
||||
:name "color.primary"
|
||||
:type :color
|
||||
:value value}))))
|
||||
|
||||
(defn- with-tokens
|
||||
[lib]
|
||||
(assoc (mkdata {}) :tokens-lib lib))
|
||||
|
||||
(t/deftest compute-changes-token-value-roundtrip
|
||||
(let [sid (uuid/next)
|
||||
tid (uuid/next)
|
||||
base-lib (token-lib sid tid "#ff0000")
|
||||
branch-lib (ctob/update-token base-lib sid tid
|
||||
(fn [t] (ctob/make-token (assoc (into {} t) :value "#0000ff"))))
|
||||
base (with-tokens base-lib)
|
||||
main (with-tokens base-lib)
|
||||
branch (with-tokens branch-lib)
|
||||
|
||||
{:keys [changes unsupported]} (bm/compute-changes base main branch)
|
||||
set-token-change (first (filter #(= :set-token (:type %)) changes))]
|
||||
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (= sid (:set-id set-token-change)))
|
||||
(t/is (= tid (:token-id set-token-change)))
|
||||
(t/is (= "#0000ff" (-> set-token-change :attrs :value)))
|
||||
|
||||
;; round-trip: apply the change to main's tokens-lib and read the value back
|
||||
(let [data' (cfc/process-changes {:tokens-lib base-lib} changes)
|
||||
tok (ctob/get-token (:tokens-lib data') sid tid)]
|
||||
(t/is (= "#0000ff" (:value tok))))))
|
||||
|
||||
(t/deftest compute-changes-token-set-add
|
||||
(let [sid (uuid/next)
|
||||
tid (uuid/next)
|
||||
sid2 (uuid/next)
|
||||
tid2 (uuid/next)
|
||||
base-lib (token-lib sid tid "#ff0000")
|
||||
branch-lib (-> base-lib
|
||||
(ctob/add-set (ctob/make-token-set {:id sid2 :name "extra"}))
|
||||
(ctob/add-token sid2 (ctob/make-token {:id tid2
|
||||
:name "color.secondary"
|
||||
:type :color
|
||||
:value "#00ff00"})))
|
||||
{:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
|
||||
(with-tokens base-lib)
|
||||
(with-tokens branch-lib))
|
||||
by-type (group-by :type changes)]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (some #(= sid2 (:id %)) (:set-token-set by-type)))
|
||||
(t/is (some #(= tid2 (:token-id %)) (:set-token by-type)))
|
||||
;; round-trip: the new set and its token exist in main afterwards
|
||||
(let [data' (cfc/process-changes {:tokens-lib base-lib} changes)]
|
||||
(t/is (some? (ctob/get-set (:tokens-lib data') sid2)))
|
||||
(t/is (some? (ctob/get-token (:tokens-lib data') sid2 tid2))))))
|
||||
|
||||
(t/deftest compute-changes-token-set-delete
|
||||
(let [sid (uuid/next)
|
||||
tid (uuid/next)
|
||||
sid2 (uuid/next)
|
||||
tid2 (uuid/next)
|
||||
base-lib (-> (token-lib sid tid "#ff0000")
|
||||
(ctob/add-set (ctob/make-token-set {:id sid2 :name "extra"}))
|
||||
(ctob/add-token sid2 (ctob/make-token {:id tid2 :name "color.x"
|
||||
:type :color :value "#00ff00"})))
|
||||
branch-lib (ctob/delete-set base-lib sid2)
|
||||
{:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
|
||||
(with-tokens base-lib)
|
||||
(with-tokens branch-lib))
|
||||
del (filter #(and (= :set-token-set (:type %)) (nil? (:attrs %))) changes)]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (= sid2 (:id (first del))))
|
||||
(let [data' (cfc/process-changes {:tokens-lib base-lib} changes)]
|
||||
(t/is (nil? (ctob/get-set (:tokens-lib data') sid2))))))
|
||||
|
||||
(t/deftest compute-changes-token-theme-add
|
||||
(let [sid (uuid/next)
|
||||
tid (uuid/next)
|
||||
thid (uuid/next)
|
||||
base-lib (token-lib sid tid "#ff0000")
|
||||
branch-lib (ctob/add-theme base-lib (ctob/make-token-theme {:id thid :name "Dark" :group ""}))
|
||||
{:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
|
||||
(with-tokens base-lib)
|
||||
(with-tokens branch-lib))
|
||||
thm (filter #(= :set-token-theme (:type %)) changes)]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (= thid (:id (first thm))))
|
||||
(let [data' (cfc/process-changes {:tokens-lib base-lib} changes)]
|
||||
(t/is (some? (ctob/get-theme (:tokens-lib data') thid))))))
|
||||
|
||||
;; --- pages
|
||||
|
||||
(t/deftest compute-changes-page-add
|
||||
(let [p1 {:id :p1 :name "Page 1" :objects {}}
|
||||
p2 {:id :p2 :name "Page 2" :objects {:s1 {:id :s1 :name "A"}}}
|
||||
base (pages-data {:p1 p1} [:p1])
|
||||
branch (pages-data {:p1 p1 :p2 p2} [:p1 :p2])
|
||||
{:keys [changes unsupported]} (bm/compute-changes base base branch)
|
||||
add (first (filter #(= :add-page (:type %)) changes))]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (= :p2 (-> add :page :id)))
|
||||
(t/is (contains? (-> add :page :objects) :s1))))
|
||||
|
||||
(t/deftest compute-changes-page-delete
|
||||
(let [p1 {:id :p1 :name "Page 1" :objects {}}
|
||||
p2 {:id :p2 :name "Page 2" :objects {}}
|
||||
base (pages-data {:p1 p1 :p2 p2} [:p1 :p2])
|
||||
branch (pages-data {:p1 p1} [:p1])
|
||||
{:keys [changes]} (bm/compute-changes base base branch)
|
||||
del (first (filter #(= :del-page (:type %)) changes))]
|
||||
(t/is (= :p2 (:id del)))))
|
||||
|
||||
(t/deftest compute-changes-page-rename
|
||||
(let [base (pages-data {:p1 {:id :p1 :name "Page 1" :objects {}}} [:p1])
|
||||
branch (assoc-in base [:pages-index :p1 :name] "Renamed")
|
||||
{:keys [changes unsupported]} (bm/compute-changes base base branch)
|
||||
mod (first (filter #(= :mod-page (:type %)) changes))]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (= :p1 (:id mod)))
|
||||
(t/is (= "Renamed" (:name mod)))))
|
||||
|
||||
(t/deftest compute-changes-page-order
|
||||
(let [p1 (uuid/next) p2 (uuid/next) p3 (uuid/next)
|
||||
pi {p1 {:id p1 :name "P1" :objects {}}
|
||||
p2 {:id p2 :name "P2" :objects {}}
|
||||
p3 {:id p3 :name "P3" :objects {}}}
|
||||
base (pages-data pi [p1 p2 p3])
|
||||
branch (pages-data pi [p3 p1 p2])
|
||||
{:keys [changes unsupported]} (bm/compute-changes base base branch)]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (seq (filter #(= :mov-page (:type %)) changes)))
|
||||
;; round-trip: main ends up in branch's page order
|
||||
(let [data' (cfc/process-changes base changes)]
|
||||
(t/is (= [p3 p1 p2] (:pages data'))))))
|
||||
|
||||
(t/deftest compute-changes-page-guide
|
||||
(let [pid (uuid/next)
|
||||
gid (uuid/next)
|
||||
guide {:id gid :axis :x :position 100}
|
||||
base (pages-data {pid {:id pid :name "P" :objects {} :guides {}}} [pid])
|
||||
branch (assoc-in base [:pages-index pid :guides gid] guide)
|
||||
{:keys [changes unsupported]} (bm/compute-changes base base branch)
|
||||
ch (first (filter #(= :set-guide (:type %)) changes))]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (= gid (:id ch)))
|
||||
(t/is (= pid (:page-id ch)))
|
||||
;; round-trip: the guide is present on main afterwards
|
||||
(let [data' (cfc/process-changes base changes)]
|
||||
(t/is (contains? (get-in data' [:pages-index pid :guides]) gid)))))
|
||||
|
||||
(t/deftest compute-changes-page-attrs-unsupported
|
||||
;; an unknown/unhandled residual page attr must be refused, never dropped
|
||||
(let [base (pages-data {:p1 {:id :p1 :name "Page 1" :objects {}}} [:p1])
|
||||
branch (assoc-in base [:pages-index :p1 :some-future-attr] {:x 1})
|
||||
{:keys [unsupported]} (bm/compute-changes base base branch)]
|
||||
(t/is (contains? unsupported :page-attrs))))
|
||||
|
||||
(t/deftest compute-changes-page-default-grid
|
||||
(let [pid (uuid/next)
|
||||
grid {:size 16 :color {:color "#cccccc" :opacity 0.5}}
|
||||
base (pages-data {pid {:id pid :name "P" :objects {} :default-grids {}}} [pid])
|
||||
branch (assoc-in base [:pages-index pid :default-grids :square] grid)
|
||||
{:keys [changes unsupported]} (bm/compute-changes base base branch)
|
||||
ch (first (filter #(= :set-default-grid (:type %)) changes))]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (= pid (:page-id ch)))
|
||||
(t/is (= :square (:grid-type ch)))
|
||||
(t/is (= grid (:params ch)))
|
||||
;; round-trip: the grid is present on main afterwards
|
||||
(let [data' (cfc/process-changes base changes)]
|
||||
(t/is (= grid (get-in data' [:pages-index pid :default-grids :square]))))))
|
||||
|
||||
(t/deftest compute-changes-page-default-grid-delete
|
||||
(let [pid (uuid/next)
|
||||
grid {:size 16 :color {:color "#cccccc" :opacity 0.5}}
|
||||
base (pages-data {pid {:id pid :name "P" :objects {} :default-grids {:square grid}}} [pid])
|
||||
branch (assoc-in base [:pages-index pid :default-grids] {})
|
||||
{:keys [changes]} (bm/compute-changes base base branch)
|
||||
ch (first (filter #(= :set-default-grid (:type %)) changes))]
|
||||
(t/is (= :square (:grid-type ch)))
|
||||
(t/is (nil? (:params ch)))
|
||||
(let [data' (cfc/process-changes base changes)]
|
||||
(t/is (not (contains? (get-in data' [:pages-index pid :default-grids]) :square))))))
|
||||
|
||||
(t/deftest compute-changes-page-plugin-data
|
||||
(let [pid (uuid/next)
|
||||
base (pages-data {pid {:id pid :name "P" :objects {} :plugin-data {}}} [pid])
|
||||
branch (assoc-in base [:pages-index pid :plugin-data :my-plugin] {"foo" "bar"})
|
||||
{:keys [changes unsupported]} (bm/compute-changes base base branch)
|
||||
ch (first (filter #(= :set-plugin-data (:type %)) changes))]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (= :page (:object-type ch)))
|
||||
(t/is (= pid (:object-id ch)))
|
||||
(t/is (= :my-plugin (:namespace ch)))
|
||||
(t/is (= "foo" (:key ch)))
|
||||
(t/is (= "bar" (:value ch)))
|
||||
;; round-trip
|
||||
(let [data' (cfc/process-changes base changes)]
|
||||
(t/is (= "bar" (get-in data' [:pages-index pid :plugin-data :my-plugin "foo"]))))))
|
||||
|
||||
;; --- components
|
||||
|
||||
(t/deftest compute-changes-component-add
|
||||
(let [pid (uuid/next)
|
||||
mi (uuid/next)
|
||||
cid (uuid/next)
|
||||
base (pages-data {pid {:id pid :name "Page 1" :objects {}}} [pid])
|
||||
cmp {:id cid :name "Button" :path "" :main-instance-id mi :main-instance-page pid}
|
||||
branch (assoc base :components {cid cmp})
|
||||
{:keys [changes unsupported]} (bm/compute-changes base base branch)
|
||||
add (first (filter #(= :add-component (:type %)) changes))]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (= cid (:id add)))
|
||||
(t/is (= "Button" (:name add)))
|
||||
;; round-trip: the component row exists in main afterwards
|
||||
(let [data' (cfc/process-changes {:components {}} changes)]
|
||||
(t/is (contains? (:components data') cid)))))
|
||||
|
||||
(t/deftest compute-changes-component-delete
|
||||
(let [cmp {:id :c1 :name "Button" :path "" :main-instance-id :mi :main-instance-page :p1}
|
||||
base (pages-data {:p1 {:id :p1 :name "Page 1" :objects {}}} [:p1] :components {:c1 cmp})
|
||||
branch (assoc base :components {})
|
||||
{:keys [changes]} (bm/compute-changes base base branch)
|
||||
del (first (filter #(= :del-component (:type %)) changes))]
|
||||
(t/is (= :c1 (:id del)))))
|
||||
|
||||
(t/deftest compute-changes-component-soft-delete
|
||||
(let [cmp {:id :c1 :name "Button" :path "" :main-instance-id :mi :main-instance-page :p1}
|
||||
base (pages-data {:p1 {:id :p1 :name "Page 1" :objects {}}} [:p1] :components {:c1 cmp})
|
||||
;; soft-delete keeps the row but marks it deleted
|
||||
branch (assoc-in base [:components :c1 :deleted] true)
|
||||
{:keys [changes]} (bm/compute-changes base base branch)
|
||||
del (first (filter #(= :del-component (:type %)) changes))]
|
||||
;; surfaced as a real delete, not a no-op mod
|
||||
(t/is (= :c1 (:id del)))
|
||||
(t/is (empty? (filter #(= :mod-component (:type %)) changes)))))
|
||||
|
||||
(t/deftest compute-changes-component-variant-add
|
||||
(let [cmp {:id :c1 :name "Button" :path "" :main-instance-id :mi :main-instance-page :p1
|
||||
:variant-id :v1 :variant-properties [{:name "size" :value "lg"}]}
|
||||
base (pages-data {:p1 {:id :p1 :name "Page 1" :objects {}}} [:p1])
|
||||
branch (assoc base :components {:c1 cmp})
|
||||
{:keys [changes unsupported]} (bm/compute-changes base base branch)
|
||||
add (first (filter #(= :add-component (:type %)) changes))]
|
||||
(t/is (empty? unsupported))
|
||||
;; variant metadata is carried into the add-component change
|
||||
(t/is (= :v1 (:variant-id add)))
|
||||
(t/is (= [{:name "size" :value "lg"}] (:variant-properties add)))))
|
||||
|
||||
(t/deftest compute-changes-token-set-rename
|
||||
(let [sid (uuid/next)
|
||||
tid (uuid/next)
|
||||
base-lib (token-lib sid tid "#ff0000")
|
||||
;; rename the set but keep its token
|
||||
branch-lib (ctob/update-set base-lib sid
|
||||
(fn [s] (ctob/make-token-set {:id (ctob/get-id s)
|
||||
:name "renamed"
|
||||
:tokens (ctob/get-tokens base-lib sid)})))
|
||||
{:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
|
||||
(with-tokens base-lib)
|
||||
(with-tokens branch-lib))
|
||||
ch (first (filter #(= :set-token-set (:type %)) changes))]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (= "renamed" (-> ch :attrs :name)))
|
||||
;; round-trip: set renamed AND its token preserved
|
||||
(let [data' (cfc/process-changes {:tokens-lib base-lib} changes)]
|
||||
(t/is (= "renamed" (ctob/get-name (ctob/get-set (:tokens-lib data') sid))))
|
||||
(t/is (some? (ctob/get-token (:tokens-lib data') sid tid))))))
|
||||
|
||||
(t/deftest compute-changes-token-set-order
|
||||
(let [a (uuid/next) b (uuid/next) c (uuid/next)
|
||||
base-lib (-> (ctob/make-tokens-lib)
|
||||
(ctob/add-set (ctob/make-token-set {:id a :name "a"}))
|
||||
(ctob/add-set (ctob/make-token-set {:id b :name "b"}))
|
||||
(ctob/add-set (ctob/make-token-set {:id c :name "c"})))
|
||||
;; branch reorders to c, a, b
|
||||
branch-lib (ctob/move-set base-lib ["c"] ["c"] ["a"] false)
|
||||
{:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
|
||||
(with-tokens base-lib)
|
||||
(with-tokens branch-lib))]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (seq (filter #(= :move-token-set (:type %)) changes)))
|
||||
;; round-trip: main ends up in branch's set order
|
||||
(let [data' (cfc/process-changes {:tokens-lib base-lib} changes)
|
||||
order (mapv ctob/get-name (ctob/get-sets (:tokens-lib data')))]
|
||||
(t/is (= ["c" "a" "b"] order)))))
|
||||
|
||||
(t/deftest compute-changes-token-active-sets
|
||||
(let [sid (uuid/next)
|
||||
tid (uuid/next)
|
||||
base-lib (token-lib sid tid "#ff0000")
|
||||
;; branch toggles the "core" set active in the hidden theme
|
||||
branch-lib (ctob/toggle-set-in-theme base-lib ctob/hidden-theme-id "core")
|
||||
{:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
|
||||
(with-tokens base-lib)
|
||||
(with-tokens branch-lib))
|
||||
ch (first (filter #(= :set-token-theme (:type %)) changes))
|
||||
hidden-sets (fn [lib] (set (:sets (ctob/get-theme lib ctob/hidden-theme-id))))]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (= ctob/hidden-theme-id (:id ch)))
|
||||
;; round-trip: main's hidden theme active sets match the branch
|
||||
(let [data' (cfc/process-changes {:tokens-lib base-lib} changes)]
|
||||
(t/is (= (hidden-sets branch-lib) (hidden-sets (:tokens-lib data')))))))
|
||||
|
||||
(t/deftest compute-changes-token-active-themes
|
||||
(let [thid (uuid/next)
|
||||
base-lib (-> (ctob/make-tokens-lib)
|
||||
(ctob/add-theme (ctob/make-token-theme {:id thid :name "Dark" :group ""})))
|
||||
branch-lib (ctob/activate-theme base-lib thid)
|
||||
{:keys [changes unsupported]} (bm/compute-changes (with-tokens base-lib)
|
||||
(with-tokens base-lib)
|
||||
(with-tokens branch-lib))
|
||||
ch (first (filter #(= :set-active-token-themes (:type %)) changes))]
|
||||
(t/is (empty? unsupported))
|
||||
(t/is (some? ch))
|
||||
;; round-trip: active theme paths match the branch
|
||||
(let [data' (cfc/process-changes {:tokens-lib base-lib} changes)]
|
||||
(t/is (= (set (ctob/get-active-theme-paths branch-lib))
|
||||
(set (ctob/get-active-theme-paths (:tokens-lib data'))))))))
|
||||
|
||||
;;; --- Regression tests: classification on stripped shapes ---
|
||||
|
||||
(t/deftest no-false-conflict-when-both-add-children-to-same-frame
|
||||
;; both sides adding a child to the same frame only diverge on the
|
||||
;; frame's :shapes (derived) — that must NOT be a conflict on the frame.
|
||||
(let [base (mkdata {:f1 {:id :f1 :name "Frame" :type :frame :shapes [:a]}
|
||||
:a {:id :a :name "A"}})
|
||||
main (mkdata {:f1 {:id :f1 :name "Frame" :type :frame :shapes [:a :m]}
|
||||
:a {:id :a :name "A"}
|
||||
:m {:id :m :name "M" :parent-id :f1 :frame-id :f1}})
|
||||
branch (mkdata {:f1 {:id :f1 :name "Frame" :type :frame :shapes [:a :b]}
|
||||
:a {:id :a :name "A"}
|
||||
:b {:id :b :name "B" :parent-id :f1 :frame-id :f1}})
|
||||
r (bm/compute-merge base main branch :branch->main)]
|
||||
(t/is (= [] (:conflicts r)))
|
||||
(t/is (= #{:b} (set (map :id (:changes r)))))))
|
||||
|
||||
(t/deftest no-delete-conflict-on-touched-only-main-change
|
||||
;; branch deletes a shape; main only flipped its :touched (library sync,
|
||||
;; not a user change) — that must stay a CLEAN delete, not a conflict.
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :touched nil}})
|
||||
main (mkdata {:s1 {:id :s1 :name "A" :touched #{:fill-group}}})
|
||||
branch (mkdata {})
|
||||
r (bm/compute-merge base main branch :branch->main)]
|
||||
(t/is (= [] (:conflicts r)))
|
||||
(t/is (= [:deleted] (mapv :status (:changes r))))))
|
||||
|
||||
(t/deftest compute-changes-restores-shape-on-modify-delete-branch
|
||||
;; main deleted a frame the branch modified; resolving :branch must
|
||||
;; re-add the frame AND its surviving child (main's delete was recursive).
|
||||
(let [base (mkdata {:f1 {:id :f1 :name "Frame" :type :frame :shapes [:c1]
|
||||
:parent-id uuid/zero :frame-id uuid/zero}
|
||||
:c1 {:id :c1 :name "Child" :parent-id :f1 :frame-id :f1}})
|
||||
main (mkdata {})
|
||||
branch (mkdata {:f1 {:id :f1 :name "Frame RENAMED" :type :frame :shapes [:c1]
|
||||
:parent-id uuid/zero :frame-id uuid/zero}
|
||||
:c1 {:id :c1 :name "Child" :parent-id :f1 :frame-id :f1}})
|
||||
{:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
|
||||
{:keys [changes]} (bm/compute-changes base main branch {:f1 :branch})
|
||||
adds (into {} (comp (filter #(= :add-obj (:type %)))
|
||||
(map (juxt :id identity)))
|
||||
changes)]
|
||||
(t/is (= [:modify-delete] (mapv :reason conflicts)))
|
||||
;; both the frame and its child come back, parent before child
|
||||
(t/is (contains? adds :f1))
|
||||
(t/is (contains? adds :c1))
|
||||
(let [add-order (mapv :id (filter #(= :add-obj (:type %)) changes))
|
||||
idx (fn [id] (first (keep-indexed (fn [i v] (when (= v id) i)) add-order)))]
|
||||
(t/is (< (idx :f1) (idx :c1))))
|
||||
(t/is (= "Frame RENAMED" (get-in adds [:f1 :obj :name])))))
|
||||
|
||||
(t/deftest compute-changes-modify-delete-resolved-main-drops-shape
|
||||
;; same conflict resolved to :main -> nothing is re-added
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A"}})
|
||||
main (mkdata {})
|
||||
branch (mkdata {:s1 {:id :s1 :name "A2"}})
|
||||
{:keys [changes]} (bm/compute-changes base main branch {:s1 :main})]
|
||||
(t/is (= [] (filterv #(= :add-obj (:type %)) changes)))))
|
||||
|
||||
;;; --- Regression tests: order resolution ids ---
|
||||
|
||||
(t/deftest page-order-conflict-id-and-resolution
|
||||
;; both sides reordered pages differently: the conflict id must be
|
||||
;; :page-order (not :order) and resolving it to :branch must apply
|
||||
(let [p1 (uuid/next) p2 (uuid/next) p3 (uuid/next)
|
||||
pi {p1 {:id p1 :name "P1" :objects {}}
|
||||
p2 {:id p2 :name "P2" :objects {}}
|
||||
p3 {:id p3 :name "P3" :objects {}}}
|
||||
base (pages-data pi [p1 p2 p3])
|
||||
main (pages-data pi [p2 p1 p3])
|
||||
branch (pages-data pi [p3 p1 p2])
|
||||
{:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
|
||||
conflict (first (filter #(= :page-order (:kind %)) conflicts))
|
||||
{:keys [changes]} (bm/compute-changes base main branch {:page-order :branch})]
|
||||
(t/is (= :page-order (:id conflict)))
|
||||
(t/is (seq (filter #(= :mov-page (:type %)) changes)))
|
||||
(let [data' (cfc/process-changes (pages-data pi [p2 p1 p3]) changes)]
|
||||
(t/is (= [p3 p1 p2] (:pages data'))))))
|
||||
|
||||
;;; --- Regression tests: page delete-vs-modify ---
|
||||
|
||||
(t/deftest page-deleted-in-branch-edited-in-main-conflicts
|
||||
(let [p1 (uuid/next) p2 (uuid/next) s1 (uuid/next)
|
||||
mk (fn [p2-page]
|
||||
{:pages-index (cond-> {p1 {:id p1 :name "P1" :objects {}}}
|
||||
p2-page (assoc p2 p2-page))
|
||||
:pages (if p2-page [p1 p2] [p1])
|
||||
:colors {} :typographies {} :media {} :components {}})
|
||||
base (mk {:id p2 :name "P2" :objects {}})
|
||||
main (mk {:id p2 :name "P2" :objects {s1 {:id s1 :name "New"}}})
|
||||
branch (mk nil)
|
||||
{:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
|
||||
conflict (first (filter #(= :page (:kind %)) conflicts))]
|
||||
(t/is (some? conflict))
|
||||
(t/is (= :delete-modify (:reason conflict)))
|
||||
(t/is (= p2 (:id conflict)))
|
||||
;; resolved to :branch -> the page is deleted
|
||||
(let [{:keys [changes]} (bm/compute-changes base main branch {p2 :branch})]
|
||||
(t/is (= [p2] (mapv :id (filter #(= :del-page (:type %)) changes)))))
|
||||
;; resolved to :main -> the page is kept, nothing emitted for it
|
||||
(let [{:keys [changes]} (bm/compute-changes base main branch {p2 :main})]
|
||||
(t/is (= [] (filterv #(= :del-page (:type %)) changes))))))
|
||||
|
||||
(t/deftest page-deleted-in-main-edited-in-branch-restores
|
||||
(let [p1 (uuid/next) p2 (uuid/next) s1 (uuid/next)
|
||||
pi-with (fn [objects]
|
||||
{p1 {:id p1 :name "P1" :objects {}}
|
||||
p2 {:id p2 :name "P2" :objects objects}})
|
||||
base (pages-data (pi-with {}) [p1 p2])
|
||||
main (pages-data {p1 {:id p1 :name "P1" :objects {}}} [p1])
|
||||
branch (pages-data (pi-with {s1 {:id s1 :name "New"}}) [p1 p2])
|
||||
{:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
|
||||
conflict (first (filter #(= :page (:kind %)) conflicts))]
|
||||
(t/is (= :modify-delete (:reason conflict)))
|
||||
;; resolved to :branch -> the full branch page is re-added
|
||||
(let [{:keys [changes]} (bm/compute-changes base main branch {p2 :branch})
|
||||
add (first (filter #(= :add-page (:type %)) changes))]
|
||||
(t/is (= p2 (get-in add [:page :id])))
|
||||
(t/is (contains? (get-in add [:page :objects]) s1)))))
|
||||
|
||||
(t/deftest page-clean-delete-still-clean
|
||||
;; branch deletes a page main did not touch -> clean delete, no conflict
|
||||
(let [p1 (uuid/next) p2 (uuid/next)
|
||||
pi {p1 {:id p1 :name "P1" :objects {}}
|
||||
p2 {:id p2 :name "P2" :objects {}}}
|
||||
base (pages-data pi [p1 p2])
|
||||
branch (pages-data {p1 {:id p1 :name "P1" :objects {}}} [p1])
|
||||
{:keys [conflicts]} (bm/compute-merge base base branch :branch->main)
|
||||
{:keys [changes]} (bm/compute-changes base base branch)]
|
||||
(t/is (= [] conflicts))
|
||||
(t/is (= [p2] (mapv :id (filter #(= :del-page (:type %)) changes))))))
|
||||
|
||||
;;; --- Regression tests: token-set delete-vs-modify ---
|
||||
|
||||
(t/deftest token-set-deleted-in-branch-edited-in-main-conflicts
|
||||
(let [sid (uuid/next) tid (uuid/next)
|
||||
base-lib (token-lib sid tid "#ff0000")
|
||||
;; main edits a token inside the set the branch deletes
|
||||
main-lib (ctob/update-token base-lib sid tid
|
||||
(fn [t] (ctob/make-token (assoc (into {} t) :value "#00ff00"))))
|
||||
branch-lib (ctob/delete-set base-lib sid)
|
||||
base (with-tokens base-lib)
|
||||
main (with-tokens main-lib)
|
||||
branch (with-tokens branch-lib)
|
||||
{:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
|
||||
conflict (first (filter #(= :token-set (:kind %)) conflicts))]
|
||||
(t/is (some? conflict))
|
||||
(t/is (= :delete-modify (:reason conflict)))
|
||||
;; no redundant token-level conflicts for the deleted set
|
||||
(t/is (= [] (filterv #(= :token (:kind %)) conflicts)))
|
||||
;; resolved to :branch -> set deleted; to :main -> set (and token) kept
|
||||
(let [{:keys [changes]} (bm/compute-changes base main branch {sid :branch})]
|
||||
(t/is (some #(and (= :set-token-set (:type %)) (nil? (:attrs %))) changes)))
|
||||
(let [{:keys [changes]} (bm/compute-changes base main branch {sid :main})]
|
||||
(t/is (= [] (filterv #(= :set-token-set (:type %)) changes)))
|
||||
(t/is (= [] (filterv #(= :set-token (:type %)) changes))))))
|
||||
|
||||
(t/deftest token-set-deleted-in-main-edited-in-branch-restores
|
||||
(let [sid (uuid/next) tid (uuid/next)
|
||||
base-lib (token-lib sid tid "#ff0000")
|
||||
main-lib (ctob/delete-set base-lib sid)
|
||||
branch-lib (ctob/update-token base-lib sid tid
|
||||
(fn [t] (ctob/make-token (assoc (into {} t) :value "#0000ff"))))
|
||||
base (with-tokens base-lib)
|
||||
main (with-tokens main-lib)
|
||||
branch (with-tokens branch-lib)
|
||||
{:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
|
||||
conflict (first (filter #(= :token-set (:kind %)) conflicts))]
|
||||
(t/is (= :modify-delete (:reason conflict)))
|
||||
;; resolved to :branch -> the set is re-created WITH its tokens
|
||||
(let [{:keys [changes]} (bm/compute-changes base main branch {sid :branch})
|
||||
restore (first (filter #(and (= :set-token-set (:type %)) (some? (:attrs %))) changes))
|
||||
data' (cfc/process-changes (with-tokens main-lib) changes)]
|
||||
(t/is (some? restore))
|
||||
(t/is (seq (get-in restore [:attrs :tokens])))
|
||||
(t/is (= "#0000ff" (:value (ctob/get-token (:tokens-lib data') sid tid)))))))
|
||||
|
||||
(t/deftest per-attr-geometry-resolution-carries-caches
|
||||
;; a modify-modify conflict where :x is resolved to :branch must also
|
||||
;; bring the branch's :selrect/:points so geometry stays consistent
|
||||
(let [base (mkdata {:s1 {:id :s1 :name "A" :x 0
|
||||
:selrect {:x 0} :points [{:x 0}]}})
|
||||
main (mkdata {:s1 {:id :s1 :name "MAIN" :x 0
|
||||
:selrect {:x 0} :points [{:x 0}]}})
|
||||
branch (mkdata {:s1 {:id :s1 :name "A" :x 100
|
||||
:selrect {:x 100} :points [{:x 100}]}})
|
||||
{:keys [changes]} (bm/compute-changes base main branch
|
||||
{:s1 {:x :branch :name :main}})
|
||||
ops (->> changes
|
||||
(filter #(= :mod-obj (:type %)))
|
||||
first :operations
|
||||
(map (juxt :attr :val))
|
||||
(into {}))]
|
||||
(t/is (= 100 (get ops :x)))
|
||||
(t/is (= {:x 100} (get ops :selrect)))
|
||||
(t/is (not (contains? ops :name)))))
|
||||
|
||||
(t/deftest shape-conflicts-carry-full-shapes
|
||||
;; classification runs on STRIPPED shapes, but the conflict payload must
|
||||
;; carry the FULL sides: the resolution UI renders real shape previews
|
||||
;; that need :selrect/:points (frame-clip-def asserts (rect? selrect))
|
||||
(let [selrect {:x 0 :y 0 :width 10 :height 10 :x1 0 :y1 0 :x2 10 :y2 10}
|
||||
mk (fn [name] {:id :s1 :name name :type :frame
|
||||
:selrect selrect :points [{:x 0 :y 0}] :shapes []})
|
||||
base (mkdata {:s1 (mk "A")})
|
||||
main (mkdata {:s1 (mk "MAIN")})
|
||||
branch (mkdata {:s1 (mk "BRANCH")})
|
||||
{:keys [conflicts]} (bm/compute-merge base main branch :branch->main)
|
||||
c (first conflicts)]
|
||||
(t/is (= :modify-modify (:reason c)))
|
||||
;; changed-attrs stay noise-free (no derived attrs)
|
||||
(t/is (= #{:name} (set (keys (:changed-attrs c)))))
|
||||
;; but every side carries the full shape, caches included
|
||||
(t/is (= selrect (get-in c [:base :selrect])))
|
||||
(t/is (= selrect (get-in c [:main :selrect])))
|
||||
(t/is (= selrect (get-in c [:branch :selrect])))
|
||||
(t/is (= "MAIN" (get-in c [:main :name])))
|
||||
(t/is (= "BRANCH" (get-in c [:branch :name])))))
|
||||
597
docs/technical-guide/developer/branching.md
Normal file
@ -0,0 +1,597 @@
|
||||
---
|
||||
title: 3.12. Branching
|
||||
desc: Learn how Penpot file branching works — isolated file copies, a three-way entity-level merge engine, conflict resolution, the RPC API and the workspace UI. View Penpot's technical guide for self-hosting, configuration and developer insights.
|
||||
---
|
||||
|
||||
# File branching
|
||||
|
||||
Branching lets a user take an isolated, editable copy of a file (a **branch**),
|
||||
work on it without touching the original (**main**), and later bring the changes
|
||||
back through a **merge**. It is conceptually the same idea Git offers for source
|
||||
code, applied to design files: the workspace even reuses a small set of `git-*`
|
||||
icons (`git-branch`, `git-branch-plus`, `git-commit`, `git-merge`) to make the
|
||||
mental model obvious.
|
||||
|
||||
The whole feature is gated behind the `:branching` product flag and was added in
|
||||
the `2.16` API generation (all the RPC methods carry `::doc/added "2.16"`).
|
||||
|
||||
## Conceptual model
|
||||
|
||||
Branching always reasons about **three** versions of the same file:
|
||||
|
||||
* **main** — the source file, the one everybody else keeps editing.
|
||||
* **branch** — the isolated copy the user works on.
|
||||
* **merge base** — a frozen snapshot of main taken at the exact moment the
|
||||
branch was created.
|
||||
|
||||
Comparing two versions is not enough to decide who is right when a value
|
||||
differs. With the third (the base) the engine can tell *who* changed *what*:
|
||||
|
||||
* if main still equals the base, then the **branch** is the one that changed it;
|
||||
* if the branch still equals the base, then **main** is the one that changed it;
|
||||
* if **both** changed it, to different values, that is a **conflict** and a
|
||||
human must pick a side.
|
||||
|
||||
This is the classic **three-way merge** algorithm, applied at the granularity of
|
||||
individual *entities* (a color, a shape, a typography, a token, …) rather than to
|
||||
the file blob as a whole.
|
||||
|
||||
A second, subtler property makes the whole thing possible: a branch is a copy
|
||||
that **preserves the internal `:data` ids** of every entity. Because "color X" in
|
||||
the branch and "color X" in main share the same id, the engine can pair them up
|
||||
and diff them. Without stable ids an id-based merge would be impossible.
|
||||
|
||||
## Where the code lives
|
||||
|
||||
The feature spans the three subsystems. The core, shared logic lives in
|
||||
`common` so it can run identically in the JVM and in JavaScript test runners.
|
||||
|
||||
```text
|
||||
common/src/app/common/files/branch_merge.cljc ; three-way diff + merge->changes engine
|
||||
backend/src/app/rpc/commands/files_branch.clj ; RPC commands (create/list/diff/merge/update)
|
||||
backend/src/app/migrations/sql/0155-add-file-branch-table.sql ; schema (file_branch table + is_branch)
|
||||
frontend/src/app/main/data/workspace/branches.cljs ; frontend state + RPC calls (ptk events)
|
||||
frontend/src/app/main/ui/workspace/sidebar/branches.cljs ; panels & dialogs
|
||||
frontend/src/app/main/ui/dashboard/branches_popover.cljs ; dashboard file-card popover
|
||||
```
|
||||
|
||||
Tests:
|
||||
|
||||
```text
|
||||
common/test/common_tests/files_branch_merge_test.cljc ; unit tests for the diff/merge engine
|
||||
backend/test/backend_tests/rpc_file_branch_test.clj ; integration tests for the RPC commands
|
||||
```
|
||||
|
||||
The branching commands closely mirror the patterns in
|
||||
`app.rpc.commands.files-snapshot`, and reuse the production change pipeline
|
||||
(`app.common.files.changes/process-changes`), the file snapshot machinery
|
||||
(`app.features.file-snapshots`) and the file-duplication pipeline
|
||||
(`app.rpc.commands.management/duplicate-file`).
|
||||
|
||||
## Data model
|
||||
|
||||
The migration adds one column to `file` and one new table.
|
||||
|
||||
```sql
|
||||
ALTER TABLE file
|
||||
ADD COLUMN is_branch boolean NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE file_branch (
|
||||
id uuid PRIMARY KEY,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
|
||||
branch_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
|
||||
source_file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
|
||||
|
||||
base_snapshot_id uuid NULL,
|
||||
|
||||
-- main's revn when the base was (re)positioned
|
||||
base_revn bigint NOT NULL,
|
||||
-- the BRANCH file's revn at the same moment (independent counter)
|
||||
base_branch_revn bigint NOT NULL DEFAULT 0,
|
||||
|
||||
created_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
|
||||
|
||||
name text NOT NULL,
|
||||
description text NULL,
|
||||
|
||||
status text NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'merged', 'archived')),
|
||||
|
||||
merged_at timestamptz NULL,
|
||||
merged_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL,
|
||||
deleted_at timestamptz NULL
|
||||
);
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
* **`is_branch`** on `file` flags the branch copy so it is hidden from the
|
||||
normal project/team file listings, the dashboard **search**, the team
|
||||
**trash** (and its restore path), and project **duplication/binfile
|
||||
export** — you don't want every branch mixed in with real files, nor
|
||||
restorable zombies without their `file_branch` row. When the *source*
|
||||
file is deleted, the `delete-object` cascade propagates the logical
|
||||
deletion to its branches (they would otherwise be unreachable forever).
|
||||
* **`branch_file_id`** points to the file row that *is* the branch copy;
|
||||
**`source_file_id`** points to main. A `UNIQUE` index on `branch_file_id`
|
||||
guarantees a file can be the branch of at most one source.
|
||||
* **`base_snapshot_id`** references the merge-base snapshot. **`base_revn`**
|
||||
records MAIN's revision number and **`base_branch_revn`** the BRANCH file's
|
||||
one at the moment the base was (re)positioned — the two files carry
|
||||
independent revn counters, so each side's cheap "did anything change?" gate
|
||||
compares against its own counter (see *ahead/behind* below). The migration
|
||||
is intentionally idempotent (`IF NOT EXISTS` everywhere) so instances that
|
||||
applied it under its old development name pick up new columns on re-apply.
|
||||
* **`status`** moves through `open` → `merged` (after a successful integration)
|
||||
or `open` ↔ `archived` (a reversible "hide it" state). Logical deletion is
|
||||
handled the standard Penpot way via `deleted_at` rather than a hard delete.
|
||||
|
||||
## Lifecycle of a branch
|
||||
|
||||
### Creating a branch
|
||||
|
||||
The `::create-file-branch` command (`files_branch.clj`) refuses to branch a
|
||||
branch (`:cannot-branch-a-branch`) and runs everything inside a single
|
||||
transaction (with `SET CONSTRAINTS ALL DEFERRED`, because the new file and
|
||||
its branch row reference each other) **under the file's advisory lock**
|
||||
(`db/xact-lock!`, the same one update-file takes), so the base snapshot and
|
||||
the duplicated copy are guaranteed to capture the SAME state of main — a
|
||||
concurrent save between the two would otherwise make the base differ from
|
||||
the branch's initial content and manufacture phantom diffs later:
|
||||
|
||||
1. **Materialize the merge base.** `fsnap/create!` takes a *system* snapshot of
|
||||
main labelled `branch-base/<name>`, with a `deleted-at` 3650 days in the
|
||||
future. The far-future deletion date keeps the snapshot from being pruned by
|
||||
the snapshot GC while the branch is open — the diff needs it for as long as
|
||||
the branch lives. The pin is **released** (rescheduled with the team's
|
||||
normal deletion delay) whenever the branch is merged, deleted or its base
|
||||
is repositioned, so closed branches do not leak multi-year snapshots. If
|
||||
the snapshot can ever not be resolved, merge/update/diff **refuse** with
|
||||
`:base-snapshot-missing` — silently falling back to main would degrade the
|
||||
three-way merge into "branch overwrites main" without conflicts.
|
||||
2. **Duplicate main into the branch file.** `mgmt/duplicate-file` is run with
|
||||
`bfc/*state*` bound to `{:index {file-id branch-id}}` and
|
||||
`:reset-shared-flag true`. The duplicate pipeline remaps the
|
||||
file/media/library ids but **preserves the internal `:data` ids** — the
|
||||
precondition for the id-based merge.
|
||||
3. **Mark the new file as a branch** (`is_branch = true`) so it stays out of the
|
||||
listings.
|
||||
4. **Insert the `file_branch` row** with `status = 'open'`, the base snapshot id
|
||||
and `base_revn = (:revn file)`.
|
||||
|
||||
Before any of this it enforces edition permissions on main and checks two quotas:
|
||||
`::quotes/branches-per-file` and `::quotes/branches-per-team` (configurable via
|
||||
`PENPOT_QUOTES_BRANCHES_PER_FILE` / `PENPOT_QUOTES_BRANCHES_PER_TEAM`,
|
||||
registered in `app.config`).
|
||||
|
||||
On the frontend, `create-branch` (in `branches.cljs`) first force-persists any
|
||||
pending local changes, then calls `:create-file-branch`, so the branch starts
|
||||
from a fully-saved state of main.
|
||||
|
||||
### Working on a branch
|
||||
|
||||
Once created, main and the branch are two live, independent files. When a branch
|
||||
file is opened, the frontend fetches its context via `:get-file-branch-info`
|
||||
(events `fetch-branches` / `fetch-branch-context`) and shows a
|
||||
`branch-context-banner*` at the top of the workspace indicating "you are on a
|
||||
branch", together with its *ahead* / *behind* counts and the merge / update
|
||||
actions.
|
||||
|
||||
### Ahead / behind counts
|
||||
|
||||
`::get-file-branches` lists a file's branches and annotates each with
|
||||
entity-level `ahead` / `behind` / `conflicts` counts — the same numbers the
|
||||
compare dialog shows.
|
||||
|
||||
Computing these means running the full three-way diff, which is expensive, so
|
||||
there is a two-level gate (`branch-diff-counts`):
|
||||
|
||||
* Each file carries a monotonically increasing `revn`. The command first
|
||||
computes cheap revn deltas: `ahead-revn = branch_revn - base_branch_revn`,
|
||||
`behind-revn = source_revn - base_revn` (`revn-deltas`) — each side against
|
||||
its OWN counter; mixing them breaks after an update-from-main repositions
|
||||
the base.
|
||||
* If **both deltas are zero**, the branch is in sync and the diff is skipped
|
||||
entirely (`[0 0 0]`).
|
||||
* Otherwise main's `:data` is realized **once** and shared across all branches
|
||||
of the same source. `ahead` is the count of clean changes in the
|
||||
`:branch->main` direction, `behind` the count in the reverse direction, and
|
||||
`conflicts` is only non-zero when *both* sides diverged. Only **open**
|
||||
branches are diffed (merged/archived ones report `[0 0 0]`), and a listing
|
||||
never fails because of one corrupted branch: count errors degrade to zeros
|
||||
with a warning (the merge itself still refuses loudly).
|
||||
|
||||
## The three-way diff engine
|
||||
|
||||
`app.common.files.branch-merge` is the heart of the feature. It is a pure,
|
||||
side-effect-free engine: it reads three `:data` blobs and returns a serializable
|
||||
summary. It never mutates anything.
|
||||
|
||||
### `three-way-entities` — the per-entity truth table
|
||||
|
||||
`three-way-entities` diffs one indexed collection (`id -> value`) across
|
||||
base / theirs / ours and returns `{:changes [...] :conflicts [...]}`. For each id
|
||||
it applies this decision table (`theirs` is main, `ours` is the branch, in the
|
||||
default `:branch->main` direction):
|
||||
|
||||
| Situation | Outcome |
|
||||
| ------------------------------------------- | ---------------------------------------- |
|
||||
| deleted in both | nothing |
|
||||
| deleted in branch, main intact (`= base`) | clean `:deleted` |
|
||||
| deleted in main, branch intact (`= base`) | nothing (already gone in main) |
|
||||
| deleted in branch / modified in main | **conflict** `:delete-modify` |
|
||||
| modified in branch / deleted in main | **conflict** `:modify-delete` |
|
||||
| new in branch only | clean `:added` |
|
||||
| new in main only | nothing |
|
||||
| added on both, same value | nothing |
|
||||
| added on both, different value | **conflict** `:add-add` |
|
||||
| present in all three, branch `= base` | nothing (main wins) |
|
||||
| present in all three, main `= base` | clean `:modified` (branch wins) |
|
||||
| present in all three, branch `= main` | nothing (converged) |
|
||||
| present in all three, all three differ | **conflict** `:modify-modify` |
|
||||
|
||||
So there are exactly **four conflict reasons**: `:delete-modify`,
|
||||
`:modify-delete`, `:add-add` and `:modify-modify`. Every conflict descriptor
|
||||
carries `:base`, `:main` and `:branch` values plus a `:changed-attrs` map
|
||||
(attribute → `{:main v :branch v}`) so the UI can render a side-by-side diff.
|
||||
|
||||
Shapes are diffed **stripped** of their derived attrs (`strip-shapes`:
|
||||
`:shapes`, `:touched`, `:selrect`, `:points`) and the page root frame
|
||||
(`uuid/zero`) is ignored. Stripping happens at CLASSIFICATION level, not just
|
||||
display: this is what prevents false conflicts — both sides adding children to
|
||||
the same frame only differ on `:shapes`, and a library sync on main only flips
|
||||
`:touched`; neither is a user change, so neither may turn a clean branch edit
|
||||
into a modify-modify/delete conflict. Containment (`:parent-id`/`:frame-id`)
|
||||
is deliberately kept: reparenting is a real, user-meaningful change. The same
|
||||
principle strips the `:modified-at` bookkeeping stamp from colors,
|
||||
typographies and components (`strip-modified-at`): the apply pipeline
|
||||
re-`touch`es them, so after an update-from-main the copied entities would
|
||||
otherwise differ from main forever.
|
||||
|
||||
Pages and token sets get a **content-valued presence pass** (`presence-only`):
|
||||
presence is diffed over the normalized content instead of a bare marker, so
|
||||
deleting a page or token set the other side edited raises a proper
|
||||
`:delete-modify`/`:modify-delete` conflict instead of silently dropping those
|
||||
edits; content-only modifications are filtered out (the granular passes own
|
||||
them) and the conflict payloads are slimmed for the wire.
|
||||
|
||||
### `compute-merge` — across the whole file
|
||||
|
||||
`compute-merge` runs `three-way-entities` over every mergeable collection in the
|
||||
file and concatenates the results:
|
||||
|
||||
* **colors**, **typographies**, **components** (row metadata), **media** —
|
||||
straightforward indexed collections.
|
||||
* **pages** (`diff-pages`) — page add/delete, page metadata
|
||||
(name/background/grid), per-page guides, flows, default-grids, plugin-data,
|
||||
and the shapes (`:objects`) of every common page. Residual page attributes
|
||||
that no pass handles are surfaced under the `:page-attrs` kind so they are
|
||||
*refused* rather than silently dropped.
|
||||
* **tokens** (`diff-tokens`) — token-set add/delete, set rename, set order,
|
||||
themes, active theme paths, active-set toggles, and per-token values.
|
||||
|
||||
The result is `{:changes [...] :conflicts [...] :stats {...}}`, where `:stats`
|
||||
counts added / modified / deleted / conflicts. The direction argument swaps which
|
||||
side is "theirs" and which is "ours": `:branch->main` (merge / compare) vs
|
||||
`:main->branch` (update from main).
|
||||
|
||||
### `remap-refs` — the local-reference fix-up
|
||||
|
||||
A branch is a file copy with its own ids: `duplicate-file` re-points every
|
||||
**local** file reference (`:component-file`, `:fill-color-ref-file`,
|
||||
`:stroke-color-ref-file`, `:typography-ref-file`, shadow/grid `:file-id`) to
|
||||
the branch file id, and gives the branch's `file_media_object` rows FRESH ids
|
||||
(relinking shape `:metadata`/`:fill-image`/`:stroke-image`, the `:media` index
|
||||
and library-color `:image` refs to them). If diffed as-is, every affected
|
||||
entity would look modified, inflating the diff and conflicts; worse, after a
|
||||
merge the references could not be resolved in the target file — repair would
|
||||
silently *detach* components and images would break when the branch file is
|
||||
GC'd.
|
||||
|
||||
`bm/remap-refs [data id-map]` rewrites exactly the same reference surface the
|
||||
duplication pipeline remapped, through an `{old-id -> new-id}` map; ids not in
|
||||
the map (external libraries) are untouched. The backend builds that map per
|
||||
operation: the branch file id plus the **media pairs** (`media-pairs`, rows
|
||||
matched across the two files by storage object + name + dimensions + mtype).
|
||||
Media *added* on the source side of a merge/update gets fresh ids
|
||||
pre-allocated before the diff, and its `file_media_object` rows are **copied
|
||||
into the target file** when the operation applies — so merged shapes always
|
||||
reference rows owned by the file that outlives the branch.
|
||||
|
||||
## Merging a branch into main
|
||||
|
||||
`::merge-file-branch` integrates the branch back into main. Only an **editor of
|
||||
main** may do it (same rule as Figma). The whole operation runs in a transaction
|
||||
guarded by `db/xact-lock!` on **both** file ids (in stable order, to avoid
|
||||
deadlocks) — without the branch lock, edits saved to the branch while the merge
|
||||
runs would be silently lost when the branch is marked merged and deleted.
|
||||
|
||||
The flow:
|
||||
|
||||
1. **Load** main, the branch (normalized), and the base (from the snapshot).
|
||||
2. **Optimistic concurrency check.** If the caller passed `expected-main-revn`
|
||||
and it no longer matches main's `revn`, it raises `:file-modified` ("recompute
|
||||
the diff and retry"). The frontend always sends it (taken from the diff's
|
||||
`:meta :main-revn`), so resolutions computed against a stale diff can never
|
||||
apply; on `:file-modified` it re-fetches the diff and asks the user to
|
||||
review again.
|
||||
3. **Conflict gate.** `compute-merge` is run; any conflict not resolved in the
|
||||
`resolutions` map aborts with `{:status :conflicts :conflicts [...]}`.
|
||||
4. **Unsupported gate.** `compute-changes` is run; if it reports any
|
||||
`:unsupported` kinds it aborts with `{:status :unsupported :kinds [...]}` —
|
||||
nothing is applied, so no change is ever silently lost.
|
||||
5. **No-op shortcut.** If there are no changes to apply (the branch already
|
||||
matches main), the branch is closed without touching main.
|
||||
6. **Apply.** Otherwise it:
|
||||
* takes a **safety snapshot** of pre-merge main (`pre-merge/<name>`, with the
|
||||
team's normal deletion delay) so the merge can be rolled back via the
|
||||
version history;
|
||||
* applies the change vector through `cpc/process-changes`, bumping `revn`;
|
||||
* **validates** the merged file (`cfv/validate-file`) and, if it finds
|
||||
problems, runs `cfr/repair-file` and applies the repair changes;
|
||||
* copies the `file_media_object` rows of media added on the branch into
|
||||
main, under the pre-allocated ids the changes reference;
|
||||
* writes a `file_change` (xlog) row, persists the file
|
||||
(`fupd/persist-file!`), marks the branch `merged`;
|
||||
* publishes a `:file-merged` message on the msgbus topic of main so every
|
||||
open client reloads the file in real time.
|
||||
|
||||
Closing the branch (`finish-branch!`) always releases the pinned base
|
||||
snapshot and, **unless the caller passes `:keep-branch true`**, logically
|
||||
deletes the branch and its file in the SAME transaction (`delete-branch!`) —
|
||||
merged copies do not pile up, and there is no client-driven second call that
|
||||
could be lost mid-way. The `:file-deleted` msgbus message carries the
|
||||
`session-id`, so the merging client (which is navigating to main on its own)
|
||||
is not raced into the dashboard by its own deletion.
|
||||
|
||||
### Resolutions
|
||||
|
||||
Conflict resolutions are a simple map of **entity id → `:main` | `:branch`**.
|
||||
The id is usually a uuid, but structural conflicts use keyword ids
|
||||
(`:active-themes`, `:active-sets`, `:page-order`, `:token-set-order` — each
|
||||
distinct, so resolving one can never accidentally satisfy another). `:branch`
|
||||
takes the branch side, `:main` (or absent) leaves main untouched. Resolving a
|
||||
delete conflict to `:branch` really restores: a shape comes back with its whole
|
||||
surviving subtree, a page with its full contents, a token set with its tokens. The frontend builds this
|
||||
map through `set-conflict-resolution` (one entity) and `set-all-resolutions`
|
||||
(bulk), then passes it to the merge command.
|
||||
|
||||
## From merge to change ops: `compute-changes`
|
||||
|
||||
A diff summary is not directly applicable; it has to become the same kind of raw
|
||||
change maps the editor produces. `compute-changes` does that translation and
|
||||
returns `{:changes [...] :unsupported #{...}}`. The RPCs pass it the
|
||||
`compute-merge` summary they already computed for the conflict gate, so the
|
||||
full three-way diff is not run a second time just to derive `:unsupported`.
|
||||
|
||||
### Mergeable vs. unsupported kinds
|
||||
|
||||
`mergeable-kinds` is the source of truth for what can be translated:
|
||||
|
||||
```clojure
|
||||
#{:color :typography :media :shape :token :token-set :token-set-rename :token-set-order
|
||||
:token-theme :token-active-themes :token-active-sets
|
||||
:page :page-order :page-guide :page-flow :page-grid :page-plugin :component}
|
||||
```
|
||||
|
||||
`unsupported-kinds` returns any kind present in the diff that is *not* in this set
|
||||
(notably `:page-attrs`, the residual page attributes). When that set is non-empty
|
||||
the merge is refused — the engine prefers to refuse rather than drop a change it
|
||||
cannot faithfully reproduce.
|
||||
|
||||
### Per-kind translation
|
||||
|
||||
`flat-changes` is the shared helper that walks a flat `id -> value` collection and
|
||||
emits add / modify / delete ops, honouring resolutions (a conflicting entity is
|
||||
emitted only when its resolution is `:branch`). The concrete op types it produces:
|
||||
|
||||
* **colors / typographies / media** — `:add-color` / `:mod-color` /
|
||||
`:del-color`, and the analogous `:*-typography` / `:*-media` ops.
|
||||
* **shapes** (`page-shape-changes`) — the most involved pass:
|
||||
* modifications become `:mod-obj` with `:set` operations for the attrs that
|
||||
differ (structural attrs `:shapes` / `:parent-id` / `:frame-id` are
|
||||
excluded — they are applied via moves, not plain sets); per-attr
|
||||
resolutions that take a geometry attr also carry the branch's
|
||||
`:selrect`/`:points` so position and caches stay consistent;
|
||||
* deletions become `:del-obj`;
|
||||
* additions become `:add-obj`, emitted in **topological order** so a newly
|
||||
added parent is created before its newly added children; a
|
||||
modify-delete conflict resolved to `:branch` re-adds the shape's whole
|
||||
surviving subtree the same way;
|
||||
* reparenting of existing shapes becomes `:mov-objects`, emitted **last**
|
||||
(after new containers exist) and ordered by depth so a container moves
|
||||
before the shapes moved into it. Reparenting cannot be a `:set :parent-id`
|
||||
op — that would leave the shape loose in its old parent and let file repair
|
||||
duplicate it.
|
||||
* **pages** — `:add-page` (full page incl. objects) / `:del-page`, `:mod-page`
|
||||
(name/background/grid), `:mov-page` (reorder), plus per-page `:set-guide`,
|
||||
`:set-flow`, `:set-default-grid` and `:set-plugin-data`.
|
||||
* **components** — `:add-component` / `:mod-component` / `:del-component` (a
|
||||
branch soft-delete, `:deleted true`, is surfaced as a real `:del-component`).
|
||||
Components are emitted *before* shapes so `:del-component` can capture the
|
||||
main-instance objects before `:del-obj` removes them.
|
||||
* **tokens** — `:set-token` (per-token values), `:set-token-set` (create/delete
|
||||
and rename), `:move-token-set` (reorder), `:set-token-theme`,
|
||||
`:set-active-token-themes`.
|
||||
|
||||
## Updating a branch from main
|
||||
|
||||
`::update-branch-from-main` is the **reverse** direction: it brings into the
|
||||
branch the changes main received since the merge base. It needs edition
|
||||
permissions on the *branch* file, locks the *branch* file id, and applies main's
|
||||
changes (plus any conflicts resolved to `:main`) into the branch.
|
||||
|
||||
It works like a merge with `compute-merge`/`compute-changes` run with the sides
|
||||
swapped, the same `:conflicts` / `:unsupported` gates, and the same safety
|
||||
snapshot. The one extra step at the end is that it **repositions the merge base**:
|
||||
the base snapshot is moved forward to the current state of main (releasing the
|
||||
previous one), and both `base_revn`/`base_branch_revn` are refreshed. When the
|
||||
branch carried its own changes, `base_branch_revn` is stored one revn short on
|
||||
purpose: the new base equals MAIN, so the branch still differs from it even if
|
||||
its revn never moves again — the trick keeps the cheap gate open and the diff
|
||||
reports the real counts. UI resolutions arrive in main/branch terms and are
|
||||
inverted server-side (per-attr maps included) before translation.
|
||||
|
||||
## RPC API summary
|
||||
|
||||
All commands live in `app.rpc.commands.files-branch`, are gated by
|
||||
`check-branching-enabled!`, and were added in `2.16`.
|
||||
|
||||
| Command | Kind | Purpose |
|
||||
| ----------------------------- | -------- | --------------------------------------------------- |
|
||||
| `::create-file-branch` | mutation | Snapshot main + duplicate it into a branch |
|
||||
| `::get-file-branches` | query | List branches with ahead/behind/conflicts counts |
|
||||
| `::get-branch-diff` | query | Read-only three-way diff (either direction) |
|
||||
| `::merge-file-branch` | mutation | Integrate a branch into main (resolutions, `expected-main-revn`, `keep-branch`) |
|
||||
| `::update-branch-from-main` | mutation | Pull main's changes into the branch |
|
||||
| `::get-file-branch-info` | query | Branch metadata when opening a branch file |
|
||||
| `::update-file-branch` | mutation | Rename / edit description |
|
||||
| `::archive-file-branch` | mutation | Archive / restore a branch |
|
||||
| `::delete-file-branch` | mutation | Logically delete a branch |
|
||||
|
||||
## Frontend integration
|
||||
|
||||
### State and events
|
||||
|
||||
`app.main.data.workspace.branches` holds the state and wraps the RPC calls as
|
||||
`ptk` events. The relevant pieces:
|
||||
|
||||
* State keys: `workspace-branches` (the list + status), `workspace-branch-diff`
|
||||
(the diff result + selected change + resolutions) and `workspace-branch-context`
|
||||
(metadata when editing a branch).
|
||||
* Events: `fetch-branches`, `create-branch`, `open-branch`, `fetch-branch-diff`,
|
||||
`set-conflict-resolution`, `set-all-resolutions`, `merge-branch`,
|
||||
`update-branch-from-main`, `fetch-branch-context`, plus `rename-branch`,
|
||||
`archive-branch`, `delete-branch`. Each maps to the matching `rp/cmd!` call
|
||||
(`:create-file-branch`, `:get-file-branches`, `:get-branch-diff`,
|
||||
`:merge-file-branch`, `:update-branch-from-main`, …).
|
||||
* `reload-file-window` / `show-merge-result` handle the post-merge UX (reacting
|
||||
to the `:file-merged` msgbus notification and showing the result).
|
||||
|
||||
### UI components
|
||||
|
||||
`app.main.ui.workspace.sidebar.branches` provides the workspace UI:
|
||||
|
||||
* **`branches-toolbox*`** — the sidebar panel listing the current, open and
|
||||
archived branches; **`branch-entry*`** is a single branch card with rename /
|
||||
archive / delete actions.
|
||||
* **`create-branch-dialog*`** — the create modal (name + description).
|
||||
* **`branch-compare-dialog*`** — a read-only diff viewer, filterable by category
|
||||
(pages / components / colors / tokens) and status (added / modified / deleted).
|
||||
* **`branch-conflicts-dialog*`** — the conflict-resolution UI, showing
|
||||
base / main / branch side by side and letting the user pick a side per
|
||||
conflict.
|
||||
* **`branch-context-banner*`** — the top-of-workspace banner shown while editing
|
||||
a branch, with the ahead/behind counts and the merge / update actions.
|
||||
* `confirm-merge!` / `confirm-update!` guard the irreversible operations with a
|
||||
confirmation modal.
|
||||
|
||||
`app.main.ui.dashboard.branches_popover` adds a popover to the dashboard file
|
||||
card so branches can be opened or created without entering the file.
|
||||
|
||||
## Testing
|
||||
|
||||
The pure engine is covered by `common-tests.files-branch-merge-test`, which
|
||||
exercises `three-way-entities` and `compute-merge` across the add / modify /
|
||||
delete / conflict scenarios and can be run in both the JVM and JavaScript
|
||||
runners. The RPC layer is covered end-to-end by
|
||||
`backend-tests.rpc-file-branch-test` (create / list / diff / merge). See the
|
||||
[Unit tests](/technical-guide/developer/common/#unit-tests) section for how to run
|
||||
them.
|
||||
|
||||
## Pull requests
|
||||
|
||||
On top of branching, **pull requests** add a review step before the merge:
|
||||
the author of a branch asks one or more team members (**reviewers**) to
|
||||
evaluate their changes. The feature is part of branching and is gated
|
||||
behind the same `:branching` product flag; it lives in:
|
||||
|
||||
```text
|
||||
backend/src/app/migrations/sql/0156-add-file-pull-request-tables.sql
|
||||
backend/src/app/rpc/commands/files_pull_request.clj
|
||||
frontend/src/app/main/data/workspace/pull_requests.cljs
|
||||
frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs
|
||||
backend/test/backend_tests/rpc_file_pull_request_test.clj
|
||||
```
|
||||
|
||||
### Model
|
||||
|
||||
A pull request is **not** a file copy. The `file_pull_request` row points
|
||||
to its `file_branch` and to a **pinned review snapshot** of the branch
|
||||
file (label `pr-review/<title>`, the same ~10-year pin/release mechanics
|
||||
as the merge-base snapshot). There is at most one open pull request per
|
||||
branch (partial unique index).
|
||||
|
||||
Opening a review (`?pr-id=` on the workspace url) opens the **live
|
||||
branch file as a completely normal workspace file** — navigable and
|
||||
editable, so reviewers can try and validate anything (edits go to the
|
||||
branch, as always) — decorated with the review banner and actions. The
|
||||
pinned snapshot backs two things instead of the canvas: the cheap
|
||||
"outdated" gate (`branch_revn > review_revn`) and the prototype
|
||||
**viewer**: `::get-view-only-bundle` accepts an optional `pr-id` and
|
||||
serves the snapshot overlay read-only (permissions taken from the target
|
||||
file, edition stripped; share-links do not apply), reachable from the
|
||||
review banner's "Try interactions" action.
|
||||
`::get-pull-request-bundle` serves the same snapshot overlay as a
|
||||
workspace-shaped file bundle for API consumers; it refuses once the pull
|
||||
request is closed, so the pinned state stops resolving when the review
|
||||
ends (closing, merging or deleting the branch releases the pin).
|
||||
|
||||
Because the sandbox is pinned, the author can keep editing the branch;
|
||||
publishing the new state to reviewers is an explicit action
|
||||
(`::update-pull-request-snapshot`) that repositions the snapshot.
|
||||
`branch_revn > review_revn` is the cheap "outdated" gate, mirroring the
|
||||
ahead/behind revn gates.
|
||||
|
||||
### Reviews
|
||||
|
||||
Reviewers are stored in `file_pull_request_review` (one row per profile,
|
||||
`pending` / `approved` / `changes-requested`). A verdict records the
|
||||
`review_revn` it was issued over, so it goes **stale** (not deleted) when
|
||||
the author publishes newer changes. The aggregate `review-state` is
|
||||
derived, never stored: current `changes-requested` beats current
|
||||
`approved`; stale verdicts only count as "in review". Approval is
|
||||
**informative**: merging keeps requiring edition permissions on main and
|
||||
goes through the exact same `::merge-file-branch` gates.
|
||||
|
||||
Stored status is only `open` → `merged`/`closed` (with reopen). All the
|
||||
UI states (approved, changes requested, outdated, conflicts) are derived
|
||||
per request from the reviews and the existing diff-count helpers.
|
||||
|
||||
Accepting a pull request is offered from the sandbox banner (and the
|
||||
sidebar entries) to any user with edition permissions on main, but it is
|
||||
just an entry point into the exact same branch merge dialog and
|
||||
`::merge-file-branch` flow — including conflict resolution and the
|
||||
safety snapshot. Note that merging always integrates the LIVE branch
|
||||
state: an outdated pull request merges the newest changes, not the
|
||||
pinned review snapshot.
|
||||
|
||||
### Lifecycle hooks
|
||||
|
||||
The branch lifecycle closes its pull request: merge marks it `merged`,
|
||||
branch delete/archive marks it `closed` (see
|
||||
`close-branch-pull-requests!` in `files_branch.clj`), and the
|
||||
`delete-object` cascade closes it when the branch file dies through
|
||||
other paths (the row itself survives as history; it is only logically
|
||||
deleted when MAIN dies). Reviewers are notified by email
|
||||
(`review-request` template) and through the dashboard notifications
|
||||
dropdown (`::get-profile-pending-reviews`: open pull requests where the
|
||||
profile has no current verdict); `:pull-request-*` msgbus messages on
|
||||
main's topic keep open clients fresh.
|
||||
|
||||
## Current scope and limitations
|
||||
|
||||
The feature was built in phases, and the engine refuses anything it cannot yet
|
||||
reproduce faithfully rather than risk losing work:
|
||||
|
||||
* The merge is **id-based** and assumes base, main and branch share the same file
|
||||
data version; explicit data-version normalization before diffing is a later
|
||||
refinement.
|
||||
* Residual page attributes (`:page-attrs`) are surfaced but **refused**, so a
|
||||
merge that touches them returns `{:status :unsupported}`.
|
||||
* Comments / comment-thread positions are deliberately **not** migrated across a
|
||||
merge (the same choice Figma makes).
|
||||
* The merge base snapshot is kept alive for the whole life of the branch; the
|
||||
far-future `deleted-at` is what protects it from the snapshot GC.
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38f280443999",
|
||||
"~:status": "closed"
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"~:changes": [],
|
||||
"~:conflicts": [],
|
||||
"~:stats": {
|
||||
"~:added": 0,
|
||||
"~:modified": 0,
|
||||
"~:deleted": 0,
|
||||
"~:conflicts": 0
|
||||
},
|
||||
"~:meta": {
|
||||
"~:base-at": "~m1713536343369",
|
||||
"~:main-at": "~m1713873823633",
|
||||
"~:branch-at": "~m1713873823633",
|
||||
"~:main-revn": 11
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
{
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38f280443998",
|
||||
"~:branch-file-id": "~uc7ce0794-0992-8105-8004-38f280443849",
|
||||
"~:source-file-id": "~uc7ce0794-0992-8105-8004-38f280443997",
|
||||
"~:name": "checkout",
|
||||
"~:description": null,
|
||||
"~:status": "open",
|
||||
"~:created-by": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
|
||||
"~:created-at": "~m1713536343369",
|
||||
"~:updated-at": "~m1713873823633",
|
||||
"~:source-name": "New File 1",
|
||||
"~:ahead": 1,
|
||||
"~:behind": 0,
|
||||
"~:conflicts": 0
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
null
|
||||
@ -0,0 +1 @@
|
||||
[]
|
||||
@ -0,0 +1,35 @@
|
||||
[
|
||||
{
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38f280443999",
|
||||
"~:file-branch-id": "~uc7ce0794-0992-8105-8004-38f280443998",
|
||||
"~:source-file-id": "~uc7ce0794-0992-8105-8004-38f280443849",
|
||||
"~:target-file-id": "~uc7ce0794-0992-8105-8004-38f280443997",
|
||||
"~:title": "Checkout redesign",
|
||||
"~:description": "Please review the new checkout flow",
|
||||
"~:created-by": "~uc7ce0794-0992-8105-8004-38e630f29a9c",
|
||||
"~:created-at": "~m1713536343369",
|
||||
"~:updated-at": "~m1713873823633",
|
||||
"~:status": "open",
|
||||
"~:review-revn": 11,
|
||||
"~:review-updated-at": "~m1713873823633",
|
||||
"~:closed-at": null,
|
||||
"~:closed-by": null,
|
||||
"~:branch-name": "checkout",
|
||||
"~:target-name": "New File 1",
|
||||
"~:reviews": [
|
||||
{
|
||||
"~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
|
||||
"~:state": "pending",
|
||||
"~:reviewed-revn": null,
|
||||
"~:comment": null,
|
||||
"~:updated-at": "~m1713873823633",
|
||||
"~:stale": false
|
||||
}
|
||||
],
|
||||
"~:review-state": "pending",
|
||||
"~:outdated": false,
|
||||
"~:ahead": 1,
|
||||
"~:behind": 0,
|
||||
"~:conflicts": 0
|
||||
}
|
||||
]
|
||||
33
frontend/playwright/data/pull-requests/get-pull-request.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38f280443999",
|
||||
"~:file-branch-id": "~uc7ce0794-0992-8105-8004-38f280443998",
|
||||
"~:source-file-id": "~uc7ce0794-0992-8105-8004-38f280443849",
|
||||
"~:target-file-id": "~uc7ce0794-0992-8105-8004-38f280443997",
|
||||
"~:title": "Checkout redesign",
|
||||
"~:description": "Please review the new checkout flow",
|
||||
"~:created-by": "~uc7ce0794-0992-8105-8004-38e630f29a9c",
|
||||
"~:created-at": "~m1713536343369",
|
||||
"~:updated-at": "~m1713873823633",
|
||||
"~:status": "open",
|
||||
"~:review-revn": 11,
|
||||
"~:review-updated-at": "~m1713873823633",
|
||||
"~:closed-at": null,
|
||||
"~:closed-by": null,
|
||||
"~:branch-name": "checkout",
|
||||
"~:target-name": "New File 1",
|
||||
"~:reviews": [
|
||||
{
|
||||
"~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
|
||||
"~:state": "pending",
|
||||
"~:reviewed-revn": null,
|
||||
"~:comment": null,
|
||||
"~:updated-at": "~m1713873823633",
|
||||
"~:stale": false
|
||||
}
|
||||
],
|
||||
"~:review-state": "pending",
|
||||
"~:outdated": false,
|
||||
"~:ahead": 1,
|
||||
"~:behind": 0,
|
||||
"~:conflicts": 0
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38f280443999",
|
||||
"~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
|
||||
"~:state": "approved",
|
||||
"~:reviewed-revn": 11
|
||||
}
|
||||
@ -109,8 +109,12 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
|
||||
async waitForIdle(options) {
|
||||
await this.page.evaluate(
|
||||
(options) => new Promise(
|
||||
(resolve) => globalThis.requestIdleCallback(resolve, options)), options);
|
||||
(options) =>
|
||||
new Promise((resolve) =>
|
||||
globalThis.requestIdleCallback(resolve, options),
|
||||
),
|
||||
options,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -218,19 +222,26 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
fileId = this.fileId ?? WorkspacePage.anyFileId,
|
||||
pageId = this.pageId ?? WorkspacePage.anyPageId,
|
||||
pageName = "Page 1",
|
||||
// extra query params appended verbatim, e.g. "&pr-id=<uuid>" for the
|
||||
// pull request review sandbox
|
||||
extraParams = "",
|
||||
} = {}) {
|
||||
// Helpers often call setup (and this) several times per test with the
|
||||
// same file. Re-navigating would reload the document and wipe the
|
||||
// in-memory file state (e.g. tokens created by previous steps), so
|
||||
// only navigate when the target file actually changes. Extra query
|
||||
// params the app adds itself (e.g. layout=tokens) are ignored, and
|
||||
// navigating away and back still reloads as before.
|
||||
// only navigate when the target file (or the extra params) actually
|
||||
// changes. Extra query params the app adds itself (e.g. layout=tokens)
|
||||
// are ignored, and navigating away and back still reloads as before.
|
||||
const currentParams = new URL(this.page.url()).searchParams;
|
||||
const extraSearchParams = new URLSearchParams(
|
||||
extraParams.replace(/^&/, ""),
|
||||
);
|
||||
const sameFile =
|
||||
currentParams.get("screen") === "workspace" &&
|
||||
currentParams.get("team-id") === WorkspacePage.anyTeamId &&
|
||||
currentParams.get("file-id") === fileId &&
|
||||
currentParams.get("page-id") === pageId;
|
||||
currentParams.get("page-id") === pageId &&
|
||||
[...extraSearchParams].every(([k, v]) => currentParams.get(k) === v);
|
||||
if (!sameFile) {
|
||||
// Drop mocks from any previous document: page.goto reloads the app,
|
||||
// so entries registered by the old document would otherwise resolve
|
||||
@ -238,7 +249,7 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
// no longer exists in the new document.
|
||||
MockWebSocketHelper.clear();
|
||||
await this.page.goto(
|
||||
`/?screen=workspace&team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`,
|
||||
`/?screen=workspace&team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}${extraParams}`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -256,7 +267,7 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
|
||||
async #waitForWebSocketReadiness(pageName) {
|
||||
// TODO: find a better event to settle whether the app is ready to receive notifications via ws
|
||||
await expect(this.pageName).toHaveText(pageName, { timeout: 30000 })
|
||||
await expect(this.pageName).toHaveText(pageName, { timeout: 30000 });
|
||||
}
|
||||
|
||||
async sendPresenceMessage(fixture) {
|
||||
|
||||
168
frontend/playwright/ui/specs/pull-requests.spec.js
Normal file
@ -0,0 +1,168 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WorkspacePage } from "../pages/WorkspacePage";
|
||||
|
||||
const PR_ID = "c7ce0794-0992-8105-8004-38f280443999";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WorkspacePage.init(page);
|
||||
await WorkspacePage.mockConfigFlags(page, ["enable-branching"]);
|
||||
});
|
||||
|
||||
const setupPullRequestSandbox = async (workspacePage) => {
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.mockRPCs({
|
||||
"get-file-branch-info?file-id=*":
|
||||
"pull-requests/get-file-branch-info-none.json",
|
||||
"get-file-branches?file-id=*": "pull-requests/get-file-branches-empty.json",
|
||||
"get-file-pull-requests?file-id=*":
|
||||
"pull-requests/get-file-pull-requests.json",
|
||||
"get-pull-request?id=*": "pull-requests/get-pull-request.json",
|
||||
"get-branch-diff?branch-id=*": "pull-requests/get-branch-diff-empty.json",
|
||||
"submit-pull-request-review":
|
||||
"pull-requests/submit-pull-request-review.json",
|
||||
});
|
||||
};
|
||||
|
||||
test("opening a file with pr-id shows the review sandbox banner", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupPullRequestSandbox(workspacePage);
|
||||
await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
|
||||
|
||||
await expect(page.getByText("Reviewing pull request")).toBeVisible();
|
||||
await expect(page.getByText("Checkout redesign").first()).toBeVisible();
|
||||
await expect(page.getByText("checkout → New File 1")).toBeVisible();
|
||||
|
||||
// only the essential actions are visible buttons...
|
||||
await expect(page.getByRole("button", { name: "Details" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Compare" })).toBeVisible();
|
||||
|
||||
// ...the rest live behind the kebab menu
|
||||
const banner = page.getByText("Reviewing pull request").locator("../../..");
|
||||
await banner.getByRole("button", { name: "Options" }).click();
|
||||
await expect(page.getByText("Try interactions")).toBeVisible();
|
||||
await expect(page.getByText("Merge to main")).toBeVisible();
|
||||
await expect(page.getByText("Exit review")).toBeVisible();
|
||||
});
|
||||
|
||||
test("the compare dialog shows the proposed changes", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupPullRequestSandbox(workspacePage);
|
||||
await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
|
||||
|
||||
await page.getByRole("button", { name: "Compare" }).click();
|
||||
|
||||
// the branching compare dialog is reused to inspect the changes
|
||||
await expect(page.getByText("Compare changes")).toBeVisible();
|
||||
});
|
||||
|
||||
test("accepting the pull request opens the merge dialog", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupPullRequestSandbox(workspacePage);
|
||||
await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
|
||||
|
||||
const banner = page.getByText("Reviewing pull request").locator("../../..");
|
||||
await banner.getByRole("button", { name: "Options" }).click();
|
||||
await page.getByText("Merge to main").click();
|
||||
|
||||
// the branching merge dialog handles the actual integration
|
||||
await expect(page.getByText("Merge into main")).toBeVisible();
|
||||
await expect(page.getByText("checkout").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("the review behaves like a normal, editable file", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupPullRequestSandbox(workspacePage);
|
||||
await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
|
||||
|
||||
await expect(page.getByText("Reviewing pull request")).toBeVisible();
|
||||
// the file is the live branch, opened normally: the design toolbar is
|
||||
// available so reviewers can try and validate anything...
|
||||
await expect(workspacePage.rectShapeButton).toBeVisible();
|
||||
// ...and none of the read-only / inspect affordances kick in
|
||||
await expect(page.getByText("Inspecting code")).toBeHidden();
|
||||
await expect(page.getByText("View only")).toBeHidden();
|
||||
});
|
||||
|
||||
test("an assigned reviewer can submit a verdict from the banner", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupPullRequestSandbox(workspacePage);
|
||||
await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
|
||||
|
||||
// the logged-in profile is an assigned reviewer in the fixture
|
||||
await page.getByRole("button", { name: "Review", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Submit review").first()).toBeVisible();
|
||||
await page.getByText("Request changes", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Submit review" }).click();
|
||||
|
||||
await expect(page.getByText("Review submitted")).toBeVisible();
|
||||
});
|
||||
|
||||
test("closing the pull request asks for confirmation", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupPullRequestSandbox(workspacePage);
|
||||
await workspacePage.mockRPC(
|
||||
"close-pull-request",
|
||||
"pull-requests/close-pull-request.json",
|
||||
);
|
||||
await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
|
||||
|
||||
await page.getByRole("button", { name: "Details" }).click();
|
||||
await page.getByRole("button", { name: "Close pull request" }).click();
|
||||
|
||||
// nothing is closed yet: a confirmation dialog takes over
|
||||
await expect(page.getByText("will disappear")).toBeVisible();
|
||||
|
||||
// confirming closes the pull request; being inside its sandbox, the
|
||||
// user is taken back to the file (no pr-id in the url)
|
||||
await page.getByRole("button", { name: "Close pull request" }).click();
|
||||
await expect(page).not.toHaveURL(/pr-id/);
|
||||
});
|
||||
|
||||
test("on a branch, requesting a review is the primary action", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupPullRequestSandbox(workspacePage);
|
||||
// the open file IS a branch without an open pull request
|
||||
await workspacePage.mockRPCs({
|
||||
"get-file-branch-info?file-id=*":
|
||||
"pull-requests/get-file-branch-info-branch.json",
|
||||
"get-file-pull-requests?file-id=*":
|
||||
"pull-requests/get-file-branches-empty.json",
|
||||
});
|
||||
await workspacePage.goToWorkspace();
|
||||
|
||||
// merging moved behind the kebab menu; requesting a review is primary
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Request review" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Merge to main" }),
|
||||
).toBeHidden();
|
||||
|
||||
const banner = page.getByText("On branch").locator("../../..");
|
||||
await banner.getByRole("button", { name: "Options" }).click();
|
||||
await page.getByText("Merge to main").click();
|
||||
await expect(page.getByText("Merge into main")).toBeVisible();
|
||||
});
|
||||
|
||||
test("exiting the review takes the user back to the file", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupPullRequestSandbox(workspacePage);
|
||||
await workspacePage.goToWorkspace({ extraParams: `&pr-id=${PR_ID}` });
|
||||
|
||||
await expect(page.getByText("Reviewing pull request")).toBeVisible();
|
||||
const banner = page.getByText("Reviewing pull request").locator("../../..");
|
||||
await banner.getByRole("button", { name: "Options" }).click();
|
||||
await page.getByText("Exit review").click();
|
||||
|
||||
// exiting drops the review context and the pr-id param; the file (the
|
||||
// live branch) stays as it was
|
||||
await expect(page).not.toHaveURL(/pr-id/);
|
||||
await expect(page.getByText("Reviewing pull request")).toBeHidden();
|
||||
});
|
||||
3
frontend/resources/images/icons/arrow-long-right.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2.667 8h10.666m0 0L8.667 3.333M13.333 8l-4.666 4.667"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 185 B |
8
frontend/resources/images/icons/git-branch-plus.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 3v12"/>
|
||||
<circle cx="18" cy="6" r="3"/>
|
||||
<circle cx="6" cy="18" r="3"/>
|
||||
<path d="M15 6a9 9 0 0 0-9 9"/>
|
||||
<path d="M18 15v6"/>
|
||||
<path d="M21 18h-6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 327 B |
5
frontend/resources/images/icons/git-branch.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 6a9 9 0 0 0-9 9V3"/>
|
||||
<circle cx="18" cy="6" r="3"/>
|
||||
<circle cx="6" cy="18" r="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 254 B |
5
frontend/resources/images/icons/git-commit-vertical.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" x2="12" y1="3" y2="9"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<line x1="12" x2="12" y1="15" y2="21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 268 B |
5
frontend/resources/images/icons/git-commit.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<line x1="3" x2="9" y1="12" y2="12"/>
|
||||
<line x1="15" x2="21" y1="12" y2="12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 268 B |
5
frontend/resources/images/icons/git-merge.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="18" cy="18" r="3"/>
|
||||
<circle cx="6" cy="6" r="3"/>
|
||||
<path d="M6 21V9a9 9 0 0 0 9 9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="5" cy="6" r="3"/>
|
||||
<path d="M5 9v12"/>
|
||||
<circle cx="19" cy="18" r="3"/>
|
||||
<path d="m15 9-3-3 3-3"/>
|
||||
<path d="M19 15V7a2 2 0 0 0-2-2h-5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 312 B |
4
frontend/resources/images/icons/pencil.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/>
|
||||
<path d="m15 5 4 4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 317 B |
5
frontend/resources/images/icons/triangle-alert.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/>
|
||||
<path d="M12 9v4"/>
|
||||
<path d="M12 17h.01"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 286 B |
@ -57,6 +57,7 @@
|
||||
[:map {:title "initialize"}
|
||||
[:file-id ::sm/uuid]
|
||||
[:share-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:pr-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:page-id {:optional true} ::sm/uuid]])
|
||||
|
||||
(defn initialize
|
||||
@ -118,10 +119,11 @@
|
||||
[:map {:title "fetch-bundle"}
|
||||
[:page-id ::sm/uuid]
|
||||
[:file-id ::sm/uuid]
|
||||
[:share-id {:optional true} ::sm/uuid]])
|
||||
[:share-id {:optional true} ::sm/uuid]
|
||||
[:pr-id {:optional true} ::sm/uuid]])
|
||||
|
||||
(defn- fetch-bundle
|
||||
[{:keys [file-id share-id] :as params}]
|
||||
[{:keys [file-id share-id pr-id] :as params}]
|
||||
|
||||
(dm/assert!
|
||||
"expected valid params"
|
||||
@ -138,7 +140,13 @@
|
||||
features cfeat/supported-features
|
||||
params' (cond-> {:file-id file-id :features features}
|
||||
(uuid? share-id)
|
||||
(assoc :share-id share-id))
|
||||
(assoc :share-id share-id)
|
||||
|
||||
;; pull request review sandbox: the bundle serves
|
||||
;; the pinned review snapshot instead of the live
|
||||
;; branch state
|
||||
(uuid? pr-id)
|
||||
(assoc :pr-id pr-id))
|
||||
|
||||
resolve (fn [[key pointer]]
|
||||
(let [params {:file-id file-id :fragment-id @pointer}
|
||||
|
||||
@ -604,6 +604,7 @@
|
||||
:workspace-editor-state
|
||||
:workspace-wasm-editor-styles
|
||||
:workspace-media-objects
|
||||
:workspace-pr-preview
|
||||
:workspace-presence
|
||||
:workspace-tokens
|
||||
:workspace-undo
|
||||
|
||||
481
frontend/src/app/main/data/workspace/branches.cljs
Normal file
@ -0,0 +1,481 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.main.data.workspace.branches
|
||||
"Data layer for file branching (Phase 1: create + list). Mirrors the
|
||||
patterns in `app.main.data.workspace.versions`."
|
||||
(:require
|
||||
[app.config :as cf]
|
||||
[app.main.broadcast :as mbc]
|
||||
[app.main.data.common :as dcm]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.persistence :as dwp]
|
||||
[app.main.data.workspace.layout :as layout]
|
||||
[app.main.repo :as rp]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[app.util.storage :as storage]
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(defn- reload-file-window
|
||||
"Hard-reload the browser window so the open file is re-fetched from
|
||||
scratch. Used after merge / update-from-main, which rewrite the file
|
||||
server-side (including repair changes); a full reload is the most
|
||||
robust way to show the new state without local/server divergence."
|
||||
[]
|
||||
(ptk/reify ::reload-file-window
|
||||
ptk/EffectEvent
|
||||
(effect [_ _ _]
|
||||
(dom/reload-current-window))))
|
||||
|
||||
(declare open-branch)
|
||||
|
||||
(defn- show-merge-result
|
||||
"After merging a branch into main, surface the merged result: if main is
|
||||
the file currently open, hard-reload it; otherwise navigate to main."
|
||||
[source-file-id]
|
||||
(ptk/reify ::show-merge-result
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(cond
|
||||
(nil? source-file-id) (rx/of (reload-file-window))
|
||||
(= source-file-id (:current-file-id state)) (rx/of (reload-file-window))
|
||||
:else (rx/of (open-branch source-file-id))))))
|
||||
|
||||
(defonce default-state
|
||||
{:status :loading
|
||||
:data nil})
|
||||
|
||||
(declare fetch-branches)
|
||||
(declare fetch-branch-context)
|
||||
(declare show-branches-panel)
|
||||
|
||||
(defn init-branches-state
|
||||
[]
|
||||
(ptk/reify ::init-branches-state
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc state :workspace-branches default-state))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (fetch-branches)))))
|
||||
|
||||
(defn update-branches-state
|
||||
[branch-state]
|
||||
(ptk/reify ::update-branches-state
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update state :workspace-branches merge branch-state))))
|
||||
|
||||
(defn fetch-branches
|
||||
[]
|
||||
(ptk/reify ::fetch-branches
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(when-let [file-id (:current-file-id state)]
|
||||
;; the open file may itself be a branch; branches are listed by their
|
||||
;; source (main), so resolve the source first (nil when on main) and
|
||||
;; list siblings against it, so the panel is complete from a branch too
|
||||
(->> (rp/cmd! :get-file-branch-info {:file-id file-id})
|
||||
(rx/mapcat (fn [info]
|
||||
(let [root (or (:source-file-id info) file-id)]
|
||||
(rp/cmd! :get-file-branches {:file-id root :include-archived true}))))
|
||||
(rx/map #(update-branches-state {:status :loaded :data %}))
|
||||
(rx/catch (fn [_]
|
||||
(rx/of (update-branches-state {:status :loaded :data []})))))))))
|
||||
|
||||
;; --- Dashboard popover (list a file's branches from the dashboard)
|
||||
|
||||
(defn- set-dashboard-branches
|
||||
[bs]
|
||||
(ptk/reify ::set-dashboard-branches
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc state :dashboard-branches bs))))
|
||||
|
||||
(defn load-file-branches
|
||||
"Load the open branches of `file-id` into `:dashboard-branches` (used by
|
||||
the dashboard branches popover). The previous list is cleared first so a
|
||||
popover opened on another file never shows stale entries."
|
||||
[file-id]
|
||||
(ptk/reify ::load-file-branches
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc state :dashboard-branches []))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(->> (rp/cmd! :get-file-branches {:file-id file-id})
|
||||
(rx/map set-dashboard-branches)
|
||||
(rx/catch (fn [_] (rx/of (set-dashboard-branches []))))))))
|
||||
|
||||
(defn rename-branch
|
||||
[id name]
|
||||
(assert (uuid? id) "expected valid uuid for `id`")
|
||||
(ptk/reify ::rename-branch
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(->> (rp/cmd! :update-file-branch {:id id :name name})
|
||||
(rx/mapcat (fn [_] (rx/of (fetch-branches) (fetch-branch-context))))
|
||||
(rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.branches.lifecycle.error")))))))))
|
||||
|
||||
(defn set-branch-description
|
||||
"Update a branch's free-text description (shown in the Branch info modal).
|
||||
Refreshes the panel and the on-branch banner so the new value is visible."
|
||||
[id description]
|
||||
(assert (uuid? id) "expected valid uuid for `id`")
|
||||
(ptk/reify ::set-branch-description
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(->> (rp/cmd! :update-file-branch {:id id :description description})
|
||||
(rx/mapcat (fn [_] (rx/of (fetch-branches) (fetch-branch-context))))
|
||||
(rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.branches.lifecycle.error")))))))))
|
||||
|
||||
(defn archive-branch
|
||||
([id] (archive-branch id true))
|
||||
([id archived?]
|
||||
(assert (uuid? id) "expected valid uuid for `id`")
|
||||
(ptk/reify ::archive-branch
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(->> (rp/cmd! :archive-file-branch {:id id :archived archived?})
|
||||
(rx/mapcat (fn [_] (rx/of (fetch-branches))))
|
||||
(rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.branches.lifecycle.error"))))))))))
|
||||
|
||||
(defn delete-branch
|
||||
[id]
|
||||
(assert (uuid? id) "expected valid uuid for `id`")
|
||||
(ptk/reify ::delete-branch
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(->> (rp/cmd! :delete-file-branch {:id id})
|
||||
(rx/mapcat (fn [_] (rx/of (ntf/success (tr "workspace.branches.lifecycle.deleted"))
|
||||
(fetch-branches))))
|
||||
(rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.branches.lifecycle.error")))))))))
|
||||
|
||||
(defn create-branch
|
||||
"Create a branch from a file. With no `file-id`, branches the currently
|
||||
open file (force-persisting first so the merge base captures the latest
|
||||
edits) and jumps into the new branch, keeping the branches panel open
|
||||
so the user lands on the branch with full context. With an explicit
|
||||
`file-id` (e.g. from the dashboard, where the file is not open),
|
||||
creates it directly and opens the new branch. Errors surface as a
|
||||
toast; the caller closes the dialog."
|
||||
([name description] (create-branch nil name description))
|
||||
([file-id name description]
|
||||
(assert (string? name) "expected string for `name`")
|
||||
(ptk/reify ::create-branch
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [current-id (:current-file-id state)
|
||||
from-ws? (and (nil? file-id) (some? current-id))
|
||||
target-id (or file-id current-id)
|
||||
;; Wait for persistence only when branching the open file;
|
||||
;; from the dashboard the file is not open, so there is
|
||||
;; nothing to flush. Emits exactly one value, so the branch
|
||||
;; is created exactly once.
|
||||
wait (if from-ws?
|
||||
(dwp/wait-persisted)
|
||||
(rx/of :ready))]
|
||||
(rx/concat
|
||||
;; Force-persist the open file first so the merge base captures
|
||||
;; the latest edits. The ::force-persist event must be dispatched
|
||||
;; at the top level (not piped into the mapcat below); off the
|
||||
;; workspace there is nothing to persist.
|
||||
(if from-ws?
|
||||
(rx/of ::dwp/force-persist (ev/event {::ev/name "create-branch"}))
|
||||
(rx/of (ev/event {::ev/name "create-branch"})))
|
||||
(->> wait
|
||||
(rx/mapcat #(rp/cmd! :create-file-branch
|
||||
{:file-id target-id
|
||||
:name name
|
||||
:description description}))
|
||||
(rx/mapcat
|
||||
(fn [{:keys [branch-file-id]}]
|
||||
(rx/concat
|
||||
(rx/of (ntf/success (tr "workspace.branches.create.success" name)))
|
||||
(if from-ws?
|
||||
;; jump into the new branch keeping the branches
|
||||
;; panel open (the layout survives in-app navigation;
|
||||
;; show-branches-panel pins the tab selection), and
|
||||
;; refresh the list so the new branch is included
|
||||
(rx/of (open-branch branch-file-id)
|
||||
(show-branches-panel)
|
||||
(fetch-branches))
|
||||
;; from dashboard: jump into the new branch
|
||||
(rx/of (dcm/go-to-workspace :file-id branch-file-id))))))
|
||||
(rx/catch
|
||||
(fn [_]
|
||||
(rx/of (ntf/error (tr "workspace.branches.create.error"))))))))))))
|
||||
|
||||
(defn open-branch
|
||||
"Navigate to the branch file as a normal workspace file. Switching
|
||||
branch must never lose the branches panel: the layout flag survives
|
||||
in-app navigation, but side effects of the transition can still close
|
||||
it (e.g. the version-history interrupt watcher armed by the header
|
||||
button), which makes the switch confusing. So once the target
|
||||
workspace finishes initializing, the panel + Branches tab are
|
||||
re-asserted; when they are already open this is an idempotent no-op."
|
||||
[branch-file-id]
|
||||
(assert (uuid? branch-file-id) "expected valid uuid for `branch-file-id`")
|
||||
(ptk/reify ::open-branch
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ stream]
|
||||
(rx/merge
|
||||
(rx/of (dcm/go-to-workspace :file-id branch-file-id))
|
||||
;; keyword literal to avoid requiring the whole app.main.data.workspace ns
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? :app.main.data.workspace/workspace-initialized))
|
||||
(rx/take 1)
|
||||
(rx/mapcat (fn [_] (rx/of (show-branches-panel)
|
||||
(fetch-branches)))))))))
|
||||
|
||||
(def history-sidebar-tab-key
|
||||
"Storage + broadcast key for the selected tab of the version-history
|
||||
sidebar. Shared with `app.main.ui.workspace.sidebar`, which reads it
|
||||
through `hooks/use-shared-state` so this event can switch the active
|
||||
tab even when the panel is already open."
|
||||
::history-sidebar-tab)
|
||||
|
||||
(defn show-branches-panel
|
||||
"Open the version-history sidebar and switch it to the Branches tab.
|
||||
No-op when the branching feature flag is disabled (the tab does not
|
||||
exist in that case). Writing storage covers a closed→open panel; the
|
||||
broadcast emit covers an already-open one."
|
||||
[]
|
||||
(ptk/reify ::show-branches-panel
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(when (contains? cf/flags :branching)
|
||||
(rx/of (layout/toggle-layout-flag :document-history :force? true))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ _ _]
|
||||
(when (contains? cf/flags :branching)
|
||||
(swap! storage/user assoc history-sidebar-tab-key "branches")
|
||||
(mbc/emit! history-sidebar-tab-key "branches")))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; COMPARE (read-only 3-way diff)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn update-branch-diff
|
||||
[diff-state]
|
||||
(ptk/reify ::update-branch-diff
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update state :workspace-branch-diff merge diff-state))))
|
||||
|
||||
(defn fetch-branch-diff
|
||||
"Load the 3-way diff for `branch-id`. `direction` selects which side's
|
||||
changes to show: `:branch->main` (default, the branch's outgoing changes)
|
||||
or `:main->branch` (main's incoming changes the branch is missing). A
|
||||
newer fetch cancels any previous in-flight one, so a stale response can
|
||||
never land over a direction/branch switch."
|
||||
([branch-id] (fetch-branch-diff branch-id :branch->main))
|
||||
([branch-id direction]
|
||||
(assert (uuid? branch-id) "expected valid uuid for `branch-id`")
|
||||
(ptk/reify ::fetch-branch-diff
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc state :workspace-branch-diff {:status :loading
|
||||
:branch-id branch-id
|
||||
:direction direction
|
||||
:selected nil}))
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ stream]
|
||||
(->> (rp/cmd! :get-branch-diff {:branch-id branch-id :direction direction})
|
||||
(rx/map #(update-branch-diff {:status :loaded :diff %}))
|
||||
(rx/catch (fn [_]
|
||||
(rx/of (update-branch-diff {:status :error}))))
|
||||
(rx/take-until (rx/filter (ptk/type? ::fetch-branch-diff) stream)))))))
|
||||
|
||||
(defn select-diff-change
|
||||
"Select a change/conflict (by its index in the diff) to show its detail."
|
||||
[selected]
|
||||
(ptk/reify ::select-diff-change
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc-in state [:workspace-branch-diff :selected] selected))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; BRANCH CONTEXT (banner when the open file is a branch)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- set-branch-context
|
||||
"Store the branch context of `file-id`. The state value is
|
||||
`{:file-id .. :loaded? true :info <row-or-nil>}`, so consumers can tell
|
||||
\"this file is not a branch\" (loaded, nil info) apart from \"not loaded
|
||||
yet\" — see `refs/branch-context` for the `:info` lens."
|
||||
[file-id info]
|
||||
(ptk/reify ::set-branch-context
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc state :workspace-branch-context {:file-id file-id
|
||||
:loaded? true
|
||||
:info info}))))
|
||||
|
||||
(defn fetch-branch-context
|
||||
"Load branch metadata for the current file (nil when it is not a branch).
|
||||
A context belonging to ANOTHER file is dropped synchronously so the
|
||||
banner/header never show stale data while the RPC is in flight. If the
|
||||
file is already known NOT to be a branch the RPC is skipped entirely (a
|
||||
file cannot become a branch, and this event fires on every save/focus).
|
||||
If `behind` grew since the previous value of the SAME file (main advanced
|
||||
while working on the branch), surface a notification."
|
||||
[]
|
||||
(ptk/reify ::fetch-branch-context
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [file-id (:current-file-id state)
|
||||
ctx (:workspace-branch-context state)]
|
||||
(if (or (nil? ctx) (= file-id (:file-id ctx)))
|
||||
state
|
||||
(dissoc state :workspace-branch-context))))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(when-let [file-id (:current-file-id state)]
|
||||
(let [ctx (:workspace-branch-context state)
|
||||
same-file? (= file-id (:file-id ctx))
|
||||
not-branch? (and same-file? (:loaded? ctx) (nil? (:info ctx)))
|
||||
prev-behind (if same-file? (or (:behind (:info ctx)) 0) 0)]
|
||||
(when-not not-branch?
|
||||
(->> (rp/cmd! :get-file-branch-info {:file-id file-id})
|
||||
(rx/mapcat
|
||||
(fn [info]
|
||||
(rx/concat
|
||||
(rx/of (set-branch-context file-id info))
|
||||
(if (and info (> (or (:behind info) 0) prev-behind))
|
||||
(rx/of (ntf/info (tr "workspace.branches.main-advanced")))
|
||||
(rx/empty)))))
|
||||
(rx/catch (fn [_] (rx/of (set-branch-context file-id nil)))))))))))
|
||||
|
||||
(defn update-branch-from-main
|
||||
"Bring main's changes into the branch (reverse of merge). `branch` is the
|
||||
branch row. With conflicts and no `resolutions`, opens the conflict
|
||||
resolution modal in update mode; with resolutions, applies them."
|
||||
([branch] (update-branch-from-main branch nil))
|
||||
([branch resolutions]
|
||||
(ptk/reify ::update-branch-from-main
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/concat
|
||||
(rx/of (ev/event {::ev/name "update-branch-from-main"}))
|
||||
(->> (rp/cmd! :update-branch-from-main (cond-> {:branch-id (:id branch)}
|
||||
(seq resolutions) (assoc :resolutions resolutions)))
|
||||
(rx/mapcat
|
||||
(fn [{:keys [status]}]
|
||||
(case status
|
||||
;; the open branch file just changed server-side: hard-reload
|
||||
;; it so the pulled changes are shown
|
||||
:updated (rx/of (ntf/success (tr "workspace.branches.update.success"))
|
||||
(reload-file-window))
|
||||
:conflicts (rx/of (modal/show :branch-conflicts {:branch branch :mode :update}))
|
||||
:unsupported (rx/of (ntf/warn (tr "workspace.branches.update.unsupported")))
|
||||
(rx/of (ntf/error (tr "workspace.branches.update.error"))))))
|
||||
(rx/catch (fn [_]
|
||||
(rx/of (ntf/error (tr "workspace.branches.update.error")))))))))))
|
||||
|
||||
(defn set-conflict-resolution
|
||||
"Choose `:main` or `:branch` for a whole conflicting entity (by id). This
|
||||
is the per-element \"Use this\" action; it overwrites any per-attr map."
|
||||
[id choice]
|
||||
(ptk/reify ::set-conflict-resolution
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc-in state [:workspace-branch-diff :resolutions id] choice))))
|
||||
|
||||
(defn set-conflict-attr-resolution
|
||||
"Choose `:main` or `:branch` for a single attribute `attr` of a
|
||||
conflicting entity, leaving the other attributes as they were. `attrs`
|
||||
is the full set of the conflict's changed-attr keys, used to seed a
|
||||
complete resolution map the first time a property is toggled (so the
|
||||
conflict counts as resolved, with untouched attrs defaulting to the safe
|
||||
`:main` side). Promotes a prior whole-entity keyword to a per-attr map."
|
||||
[id attr choice attrs]
|
||||
(ptk/reify ::set-conflict-attr-resolution
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update-in state [:workspace-branch-diff :resolutions id]
|
||||
(fn [cur]
|
||||
(let [seed (cond
|
||||
(map? cur) cur
|
||||
(= cur :branch) (zipmap attrs (repeat :branch))
|
||||
:else (zipmap attrs (repeat :main)))]
|
||||
(assoc seed attr choice)))))))
|
||||
|
||||
(defn set-all-resolutions
|
||||
"Bulk-resolve every current conflict to `:main` or `:branch`."
|
||||
[choice]
|
||||
(ptk/reify ::set-all-resolutions
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [conflicts (get-in state [:workspace-branch-diff :diff :conflicts])
|
||||
res (into {} (map (fn [c] [(:id c) choice])) conflicts)]
|
||||
(assoc-in state [:workspace-branch-diff :resolutions] res)))))
|
||||
|
||||
(defn merge-branch
|
||||
"Merge `branch` (a branch row/context map with at least `:id`) into main.
|
||||
On success the branch is marked merged server-side and open clients
|
||||
reload main via the `:file-merged` msgbus event.
|
||||
|
||||
Options:
|
||||
- `:resolutions` — optional `{entity-id (:main|:branch)}` map used to
|
||||
resolve conflicts before integrating.
|
||||
- `:keep-branch` — when true the merged branch is kept (it shows up
|
||||
under \"Archived\"); when false (default) the server deletes the
|
||||
branch and its file in the same transaction, so merged copies do not
|
||||
pile up and eat disk space.
|
||||
|
||||
When the current diff in state belongs to this branch, its main revn is
|
||||
sent as `expected-main-revn` so the server refuses to apply resolutions
|
||||
computed against a stale diff (`:file-modified`); in that case the diff
|
||||
is re-fetched and the user is asked to review it again. Unresolved
|
||||
conflicts open the resolution modal."
|
||||
([branch] (merge-branch branch nil))
|
||||
([branch {:keys [resolutions keep-branch]}]
|
||||
(assert (uuid? (:id branch)) "expected a branch row with a valid `:id`")
|
||||
(ptk/reify ::merge-branch
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [branch-id (:id branch)
|
||||
diff-st (:workspace-branch-diff state)
|
||||
main-revn (when (= branch-id (:branch-id diff-st))
|
||||
(get-in diff-st [:diff :meta :main-revn]))]
|
||||
(rx/concat
|
||||
(rx/of (ev/event {::ev/name "merge-branch"}))
|
||||
(->> (rp/cmd! :merge-file-branch
|
||||
(cond-> {:branch-id branch-id}
|
||||
(seq resolutions) (assoc :resolutions resolutions)
|
||||
keep-branch (assoc :keep-branch true)
|
||||
(some? main-revn) (assoc :expected-main-revn main-revn)))
|
||||
(rx/mapcat
|
||||
(fn [{:keys [status source-file-id]}]
|
||||
(case status
|
||||
;; the merged result lives in main: take the user there
|
||||
;; to see it (navigate if elsewhere, hard-reload if
|
||||
;; already on main). Other clients reload via the
|
||||
;; `:file-merged` event; the branch deletion (when not
|
||||
;; kept) already happened server-side.
|
||||
:merged (rx/of (ntf/success (tr "workspace.branches.merge.success"))
|
||||
(show-merge-result source-file-id))
|
||||
:conflicts (rx/of (modal/show :branch-conflicts {:branch branch :mode :merge}))
|
||||
:unsupported (rx/of (ntf/warn (tr "workspace.branches.merge.unsupported")))
|
||||
(rx/of (ntf/error (tr "workspace.branches.merge.error"))))))
|
||||
(rx/catch
|
||||
(fn [cause]
|
||||
(if (= :file-modified (:code (ex-data cause)))
|
||||
;; main moved under our feet: reload the diff so the
|
||||
;; user resolves against the current state
|
||||
(rx/of (ntf/warn (tr "workspace.branches.merge.main-moved"))
|
||||
(fetch-branch-diff branch-id))
|
||||
(rx/of (ntf/error (tr "workspace.branches.merge.error")))))))))))))
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
[app.main.data.workspace.edition :as dwe]
|
||||
[app.main.data.workspace.layout :as dwly]
|
||||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.data.workspace.pull-requests :as dwpr]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.router :as rt]
|
||||
[app.util.globals :refer [global]]
|
||||
@ -41,6 +42,8 @@
|
||||
(declare handle-file-change)
|
||||
(declare handle-file-deleted)
|
||||
(declare handle-file-restored)
|
||||
(declare handle-file-merged)
|
||||
(declare handle-pull-request-change)
|
||||
(declare handle-library-change)
|
||||
(declare handle-pointer-send)
|
||||
(declare handle-export-update)
|
||||
@ -136,7 +139,12 @@
|
||||
:file-change (handle-file-change msg)
|
||||
:file-deleted (handle-file-deleted msg)
|
||||
:file-restored (handle-file-restored msg)
|
||||
:file-merged (handle-file-merged msg)
|
||||
:library-change (handle-library-change msg)
|
||||
:pull-request-created (handle-pull-request-change msg)
|
||||
:pull-request-updated (handle-pull-request-change msg)
|
||||
:pull-request-review-submitted (handle-pull-request-change msg)
|
||||
:pull-request-closed (handle-pull-request-change msg)
|
||||
:notification (dc/handle-notification msg)
|
||||
:team-role-change (handle-change-team-role msg)
|
||||
:team-membership-change (dc/team-membership-change msg)
|
||||
@ -276,14 +284,20 @@
|
||||
:undo-changes []})))))
|
||||
|
||||
(defn handle-file-deleted
|
||||
[{:keys [file-id] :as msg}]
|
||||
[{:keys [file-id session-id] :as msg}]
|
||||
(ptk/reify ::handle-file-deleted
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [curr-file-id (:current-file-id state)
|
||||
team-id (:current-team-id state)]
|
||||
;; If the deleted file is the currently open one
|
||||
(when (= file-id curr-file-id)
|
||||
;; If the deleted file is the currently open one, evict to the
|
||||
;; dashboard — EXCEPT when this session originated the deletion
|
||||
;; (merging a branch deletes the open branch file and the merge
|
||||
;; flow itself navigates to main; racing it here would land the
|
||||
;; user on the dashboard instead).
|
||||
(when (and (= file-id curr-file-id)
|
||||
(or (nil? session-id)
|
||||
(not= session-id (:session-id state))))
|
||||
(rx/of
|
||||
(rt/nav :dashboard-recent {:team-id team-id})))))))
|
||||
|
||||
@ -314,6 +328,27 @@
|
||||
(not= vern curr-vern))
|
||||
(rx/of (ptk/event ::dw/reload-current-file)))))))
|
||||
|
||||
(defn handle-file-merged
|
||||
"A branch was merged into this file (main). Reload the current file so
|
||||
the merged changes appear for everyone with main open."
|
||||
[{:keys [file-id] :as _msg}]
|
||||
(ptk/reify ::handle-file-merged
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(when (= file-id (:current-file-id state))
|
||||
(rx/of (ptk/event ::dw/reload-current-file))))))
|
||||
|
||||
(defn handle-pull-request-change
|
||||
"A pull request of this file changed (created / edited / reviewed /
|
||||
closed): refresh the sidebar list, and the sandbox banner when it is
|
||||
showing that pull request."
|
||||
[{:keys [pull-request-id] :as _msg}]
|
||||
(ptk/reify ::handle-pull-request-change
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (dwpr/fetch-pull-requests)
|
||||
(dwpr/refresh-pull-request-preview-info pull-request-id)))))
|
||||
|
||||
(def ^:private schema:handle-library-change
|
||||
[:map {:title "handle-library-change"}
|
||||
[:type :keyword]
|
||||
|
||||
389
frontend/src/app/main/data/workspace/pull_requests.cljs
Normal file
@ -0,0 +1,389 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.main.data.workspace.pull-requests
|
||||
"Data layer for pull requests over file branches. A pull request points
|
||||
to a pinned review snapshot of its branch; the review sandbox is that
|
||||
snapshot loaded read-only into the workspace (same mechanics as the
|
||||
version-history preview in `app.main.data.workspace.versions`), so it
|
||||
is never an editable copy of anything. Mirrors the patterns of
|
||||
`app.main.data.workspace.branches`."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.config :as cf]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.persistence :as dwp]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.router :as rt]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[beicon.v2.core :as rx]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(defn enabled?
|
||||
[]
|
||||
(contains? cf/flags :branching))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; PULL REQUEST LIST (sidebar section)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defonce default-state
|
||||
{:status :loading
|
||||
:data nil})
|
||||
|
||||
(defn- update-pull-requests-state
|
||||
[pr-state]
|
||||
(ptk/reify ::update-pull-requests-state
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update state :workspace-pull-requests merge pr-state))))
|
||||
|
||||
(defn fetch-pull-requests
|
||||
"Load the pull requests of the current file into
|
||||
`:workspace-pull-requests`. The backend resolves a branch file id to
|
||||
its source, so the list is complete whether the user is on main or on
|
||||
a branch. Closed ones are included; the UI decides what to show."
|
||||
[]
|
||||
(ptk/reify ::fetch-pull-requests
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(when (enabled?)
|
||||
(when-let [file-id (:current-file-id state)]
|
||||
(->> (rp/cmd! :get-file-pull-requests {:file-id file-id :include-closed true})
|
||||
(rx/map #(update-pull-requests-state {:status :loaded :data %}))
|
||||
(rx/catch (fn [_]
|
||||
(rx/of (update-pull-requests-state {:status :loaded :data []}))))))))))
|
||||
|
||||
(defn init-pull-requests-state
|
||||
[]
|
||||
(ptk/reify ::init-pull-requests-state
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc state :workspace-pull-requests default-state))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (fetch-pull-requests)))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; PENDING REVIEWS (dashboard notifications area)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- set-pending-reviews
|
||||
[rows]
|
||||
(ptk/reify ::set-pending-reviews
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc state :dashboard-pending-reviews rows))))
|
||||
|
||||
(defn fetch-pending-reviews
|
||||
"Load into `:dashboard-pending-reviews` the open pull requests of
|
||||
`team-id` where the current profile is a reviewer without a current
|
||||
verdict. Feeds the dashboard notifications dropdown."
|
||||
[team-id]
|
||||
(ptk/reify ::fetch-pending-reviews
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(when (and (enabled?) (some? team-id))
|
||||
(->> (rp/cmd! :get-profile-pending-reviews {:team-id team-id})
|
||||
(rx/map set-pending-reviews)
|
||||
(rx/catch (fn [_] (rx/of (set-pending-reviews [])))))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; LIFECYCLE (create / edit / publish / close / reopen)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare refresh-pull-request-preview-info)
|
||||
|
||||
(defn create-pull-request
|
||||
"Create a pull request from `branch-id` towards its source file. When
|
||||
the branch is the currently open file, pending local changes are
|
||||
force-persisted first so the review snapshot captures them. Errors
|
||||
surface as a toast; the caller closes the dialog."
|
||||
[branch-id {:keys [title description reviewers]}]
|
||||
(assert (uuid? branch-id) "expected valid uuid for `branch-id`")
|
||||
(assert (string? title) "expected string for `title`")
|
||||
(ptk/reify ::create-pull-request
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [branch (->> (get-in state [:workspace-branches :data])
|
||||
(filter #(= branch-id (:id %)))
|
||||
(first))
|
||||
from-ws? (= (:branch-file-id branch) (:current-file-id state))
|
||||
wait (if from-ws?
|
||||
(dwp/wait-persisted)
|
||||
(rx/of :ready))]
|
||||
(rx/concat
|
||||
(if from-ws?
|
||||
(rx/of ::dwp/force-persist (ev/event {::ev/name "create-pull-request"}))
|
||||
(rx/of (ev/event {::ev/name "create-pull-request"})))
|
||||
(->> wait
|
||||
(rx/mapcat #(rp/cmd! :create-pull-request
|
||||
{:branch-id branch-id
|
||||
:title title
|
||||
:description description
|
||||
:reviewers (vec (or reviewers []))}))
|
||||
(rx/mapcat
|
||||
(fn [_]
|
||||
(rx/of (ntf/success (tr "workspace.pull-requests.create.success" title))
|
||||
(fetch-pull-requests))))
|
||||
(rx/catch
|
||||
(fn [cause]
|
||||
(if (= :pull-request-already-exists (:code (ex-data cause)))
|
||||
(rx/of (ntf/warn (tr "workspace.pull-requests.create.already-exists")))
|
||||
(rx/of (ntf/error (tr "workspace.pull-requests.create.error"))))))))))))
|
||||
|
||||
(defn update-pull-request
|
||||
"Edit a pull request's title, description or reviewer list (author or
|
||||
team admins only, enforced server-side)."
|
||||
[id {:keys [title description reviewers]}]
|
||||
(assert (uuid? id) "expected valid uuid for `id`")
|
||||
(ptk/reify ::update-pull-request
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(->> (rp/cmd! :update-pull-request
|
||||
(cond-> {:id id}
|
||||
(some? title) (assoc :title title)
|
||||
(some? description) (assoc :description description)
|
||||
(some? reviewers) (assoc :reviewers (vec reviewers))))
|
||||
(rx/mapcat (fn [_] (rx/of (fetch-pull-requests)
|
||||
(refresh-pull-request-preview-info id))))
|
||||
(rx/catch (fn [_] (rx/of (ntf/error (tr "workspace.pull-requests.lifecycle.error")))))))))
|
||||
|
||||
(defn update-pull-request-snapshot
|
||||
"Publish the branch's current state to the reviewers (repositions the
|
||||
review snapshot). When the branch is the currently open file, pending
|
||||
local changes are force-persisted first."
|
||||
[id]
|
||||
(assert (uuid? id) "expected valid uuid for `id`")
|
||||
(ptk/reify ::update-pull-request-snapshot
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [from-ws? (some? (:current-file-id state))
|
||||
wait (if from-ws? (dwp/wait-persisted) (rx/of :ready))]
|
||||
(rx/concat
|
||||
(if from-ws?
|
||||
(rx/of ::dwp/force-persist (ev/event {::ev/name "update-pull-request-snapshot"}))
|
||||
(rx/of (ev/event {::ev/name "update-pull-request-snapshot"})))
|
||||
(->> wait
|
||||
(rx/mapcat #(rp/cmd! :update-pull-request-snapshot {:id id}))
|
||||
(rx/mapcat
|
||||
(fn [{:keys [updated]}]
|
||||
(rx/of (if updated
|
||||
(ntf/success (tr "workspace.pull-requests.publish.success"))
|
||||
(ntf/info (tr "workspace.pull-requests.publish.up-to-date")))
|
||||
(fetch-pull-requests))))
|
||||
(rx/catch
|
||||
(fn [_]
|
||||
(rx/of (ntf/error (tr "workspace.pull-requests.lifecycle.error")))))))))))
|
||||
|
||||
(defn submit-review
|
||||
"Submit (or retract, with \"pending\") a verdict on a pull request. Only
|
||||
assigned reviewers may submit (enforced server-side)."
|
||||
[id verdict comment]
|
||||
(assert (uuid? id) "expected valid uuid for `id`")
|
||||
(assert (contains? #{"approved" "changes-requested" "pending"} verdict)
|
||||
"expected a valid verdict")
|
||||
(ptk/reify ::submit-review
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/concat
|
||||
(rx/of (ev/event {::ev/name "submit-pull-request-review"}))
|
||||
(->> (rp/cmd! :submit-pull-request-review
|
||||
(cond-> {:id id :state verdict}
|
||||
(some? comment) (assoc :comment comment)))
|
||||
(rx/mapcat
|
||||
(fn [_]
|
||||
(rx/of (ntf/success (tr "workspace.pull-requests.review.submitted"))
|
||||
(fetch-pull-requests)
|
||||
(refresh-pull-request-preview-info id))))
|
||||
(rx/catch
|
||||
(fn [cause]
|
||||
(if (= :not-a-reviewer (:code (ex-data cause)))
|
||||
(rx/of (ntf/warn (tr "workspace.pull-requests.review.not-a-reviewer")))
|
||||
(rx/of (ntf/error (tr "workspace.pull-requests.lifecycle.error")))))))))))
|
||||
|
||||
(declare exit-pull-request-preview)
|
||||
|
||||
(defn close-pull-request
|
||||
"Close (discard) a pull request. Its sandbox stops existing; if the user
|
||||
is currently inside it, take them back to the branch file."
|
||||
[id]
|
||||
(assert (uuid? id) "expected valid uuid for `id`")
|
||||
(ptk/reify ::close-pull-request
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [inside? (= id (get-in state [:workspace-pr-preview :pr-id]))]
|
||||
(->> (rp/cmd! :close-pull-request {:id id})
|
||||
(rx/mapcat
|
||||
(fn [_]
|
||||
(rx/concat
|
||||
(rx/of (ntf/success (tr "workspace.pull-requests.lifecycle.closed"))
|
||||
(fetch-pull-requests))
|
||||
(if inside?
|
||||
(rx/of (exit-pull-request-preview))
|
||||
(rx/empty)))))
|
||||
(rx/catch
|
||||
(fn [_]
|
||||
(rx/of (ntf/error (tr "workspace.pull-requests.lifecycle.error"))))))))))
|
||||
|
||||
(defn reopen-pull-request
|
||||
[id]
|
||||
(assert (uuid? id) "expected valid uuid for `id`")
|
||||
(ptk/reify ::reopen-pull-request
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(->> (rp/cmd! :reopen-pull-request {:id id})
|
||||
(rx/mapcat (fn [_] (rx/of (ntf/success (tr "workspace.pull-requests.lifecycle.reopened"))
|
||||
(fetch-pull-requests))))
|
||||
(rx/catch
|
||||
(fn [cause]
|
||||
(case (:code (ex-data cause))
|
||||
:branch-not-open
|
||||
(rx/of (ntf/warn (tr "workspace.pull-requests.lifecycle.branch-gone")))
|
||||
|
||||
:pull-request-already-exists
|
||||
(rx/of (ntf/warn (tr "workspace.pull-requests.create.already-exists")))
|
||||
|
||||
(rx/of (ntf/error (tr "workspace.pull-requests.lifecycle.error"))))))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; REVIEW SANDBOX (read-only preview of the pinned snapshot)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn open-pull-request
|
||||
"Navigate into the review sandbox of `pr`: the branch file's workspace
|
||||
with the `pr-id` query param, which loads the pinned snapshot
|
||||
read-only once the file is initialized."
|
||||
[pr]
|
||||
(assert (uuid? (:id pr)) "expected a pull request row with a valid `:id`")
|
||||
(ptk/reify ::open-pull-request
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [params (-> (rt/get-params state)
|
||||
(assoc :team-id (:current-team-id state))
|
||||
(assoc :file-id (:source-file-id pr))
|
||||
(assoc :pr-id (:id pr))
|
||||
(dissoc :page-id))]
|
||||
(rx/of (rt/nav :workspace params))))))
|
||||
|
||||
(defn open-pull-request-viewer
|
||||
"Open the review sandbox in the interactions viewer (new window): the
|
||||
viewer bundle serves the pull request's pinned snapshot, so reviewers
|
||||
can play the prototype exactly as it was published for review."
|
||||
[pr]
|
||||
(assert (uuid? (:id pr)) "expected a pull request row with a valid `:id`")
|
||||
(ptk/reify ::open-pull-request-viewer
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [params {:file-id (:source-file-id pr)
|
||||
:page-id (:current-page-id state)
|
||||
:pr-id (:id pr)
|
||||
:section :interactions}]
|
||||
(rx/of (rt/nav :viewer
|
||||
(d/without-nils params)
|
||||
{::rt/new-window true
|
||||
::rt/window-name (dm/str "viewer-" (:source-file-id pr))}))))))
|
||||
|
||||
(defn- set-pull-request-preview
|
||||
[preview]
|
||||
(ptk/reify ::set-pull-request-preview
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(if (nil? preview)
|
||||
(dissoc state :workspace-pr-preview)
|
||||
(update state :workspace-pr-preview merge preview)))))
|
||||
|
||||
(defn refresh-pull-request-preview-info
|
||||
"Refetch the pull request row backing the active sandbox (verdicts,
|
||||
counts, outdated flag). No-op when the sandbox is not active or shows
|
||||
another pull request. If the pull request finished meanwhile (merged
|
||||
or cancelled), the review context is dropped: the branch is a normal
|
||||
branch again."
|
||||
[id]
|
||||
(ptk/reify ::refresh-pull-request-preview-info
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(when (= id (get-in state [:workspace-pr-preview :pr-id]))
|
||||
(->> (rp/cmd! :get-pull-request {:id id})
|
||||
(rx/mapcat
|
||||
(fn [info]
|
||||
(if (not= "open" (:status info))
|
||||
(rx/of (exit-pull-request-preview))
|
||||
(rx/of (set-pull-request-preview {:info info})))))
|
||||
(rx/catch (fn [_] (rx/empty))))))))
|
||||
|
||||
(defn exit-pull-request-preview
|
||||
"Leave the review context: drop the state entry and the `pr-id` query
|
||||
param. The file itself was never altered (it is the live branch file,
|
||||
opened normally), so no reload is needed — the branch banner simply
|
||||
takes over again."
|
||||
[]
|
||||
(ptk/reify ::exit-pull-request-preview
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(dissoc state :workspace-pr-preview))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [params (-> (rt/get-params state)
|
||||
(dissoc :pr-id))]
|
||||
(rx/of (rt/nav :workspace params))))))
|
||||
|
||||
(defn- enter-pull-request-preview
|
||||
"Worker for `initialize-pull-request-preview`: load the pull request
|
||||
info and attach the review context to the open file. The file stays a
|
||||
NORMAL workspace file — fully navigable and editable, so reviewers can
|
||||
try and validate anything (edits go to the branch, as always); the
|
||||
pinned snapshot backs the interactions viewer and the outdated
|
||||
tracking, not the canvas. Cleans the url when the pull request does
|
||||
not belong to the open file or cannot be loaded."
|
||||
[pr-id]
|
||||
(ptk/reify ::enter-pull-request-preview
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc state :workspace-pr-preview {:pr-id pr-id
|
||||
:status :loading}))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [file-id (:current-file-id state)]
|
||||
(->> (rp/cmd! :get-pull-request {:id pr-id})
|
||||
(rx/mapcat
|
||||
(fn [info]
|
||||
(cond
|
||||
;; a stale pr-id param carried over a navigation (e.g.
|
||||
;; landing on main after merging from the review)
|
||||
(not= file-id (:source-file-id info))
|
||||
(rx/of (exit-pull-request-preview))
|
||||
|
||||
;; the pull request already finished (merged or
|
||||
;; cancelled): the branch is a normal branch again, so
|
||||
;; there is no review context to restore
|
||||
(not= "open" (:status info))
|
||||
(rx/of (exit-pull-request-preview))
|
||||
|
||||
:else
|
||||
(rx/of (set-pull-request-preview {:status :loaded :info info})))))
|
||||
(rx/catch
|
||||
(fn [_]
|
||||
(rx/of (ntf/warn (tr "workspace.pull-requests.preview.unavailable"))
|
||||
(exit-pull-request-preview)))))))))
|
||||
|
||||
(defn initialize-pull-request-preview
|
||||
"Attach the review context for `pr-id` to the open file. Split from the
|
||||
worker event so a re-emission (page navigation re-runs the workspace
|
||||
effect) is a cheap no-op."
|
||||
[pr-id]
|
||||
(assert (uuid? pr-id) "expected valid uuid for `pr-id`")
|
||||
(ptk/reify ::initialize-pull-request-preview
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(if (= pr-id (get-in state [:workspace-pr-preview :pr-id]))
|
||||
(rx/empty)
|
||||
(rx/of (enter-pull-request-preview pr-id))))))
|
||||
@ -15,6 +15,7 @@
|
||||
[app.main.data.profile :as du]
|
||||
[app.main.data.shortcuts :as ds]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.data.workspace.branches :as dwb]
|
||||
[app.main.data.workspace.colors :as mdc]
|
||||
[app.main.data.workspace.comments :as dwcm]
|
||||
[app.main.data.workspace.drawing :as dwd]
|
||||
@ -657,6 +658,12 @@
|
||||
:fn #(emit-when-no-readonly
|
||||
(dw/toggle-layout-flag :document-history))}
|
||||
|
||||
:open-branches {:tooltip (ds/meta-alt "B")
|
||||
:command (ds/ca-mod "b")
|
||||
:subsections [:panels]
|
||||
:fn #(emit-when-no-readonly
|
||||
(dwb/show-branches-panel))}
|
||||
|
||||
:toggle-colorpalette {:tooltip (ds/alt "P")
|
||||
:label (fn [] (tr "shortcuts.toggle-colorpalette"))
|
||||
:command (ds/a-mod "p")
|
||||
|
||||
@ -41,6 +41,24 @@
|
||||
(def current-page-id
|
||||
(l/derived (l/key :current-page-id) st/state))
|
||||
|
||||
(def branch-context
|
||||
;; the state entry is {:file-id .. :loaded? .. :info <row-or-nil>};
|
||||
;; consumers only care about the branch row itself
|
||||
(l/derived #(get-in % [:workspace-branch-context :info]) st/state))
|
||||
|
||||
(def pull-requests
|
||||
(l/derived :workspace-pull-requests st/state))
|
||||
|
||||
(def pull-request-preview
|
||||
;; active review context: {:pr-id .. :status .. :info <pr-row>}; the
|
||||
;; open file is the live branch, opened normally — this only drives
|
||||
;; the review banner/actions. nil when no review is active.
|
||||
(l/derived #(get % :workspace-pr-preview) st/state))
|
||||
|
||||
(def pending-reviews
|
||||
;; dashboard: open pull requests awaiting the current profile's review
|
||||
(l/derived #(get % :dashboard-pending-reviews) st/state))
|
||||
|
||||
(def team
|
||||
(l/derived dsh/lookup-team st/state))
|
||||
|
||||
|
||||
@ -189,11 +189,13 @@
|
||||
team-id (some-> params :team-id uuid/parse*)
|
||||
file-id (some-> params :file-id uuid/parse*)
|
||||
page-id (some-> params :page-id uuid/parse*)
|
||||
pr-id (some-> params :pr-id uuid/parse*)
|
||||
layout (some-> params :layout keyword)]
|
||||
[:> team-container* {:team-id team-id}
|
||||
[:> workspace-page* {:team-id team-id
|
||||
:file-id file-id
|
||||
:page-id page-id
|
||||
:pr-id pr-id
|
||||
:layout-name layout
|
||||
:key file-id}]])
|
||||
|
||||
@ -201,6 +203,7 @@
|
||||
(let [params (get params :query)
|
||||
index (some-> (rt/get-query-param params :index) parse-long)
|
||||
share-id (some-> (:share-id params) uuid/parse*)
|
||||
pr-id (some-> (:pr-id params) uuid/parse*)
|
||||
section (or (some-> (:section params) keyword)
|
||||
:interactions)
|
||||
|
||||
@ -219,6 +222,7 @@
|
||||
:section section
|
||||
:index index
|
||||
:share-id share-id
|
||||
:pr-id pr-id
|
||||
:interactions-mode imode
|
||||
:share share}]])
|
||||
|
||||
|
||||
84
frontend/src/app/main/ui/dashboard/branches_popover.cljs
Normal file
@ -0,0 +1,84 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.main.ui.dashboard.branches-popover
|
||||
"Dashboard file-card popover that lists a file's branches and lets you
|
||||
open one or create a new branch without entering the file."
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.workspace.branches :as dwb]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.dropdown :refer [dropdown]]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[okulary.core :as l]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def ^:private dashboard-branches
|
||||
(l/derived :dashboard-branches st/state))
|
||||
|
||||
(mf/defc branches-popover*
|
||||
[{:keys [file n]}]
|
||||
(let [show? (mf/use-state false)
|
||||
branches (mf/deref dashboard-branches)
|
||||
|
||||
on-toggle
|
||||
(mf/use-fn
|
||||
(mf/deps file)
|
||||
(fn [event]
|
||||
(dom/stop-propagation event)
|
||||
(when-not (deref show?)
|
||||
;; clear the previous file's list before fetching so the
|
||||
;; popover never flashes another card's branches
|
||||
(st/emit! (dwb/load-file-branches (:id file))))
|
||||
(swap! show? not)))
|
||||
|
||||
on-close (mf/use-fn #(reset! show? false))
|
||||
|
||||
on-create
|
||||
(mf/use-fn
|
||||
(mf/deps file)
|
||||
(fn [event]
|
||||
(dom/stop-propagation event)
|
||||
(reset! show? false)
|
||||
(modal/show! :create-branch {:file-name (:name file) :file-id (:id file)})))
|
||||
|
||||
on-open-branch
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(dom/stop-propagation event)
|
||||
(let [bid (-> event dom/get-current-target (dom/get-data "branch") uuid/parse)]
|
||||
(st/emit! (dwb/open-branch bid)))))]
|
||||
|
||||
[:div {:class (stl/css :branches-popover-wrap)
|
||||
:on-click (fn [e] (dom/stop-propagation e))}
|
||||
[:div {:class (stl/css :branches-badge)
|
||||
:role "button"
|
||||
:aria-label (tr "dashboard.branches-badge" (dm/str n))
|
||||
:on-click on-toggle}
|
||||
[:> i/icon* {:icon-id i/git-branch :size "s"}]
|
||||
[:span (dm/str n)]]
|
||||
|
||||
[:& dropdown {:show (deref show?) :on-close on-close}
|
||||
[:div {:class (stl/css :branches-popover)}
|
||||
[:ul {:class (stl/css :branches-popover-list)}
|
||||
(for [b branches]
|
||||
[:li {:key (dm/str (:id b))
|
||||
:class (stl/css :branches-popover-item)
|
||||
:role "button"
|
||||
:data-branch (dm/str (:branch-file-id b))
|
||||
:on-click on-open-branch}
|
||||
[:> i/icon* {:icon-id i/git-branch :size "s"}]
|
||||
[:span {:class (stl/css :branches-popover-name)} (:name b)]
|
||||
[:span {:class (stl/css :branches-popover-counts)}
|
||||
(dm/str "↑" (:ahead b) " ↓" (:behind b))]])]
|
||||
[:> button* {:variant "primary" :icon i/add :on-click on-create}
|
||||
(tr "workspace.branches.new")]]]]))
|
||||
86
frontend/src/app/main/ui/dashboard/branches_popover.scss
Normal file
@ -0,0 +1,86 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "ds/typography.scss" as t;
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
// floats over the bottom-right corner of the file thumbnail
|
||||
.branches-popover-wrap {
|
||||
position: absolute;
|
||||
inset-inline-start: deprecated.$s-12;
|
||||
inset-block-start: deprecated.$s-12;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.branches-badge {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-xxs);
|
||||
padding: var(--sp-xs) var(--sp-s);
|
||||
border: 2px solid var(--color-background-quaternary);
|
||||
border-radius: deprecated.$br-8;
|
||||
height: 32px;
|
||||
color: var(--color-foreground-secondary);
|
||||
background-color: var(--color-background-tertiary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.branches-popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-s);
|
||||
min-inline-size: 240px;
|
||||
padding: var(--sp-s);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-background-quaternary);
|
||||
background-color: var(--color-background-secondary);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.branches-popover-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xxs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-block-size: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.branches-popover-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-s);
|
||||
padding: var(--sp-xs) var(--sp-s);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--color-foreground-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.branches-popover-name {
|
||||
@include t.use-typography("body-medium");
|
||||
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.branches-popover-counts {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
@ -7,9 +7,12 @@
|
||||
(ns app.main.ui.dashboard.comments
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.comments :as dcm]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.workspace.comments :as dwcm]
|
||||
[app.main.data.workspace.pull-requests :as dwpr]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.comments :as cmt]
|
||||
@ -17,6 +20,7 @@
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :as i18n :refer [tr]]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@ -27,6 +31,7 @@
|
||||
[{:keys [profile on-show-comments]}]
|
||||
|
||||
(let [threads-map (mf/deref refs/comment-threads)
|
||||
pending (mf/deref refs/pending-reviews)
|
||||
|
||||
tgroups
|
||||
(->> (vals threads-map)
|
||||
@ -43,12 +48,13 @@
|
||||
:aria-label (tr "dashboard.notifications.view")
|
||||
:on-click on-show-comments
|
||||
:icon i/comments}
|
||||
(when (seq tgroups)
|
||||
(when (or (seq tgroups) (seq pending))
|
||||
[:div {:class (stl/css :unread)}])]]))
|
||||
|
||||
(mf/defc comments-section
|
||||
[{:keys [profile team show? on-hide-comments]}]
|
||||
(let [threads-map (mf/deref refs/comment-threads)
|
||||
pending (mf/deref refs/pending-reviews)
|
||||
|
||||
;; FIXME: with-memo
|
||||
team-id (:id team)
|
||||
@ -64,6 +70,21 @@
|
||||
(st/emit! (-> (dwcm/navigate-to-comment-from-dashboard thread)
|
||||
(with-meta {::ev/origin "dashboard"})))))
|
||||
|
||||
on-open-review
|
||||
(mf/use-callback
|
||||
(mf/deps on-hide-comments)
|
||||
(fn [event]
|
||||
(let [id (-> (dom/get-current-target event)
|
||||
(dom/get-data "pr-id")
|
||||
(uuid/parse*))
|
||||
row (->> (deref refs/pending-reviews)
|
||||
(filter #(= id (:id %)))
|
||||
(first))]
|
||||
(when row
|
||||
(on-hide-comments)
|
||||
(st/emit! (-> (dwpr/open-pull-request row)
|
||||
(with-meta {::ev/origin "dashboard"})))))))
|
||||
|
||||
on-read-all
|
||||
(mf/use-callback
|
||||
(mf/deps team-id)
|
||||
@ -73,7 +94,8 @@
|
||||
(mf/use-effect
|
||||
(mf/deps team-id)
|
||||
(fn []
|
||||
(st/emit! (dcm/retrieve-unread-comment-threads team-id))))
|
||||
(st/emit! (dcm/retrieve-unread-comment-threads team-id)
|
||||
(dwpr/fetch-pending-reviews team-id))))
|
||||
|
||||
(mf/use-effect
|
||||
(mf/deps show?)
|
||||
@ -102,19 +124,35 @@
|
||||
:on-click on-hide-comments
|
||||
:icon i/close}]]
|
||||
|
||||
(if (seq tgroups)
|
||||
[:div {:class (stl/css :thread-groups)}
|
||||
[:> cmt/comment-dashboard-thread-group*
|
||||
{:group (first tgroups)
|
||||
:on-thread-click on-navigate
|
||||
:show-file-name true}]
|
||||
(for [tgroup (rest tgroups)]
|
||||
[:> cmt/comment-dashboard-thread-group*
|
||||
{:group tgroup
|
||||
:on-thread-click on-navigate
|
||||
:show-file-name true
|
||||
:key (:page-id tgroup)}])]
|
||||
[:*
|
||||
(when (seq pending)
|
||||
[:div {:class (stl/css :review-requests)}
|
||||
[:span {:class (stl/css :review-requests-title)}
|
||||
(tr "dashboard.pending-reviews.title")]
|
||||
(for [row pending]
|
||||
[:button {:key (dm/str (:id row))
|
||||
:class (stl/css :review-request-item)
|
||||
:data-pr-id (dm/str (:id row))
|
||||
:on-click on-open-review}
|
||||
[:span {:class (stl/css :review-request-name)} (:title row)]
|
||||
[:span {:class (stl/css :review-request-route)}
|
||||
(dm/str (:branch-name row) " → " (:target-name row))]])])
|
||||
|
||||
[:div {:class (stl/css :thread-groups-placeholder)}
|
||||
comments-icon-svg
|
||||
(tr "labels.no-comments-available")])]]]))
|
||||
(cond
|
||||
(seq tgroups)
|
||||
[:div {:class (stl/css :thread-groups)}
|
||||
[:> cmt/comment-dashboard-thread-group*
|
||||
{:group (first tgroups)
|
||||
:on-thread-click on-navigate
|
||||
:show-file-name true}]
|
||||
(for [tgroup (rest tgroups)]
|
||||
[:> cmt/comment-dashboard-thread-group*
|
||||
{:group tgroup
|
||||
:on-thread-click on-navigate
|
||||
:show-file-name true
|
||||
:key (:page-id tgroup)}])]
|
||||
|
||||
(empty? pending)
|
||||
[:div {:class (stl/css :thread-groups-placeholder)}
|
||||
comments-icon-svg
|
||||
(tr "labels.no-comments-available")])]]]]))
|
||||
|
||||
@ -112,3 +112,51 @@
|
||||
border: deprecated.$s-1 solid;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pending review requests (pull requests awaiting my review)
|
||||
|
||||
.review-requests {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-4;
|
||||
padding: deprecated.$s-8 deprecated.$s-12;
|
||||
border-bottom: deprecated.$s-1 solid var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.review-requests-title {
|
||||
color: var(--color-foreground-secondary);
|
||||
font-size: deprecated.$fs-11;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.review-request-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-2;
|
||||
padding: deprecated.$s-8;
|
||||
border: none;
|
||||
border-radius: deprecated.$s-8;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.review-request-name {
|
||||
color: var(--color-foreground-primary);
|
||||
font-size: deprecated.$fs-14;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.review-request-route {
|
||||
color: var(--color-foreground-secondary);
|
||||
font-size: deprecated.$fs-11;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
(ns app.main.ui.dashboard.file-menu
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.config :as cf]
|
||||
[app.main.data.common :as dcm]
|
||||
[app.main.data.dashboard :as dd]
|
||||
[app.main.data.event :as-alias ev]
|
||||
@ -301,6 +302,17 @@
|
||||
[:> menu-item* {:id "file-duplicate" :on-action on-duplicate}
|
||||
(tr "dashboard.duplicate")])
|
||||
|
||||
(when (and (not is-lib-page?)
|
||||
(not is-search-page?)
|
||||
can-edit
|
||||
(contains? cf/flags :branching))
|
||||
[:> menu-item* {:id "file-create-branch"
|
||||
:on-action (fn []
|
||||
(modal/show! :create-branch
|
||||
{:file-name (:name file)
|
||||
:file-id (:id file)}))}
|
||||
(tr "dashboard.create-branch")])
|
||||
|
||||
(when (and (not is-lib-page?)
|
||||
(not is-search-page?)
|
||||
(or (seq current-projects) (seq other-teams))
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.color-bullet :as bc]
|
||||
[app.main.ui.dashboard.branches-popover :refer [branches-popover*]]
|
||||
[app.main.ui.dashboard.file-menu :refer [file-menu* file-menu-items*]]
|
||||
[app.main.ui.dashboard.import :refer [use-import-file]]
|
||||
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
|
||||
@ -504,6 +505,11 @@
|
||||
[:div {:class (stl/css :grid-item-badge)}
|
||||
[:> icon* {:icon-id i/library}]])
|
||||
|
||||
(when (and (contains? cf/flags :branching)
|
||||
(not library-view?)
|
||||
(pos? (or (:branches-count file) 0)))
|
||||
[:> branches-popover* {:file file :n (:branches-count file)}])
|
||||
|
||||
[:div {:class (stl/css :grid-item-info)}
|
||||
[:div {:class (stl/css :grid-item-meta)}
|
||||
(if ^boolean editing?
|
||||
|
||||
@ -409,3 +409,13 @@ $thumbnail-default-height: px2rem(168);
|
||||
.grid-loader {
|
||||
--icon-width: calc(var(--thumbnail-width, #{$thumbnail-default-width}) * 0.25);
|
||||
}
|
||||
|
||||
.branches-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-inline-end: var(--sp-xs);
|
||||
padding: var(--sp-xxs) var(--sp-xs);
|
||||
border-radius: 8px;
|
||||
color: var(--color-accent-primary);
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
@ -78,8 +78,11 @@ $grayish-red: #bfbfbf;
|
||||
--color-accent-error: #{$red-400};
|
||||
--color-background-error: #{$red-200};
|
||||
--color-foreground-error: #{$red-500};
|
||||
--color-foreground-success: #{color.scale($green-500, $lightness: 30%)};
|
||||
--color-foreground-warning: #{color.scale($orange-500, $lightness: 10%)};
|
||||
--color-accent-info: #{$blue-500};
|
||||
--color-background-info: #{$blue-200};
|
||||
--color-foreground-info: #{color.scale($blue-500, $lightness: 28%)};
|
||||
--color-background-default: #{$white};
|
||||
--color-accent-default: #{$gray-100};
|
||||
--color-icon-default: #{$blue-teal-700};
|
||||
@ -123,8 +126,11 @@ $grayish-red: #bfbfbf;
|
||||
--color-accent-error: #{$red-400};
|
||||
--color-background-error: #{$red-950};
|
||||
--color-foreground-error: #{$red-500};
|
||||
--color-foreground-success: #{color.scale($green-500, $lightness: 30%)};
|
||||
--color-foreground-warning: #{color.scale($orange-500, $lightness: 10%)};
|
||||
--color-accent-info: #{$blue-500};
|
||||
--color-background-info: #{$blue-950};
|
||||
--color-foreground-info: #{color.scale($blue-500, $lightness: 28%)};
|
||||
--color-background-default: #{$gray-950};
|
||||
--color-accent-default: #{$gray-800};
|
||||
--color-icon-default: #{$grayish-blue-500};
|
||||
|
||||
@ -52,6 +52,7 @@
|
||||
(def ^:icon-id arrow "arrow")
|
||||
(def ^:icon-id arrow-down "arrow-down")
|
||||
(def ^:icon-id arrow-left "arrow-left")
|
||||
(def ^:icon-id arrow-long-right "arrow-long-right")
|
||||
(def ^:icon-id arrow-right "arrow-right")
|
||||
(def ^:icon-id arrow-up "arrow-up")
|
||||
(def ^:icon-id arrow-up-right "arrow-up-right")
|
||||
@ -153,6 +154,12 @@
|
||||
(def ^:icon-id folder "folder")
|
||||
(def ^:icon-id gap-horizontal "gap-horizontal")
|
||||
(def ^:icon-id gap-vertical "gap-vertical")
|
||||
(def ^:icon-id git-branch "git-branch")
|
||||
(def ^:icon-id git-branch-plus "git-branch-plus")
|
||||
(def ^:icon-id git-commit "git-commit")
|
||||
(def ^:icon-id git-commit-vertical "git-commit-vertical")
|
||||
(def ^:icon-id git-merge "git-merge")
|
||||
(def ^:icon-id git-pull-request-arrow "git-pull-request-arrow")
|
||||
(def ^:icon-id graphics "graphics")
|
||||
(def ^:icon-id grid "grid")
|
||||
(def ^:icon-id grid-column "grid-column")
|
||||
@ -219,6 +226,7 @@
|
||||
(def ^:icon-id padding-top "padding-top")
|
||||
(def ^:icon-id padding-top-bottom "padding-top-bottom")
|
||||
(def ^:icon-id path "path")
|
||||
(def ^:icon-id pencil "pencil")
|
||||
(def ^:icon-id pentool "pentool")
|
||||
(def ^:icon-id percentage "percentage")
|
||||
(def ^:icon-id picker "picker")
|
||||
@ -300,6 +308,7 @@
|
||||
(def ^:icon-id to-corner "to-corner")
|
||||
(def ^:icon-id to-curve "to-curve")
|
||||
(def ^:icon-id tree "tree")
|
||||
(def ^:icon-id triangle-alert "triangle-alert")
|
||||
(def ^:icon-id unlock "unlock")
|
||||
(def ^:icon-id user "user")
|
||||
(def ^:icon-id variant "variant")
|
||||
|
||||
@ -623,11 +623,12 @@
|
||||
;; --- Component: Viewer
|
||||
|
||||
(mf/defc viewer*
|
||||
[{:keys [file-id share-id page-id] :as props}]
|
||||
(mf/with-effect [file-id page-id share-id]
|
||||
[{:keys [file-id share-id pr-id page-id] :as props}]
|
||||
(mf/with-effect [file-id page-id share-id pr-id]
|
||||
(let [params {:file-id file-id
|
||||
:page-id page-id
|
||||
:share-id share-id}]
|
||||
:share-id share-id
|
||||
:pr-id pr-id}]
|
||||
(st/emit! (dv/initialize params))
|
||||
(fn []
|
||||
(st/emit! (dv/finalize params)))))
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
[app.main.data.persistence :as dps]
|
||||
[app.main.data.plugins :as dpl]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.data.workspace.pull-requests :as dwpr]
|
||||
[app.main.features :as features]
|
||||
[app.main.fonts :as fonts]
|
||||
[app.main.refs :as refs]
|
||||
@ -32,7 +33,9 @@
|
||||
[app.main.ui.workspace.palette :refer [palette*]]
|
||||
[app.main.ui.workspace.plugins]
|
||||
[app.main.ui.workspace.sidebar :refer [sidebar*]]
|
||||
[app.main.ui.workspace.sidebar.branches :refer [branch-context-banner*]]
|
||||
[app.main.ui.workspace.sidebar.history :refer [history-toolbox*]]
|
||||
[app.main.ui.workspace.sidebar.pull-requests :refer [pr-review-banner*]]
|
||||
[app.main.ui.workspace.tokens.export]
|
||||
[app.main.ui.workspace.tokens.export.modal]
|
||||
[app.main.ui.workspace.tokens.import]
|
||||
@ -86,8 +89,18 @@
|
||||
(fn [size]
|
||||
(reset! palete-size size)))
|
||||
|
||||
node-ref (use-resize-observer on-resize)]
|
||||
node-ref (use-resize-observer on-resize)
|
||||
|
||||
pr-preview (mf/deref refs/pull-request-preview)]
|
||||
[:*
|
||||
(when (not ^boolean hide-ui?)
|
||||
;; inside a review sandbox the pull request banner replaces the
|
||||
;; branch banner: the user is reviewing a pinned snapshot, not
|
||||
;; editing the branch
|
||||
(if (some? pr-preview)
|
||||
[:> pr-review-banner* {}]
|
||||
[:> branch-context-banner* {:file-id (get file :id)}]))
|
||||
|
||||
(when (not ^boolean hide-ui?)
|
||||
[:> palette* {:layout layout
|
||||
:on-change-size on-resize-palette}])
|
||||
@ -195,7 +208,7 @@
|
||||
|
||||
(mf/defc workspace*
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [team-id project-id file-id page-id layout-name]}]
|
||||
[{:keys [team-id project-id file-id page-id pr-id layout-name]}]
|
||||
|
||||
(let [layout (mf/deref refs/workspace-layout)
|
||||
wglobal (mf/deref refs/workspace-global)
|
||||
@ -249,6 +262,15 @@
|
||||
(when (and file-loaded? (not page-id))
|
||||
(st/emit! (dcm/go-to-workspace :file-id file-id ::rt/replace true))))
|
||||
|
||||
;; Review context: once the (branch) file is initialized, attach the
|
||||
;; pull request info so the review banner and actions show up; the
|
||||
;; file itself stays a normal, editable workspace file. The event is
|
||||
;; idempotent per pr-id, so page navigation re-running this effect
|
||||
;; is harmless.
|
||||
(mf/with-effect [file-id page-id pr-id file-loaded?]
|
||||
(when (and file-loaded? page-id pr-id)
|
||||
(st/emit! (dwpr/initialize-pull-request-preview pr-id))))
|
||||
|
||||
(mf/with-effect [file-id page-id]
|
||||
(reset! first-frame-rendered? false))
|
||||
|
||||
|
||||
@ -15,6 +15,8 @@
|
||||
[app.main.refs :as refs]
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
||||
[app.main.ui.ds.tooltip :refer [tooltip*]]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.main.ui.workspace.main-menu :as main-menu]
|
||||
[app.util.dom :as dom]
|
||||
@ -31,6 +33,32 @@
|
||||
file-name (:name file)
|
||||
project-id (:id project)
|
||||
shared? (:is-shared file)
|
||||
|
||||
;; When the open file is a branch, `branch-ctx` is non-nil (the
|
||||
;; main file is never a branch, so this is also how we tell them
|
||||
;; apart). On a branch we show "<main name> (<branch name>)" and a
|
||||
;; badge; on main we keep the plain file name.
|
||||
branch-ctx (mf/deref refs/branch-context)
|
||||
branch? (and (some? branch-ctx) (some? (:source-name branch-ctx)))
|
||||
|
||||
;; When a pull request review sandbox is active the header gets a
|
||||
;; distinct badge and title so the snapshot is never mistaken for
|
||||
;; the editable branch.
|
||||
pr-preview (mf/deref refs/pull-request-preview)
|
||||
review? (some? pr-preview)
|
||||
pr-title (get-in pr-preview [:info :title] "")
|
||||
|
||||
display-name
|
||||
(cond
|
||||
^boolean review?
|
||||
(tr "workspace.pull-requests.header-title" pr-title)
|
||||
|
||||
^boolean branch?
|
||||
(dm/str (:source-name branch-ctx) " (" (:name branch-ctx) ")")
|
||||
|
||||
:else
|
||||
file-name)
|
||||
|
||||
persistence
|
||||
(mf/deref refs/persistence)
|
||||
|
||||
@ -108,8 +136,10 @@
|
||||
:default-value (:name file "")}]
|
||||
[:div
|
||||
{:class (stl/css :file-name)
|
||||
:title file-name
|
||||
:on-double-click start-editing-name}
|
||||
:title display-name
|
||||
;; on a branch the name is fixed ("File (Branch)"); renaming here
|
||||
;; would be confusing, so double-click editing is disabled.
|
||||
:on-double-click (when-not ^boolean branch? start-editing-name)}
|
||||
;; Persistence state widget
|
||||
[:div {:class (case persistence-status
|
||||
:pending (stl/css :status-notification :pending-status)
|
||||
@ -132,7 +162,19 @@
|
||||
:retrying deprecated-icon/status-alert
|
||||
:error deprecated-icon/status-wrong
|
||||
nil)]
|
||||
[:div {:class (stl/css :file-name-label)} file-name]])]
|
||||
(cond
|
||||
^boolean review?
|
||||
[:> tooltip* {:content (tr "workspace.pull-requests.header-badge-tooltip" pr-title)
|
||||
:placement "bottom"}
|
||||
[:span {:class (stl/css :review-badge)}
|
||||
[:> icon* {:icon-id i/git-pull-request-arrow :size "s"}]]]
|
||||
|
||||
^boolean branch?
|
||||
[:> tooltip* {:content (tr "workspace.branches.header-badge-tooltip" (:name branch-ctx))
|
||||
:placement "bottom"}
|
||||
[:span {:class (stl/css :branch-badge)}
|
||||
[:> icon* {:icon-id i/git-branch :size "s"}]]])
|
||||
[:div {:class (stl/css :file-name-label)} display-name]])]
|
||||
(when ^boolean shared?
|
||||
[:span {:class (stl/css :shared-badge)} deprecated-icon/library])
|
||||
[:div {:class (stl/css :menu-section)}
|
||||
|
||||
@ -82,6 +82,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
.branch-badge {
|
||||
@include deprecated.flex-center;
|
||||
|
||||
flex-shrink: 0;
|
||||
width: deprecated.$s-16;
|
||||
height: deprecated.$s-16;
|
||||
margin-right: deprecated.$s-6;
|
||||
border-radius: deprecated.$br-4;
|
||||
background-color: var(--button-secondary-background-color-rest);
|
||||
color: var(--button-secondary-foreground-color-rest);
|
||||
}
|
||||
|
||||
// pull request review sandbox: accent-colored so the read-only snapshot
|
||||
// is never mistaken for the editable branch
|
||||
.review-badge {
|
||||
@include deprecated.flex-center;
|
||||
|
||||
flex-shrink: 0;
|
||||
width: deprecated.$s-16;
|
||||
height: deprecated.$s-16;
|
||||
margin-right: deprecated.$s-6;
|
||||
border-radius: deprecated.$br-4;
|
||||
background-color: var(--button-primary-background-color-rest);
|
||||
color: var(--button-primary-foreground-color-rest);
|
||||
}
|
||||
|
||||
.shared-badge {
|
||||
@include deprecated.flex-center;
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
[app.main.data.profile :as du]
|
||||
[app.main.data.shortcuts :as scd]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.data.workspace.branches :as dwb]
|
||||
[app.main.data.workspace.comments :as dwcm]
|
||||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.data.workspace.mcp :as mcp]
|
||||
@ -628,6 +629,18 @@
|
||||
(when (kbd/enter? event)
|
||||
(on-show-version-history event))))
|
||||
|
||||
on-show-branches
|
||||
(mf/use-fn
|
||||
(fn [_]
|
||||
(st/emit! (dwb/show-branches-panel))))
|
||||
|
||||
on-show-branches-key-down
|
||||
(mf/use-fn
|
||||
(mf/deps on-show-branches)
|
||||
(fn [event]
|
||||
(when (kbd/enter? event)
|
||||
(on-show-branches event))))
|
||||
|
||||
on-pin-version
|
||||
(mf/use-fn
|
||||
(fn [_]
|
||||
@ -718,6 +731,15 @@
|
||||
(tr "dashboard.show-version-history")]
|
||||
[:> shortcuts* {:id :toggle-history}]]
|
||||
|
||||
(when (contains? cf/flags :branching)
|
||||
[:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item)
|
||||
:on-click on-show-branches
|
||||
:on-key-down on-show-branches-key-down
|
||||
:id "file-menu-show-branches"}
|
||||
[:span {:class (stl/css :item-name)}
|
||||
(tr "workspace.branches.menu")]
|
||||
[:> shortcuts* {:id :open-branches}]])
|
||||
|
||||
[:div {:class (stl/css :separator)}]])
|
||||
|
||||
[:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item)
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
[app.main.data.style-dictionary :as sd]
|
||||
[app.main.data.tokenscript :as ts]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.data.workspace.branches :as dwb]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
@ -33,6 +34,7 @@
|
||||
[app.main.ui.workspace.left-header :refer [left-header*]]
|
||||
[app.main.ui.workspace.right-header :refer [right-header*]]
|
||||
[app.main.ui.workspace.sidebar.assets :refer [assets-toolbox*]]
|
||||
[app.main.ui.workspace.sidebar.branches :refer [branches-toolbox*]]
|
||||
[app.main.ui.workspace.sidebar.debug :refer [debug-panel*]]
|
||||
[app.main.ui.workspace.sidebar.history :refer [history-toolbox*]]
|
||||
[app.main.ui.workspace.sidebar.layers :refer [layers-toolbox*]]
|
||||
@ -242,7 +244,7 @@
|
||||
::mf/memo true}
|
||||
[]
|
||||
(let [selected*
|
||||
(hooks/use-persisted-state ::history-sidebar "history")
|
||||
(hooks/use-shared-state dwb/history-sidebar-tab-key "history")
|
||||
|
||||
selected
|
||||
(deref selected*)
|
||||
@ -253,10 +255,13 @@
|
||||
|
||||
tabs
|
||||
(mf/with-memo []
|
||||
[{:label (tr "workspace.versions.tab.history")
|
||||
:id "history"}
|
||||
{:label (tr "workspace.versions.tab.actions")
|
||||
:id "actions"}])
|
||||
(cond-> [{:label (tr "workspace.versions.tab.history")
|
||||
:id "history"}
|
||||
{:label (tr "workspace.versions.tab.actions")
|
||||
:id "actions"}]
|
||||
(contains? cf/flags :branching)
|
||||
(conj {:label (tr "workspace.branches.tab")
|
||||
:id "branches"})))
|
||||
|
||||
button
|
||||
(mf/with-memo []
|
||||
@ -280,7 +285,11 @@
|
||||
|
||||
"actions"
|
||||
[:article {:class (stl/css :versions-tab)}
|
||||
[:> history-toolbox*]])]))
|
||||
[:> history-toolbox*]]
|
||||
|
||||
"branches"
|
||||
[:article {:class (stl/css :history-tab)}
|
||||
[:> branches-toolbox* {}]])]))
|
||||
|
||||
(mf/defc right-sidebar*
|
||||
[{:keys [layout section file-id page-id drawing-tool active-tokens] :as props}]
|
||||
|
||||
1793
frontend/src/app/main/ui/workspace/sidebar/branches.cljs
Normal file
1524
frontend/src/app/main/ui/workspace/sidebar/branches.scss
Normal file
614
frontend/src/app/main/ui/workspace/sidebar/pull_requests.cljs
Normal file
@ -0,0 +1,614 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.main.ui.workspace.sidebar.pull-requests
|
||||
"Pull request UI. A pull request is presented as a STATE of its branch
|
||||
(one branch → at most one open pull request), so there is no separate
|
||||
pull request list: the branch entries in the sidebar surface the review
|
||||
state (see `app.main.ui.workspace.sidebar.branches`), and this namespace
|
||||
provides the shared pieces — state badge, action helpers, the creation /
|
||||
review / details modals and the review-sandbox banner."
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.team :as dtm]
|
||||
[app.main.data.workspace.pull-requests :as dwpr]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.dropdown-menu :refer [dropdown-menu*
|
||||
dropdown-menu-item*]]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||
[app.main.ui.ds.controls.input :refer [input*]]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
|
||||
[app.main.ui.ds.product.avatar :refer [avatar*]]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
;; --- Derived display state
|
||||
|
||||
(defn- display-state
|
||||
"The single most relevant label for a pull request: its stored status
|
||||
for finished ones, the aggregate review verdict for open ones."
|
||||
[{:keys [status review-state]}]
|
||||
(case status
|
||||
"merged" :merged
|
||||
"closed" :closed
|
||||
(case review-state
|
||||
"changes-requested" :changes-requested
|
||||
"approved" :approved
|
||||
"in-review" :in-review
|
||||
:open)))
|
||||
|
||||
(def ^:private state->label
|
||||
{:open "workspace.pull-requests.state.open"
|
||||
:in-review "workspace.pull-requests.state.in-review"
|
||||
:approved "workspace.pull-requests.state.approved"
|
||||
:changes-requested "workspace.pull-requests.state.changes-requested"
|
||||
:merged "workspace.pull-requests.state.merged"
|
||||
:closed "workspace.pull-requests.state.closed"})
|
||||
|
||||
(mf/defc pr-state-badge*
|
||||
[{:keys [pr]}]
|
||||
(let [state (display-state pr)]
|
||||
[:span {:class (stl/css-case :pr-badge true
|
||||
:badge-open (= state :open)
|
||||
:badge-in-review (= state :in-review)
|
||||
:badge-approved (= state :approved)
|
||||
:badge-changes (= state :changes-requested)
|
||||
:badge-merged (= state :merged)
|
||||
:badge-closed (= state :closed))}
|
||||
(tr (get state->label state))]))
|
||||
|
||||
(defn current-approvals
|
||||
"[current-approvals total-reviewers] of a pull request; stale verdicts
|
||||
do not count."
|
||||
[{:keys [reviews]}]
|
||||
[(count (filter #(and (= "approved" (:state %)) (not (:stale %))) reviews))
|
||||
(count reviews)])
|
||||
|
||||
(defn- pr->branch
|
||||
"A branch-row-shaped map for the pull request's branch, enough for the
|
||||
branching compare / merge dialogs (they key on :id and :name; the
|
||||
counts feed their action buttons)."
|
||||
[pr]
|
||||
{:id (:file-branch-id pr)
|
||||
:name (:branch-name pr)
|
||||
:status "open"
|
||||
:ahead (:ahead pr)
|
||||
:behind (:behind pr)
|
||||
:conflicts (:conflicts pr)})
|
||||
|
||||
(defn open-compare!
|
||||
"Open the branching compare dialog on the pull request's branch, so the
|
||||
proposed changes can be inspected. Like merging, it diffs the LIVE
|
||||
branch against main."
|
||||
[pr]
|
||||
(modal/show! :branch-compare {:branch (pr->branch pr)}))
|
||||
|
||||
(defn confirm-merge!
|
||||
"Open the branching merge dialog for the pull request's branch:
|
||||
accepting a pull request goes through the exact same integration flow
|
||||
(confirmation, conflict resolution, safety snapshot) as merging the
|
||||
branch directly. Merging always integrates the LIVE branch state, so
|
||||
an outdated pull request merges the newest changes, not the snapshot."
|
||||
[pr]
|
||||
(modal/show! :merge-branch {:branch (pr->branch pr)}))
|
||||
|
||||
(defn confirm-close!
|
||||
"Ask for confirmation before cancelling a pull request: the review is
|
||||
discarded and the branch simply goes back to being a normal branch
|
||||
(it is NOT deleted)."
|
||||
[pr]
|
||||
(st/emit! (modal/show {:type :confirm
|
||||
:title (tr "workspace.pull-requests.close.title")
|
||||
:message (tr "workspace.pull-requests.close.message" (:title pr))
|
||||
:accept-label (tr "workspace.pull-requests.actions.close")
|
||||
:accept-style :danger
|
||||
:on-accept (fn [_] (st/emit! (dwpr/close-pull-request (:id pr))))})))
|
||||
|
||||
;; --- Create pull request dialog (modal)
|
||||
|
||||
(mf/defc create-pull-request-dialog*
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :create-pull-request}
|
||||
[{:keys [branch]}]
|
||||
(let [profile (mf/deref refs/profile)
|
||||
team (mf/deref refs/team)
|
||||
members (->> (get team :members)
|
||||
(filter :is-active)
|
||||
(remove #(= (:id %) (:id profile))))
|
||||
|
||||
title* (mf/use-state (or (:name branch) ""))
|
||||
title (deref title*)
|
||||
description* (mf/use-state "")
|
||||
description (deref description*)
|
||||
selected* (mf/use-state #{})
|
||||
selected (deref selected*)
|
||||
valid? (not (str/blank? title))
|
||||
|
||||
on-title-change
|
||||
(mf/use-fn #(reset! title* (dom/get-target-val %)))
|
||||
|
||||
on-description-change
|
||||
(mf/use-fn #(reset! description* (dom/get-target-val %)))
|
||||
|
||||
on-toggle-reviewer
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(let [id (-> (dom/get-current-target event)
|
||||
(dom/get-data "reviewer-id")
|
||||
(uuid/parse*))]
|
||||
(swap! selected* #(if (contains? % id) (disj % id) (conj % id))))))
|
||||
|
||||
on-close
|
||||
(mf/use-fn #(st/emit! (modal/hide)))
|
||||
|
||||
on-submit
|
||||
(mf/use-fn
|
||||
(mf/deps branch title description selected valid?)
|
||||
(fn [_]
|
||||
(when valid?
|
||||
(st/emit! (dwpr/create-pull-request
|
||||
(:id branch)
|
||||
{:title (str/trim title)
|
||||
:description (str/trim description)
|
||||
:reviewers (vec selected)})
|
||||
(modal/hide)))))]
|
||||
|
||||
;; the reviewer picker needs the team members loaded
|
||||
(mf/with-effect []
|
||||
(st/emit! (dtm/fetch-members)))
|
||||
|
||||
[:div {:class (stl/css :modal-overlay)}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:div {:class (stl/css :modal-header-icon)}
|
||||
[:> i/icon* {:icon-id i/git-pull-request-arrow}]]
|
||||
[:div {:class (stl/css :modal-header-text)}
|
||||
[:h2 {:class (stl/css :modal-title)} (tr "workspace.pull-requests.create.title")]
|
||||
[:span {:class (stl/css :modal-subtitle)}
|
||||
(tr "workspace.pull-requests.create.subtitle" (or (:name branch) ""))]]
|
||||
[:> button* {:variant "ghost"
|
||||
:icon i/close
|
||||
:aria-label (tr "labels.close")
|
||||
:on-click on-close}]]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:> input* {:label (tr "workspace.pull-requests.create.name-label")
|
||||
:icon i/git-pull-request-arrow
|
||||
:placeholder (tr "workspace.pull-requests.create.name-placeholder")
|
||||
:value title
|
||||
:on-change on-title-change}]
|
||||
|
||||
[:div {:class (stl/css :field)}
|
||||
[:label {:class (stl/css :field-label)}
|
||||
(tr "workspace.pull-requests.create.description-label")]
|
||||
[:textarea {:class (stl/css :textarea)
|
||||
:value description
|
||||
:placeholder (tr "workspace.pull-requests.create.description-placeholder")
|
||||
:on-change on-description-change
|
||||
:rows 3}]]
|
||||
|
||||
[:div {:class (stl/css :field)}
|
||||
[:label {:class (stl/css :field-label)}
|
||||
(tr "workspace.pull-requests.create.reviewers-label")]
|
||||
(if (empty? members)
|
||||
[:span {:class (stl/css :reviewers-empty)}
|
||||
(tr "workspace.pull-requests.create.no-members")]
|
||||
[:ul {:class (stl/css :reviewers-list)}
|
||||
(for [member members]
|
||||
[:li {:key (dm/str (:id member))}
|
||||
[:button {:class (stl/css-case :reviewer-option true
|
||||
:is-selected (contains? selected (:id member)))
|
||||
:data-reviewer-id (dm/str (:id member))
|
||||
:on-click on-toggle-reviewer}
|
||||
[:> avatar* {:profile member :variant "S"}]
|
||||
[:span {:class (stl/css :reviewer-name)} (:fullname member)]
|
||||
(when (contains? selected (:id member))
|
||||
[:> i/icon* {:icon-id i/tick :size "s"}])]])])]
|
||||
|
||||
[:> context-notification* {:level :info :type :context}
|
||||
(tr "workspace.pull-requests.create.info")]]
|
||||
|
||||
[:div {:class (stl/css :modal-footer)}
|
||||
[:> button* {:variant "secondary" :on-click on-close}
|
||||
(tr "labels.cancel")]
|
||||
[:> button* {:variant "primary"
|
||||
:icon i/git-pull-request-arrow
|
||||
:disabled (not valid?)
|
||||
:on-click on-submit}
|
||||
(tr "workspace.pull-requests.create.submit")]]]]))
|
||||
|
||||
;; --- Review dialog (approve / request changes)
|
||||
|
||||
(mf/defc pull-request-review-dialog*
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :pull-request-review}
|
||||
[{:keys [pr]}]
|
||||
(let [verdict* (mf/use-state "approved")
|
||||
verdict (deref verdict*)
|
||||
comment* (mf/use-state "")
|
||||
comment (deref comment*)
|
||||
|
||||
on-comment-change
|
||||
(mf/use-fn #(reset! comment* (dom/get-target-val %)))
|
||||
|
||||
on-select-verdict
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(reset! verdict* (-> (dom/get-current-target event)
|
||||
(dom/get-data "verdict")))))
|
||||
|
||||
on-close
|
||||
(mf/use-fn #(st/emit! (modal/hide)))
|
||||
|
||||
on-submit
|
||||
(mf/use-fn
|
||||
(mf/deps pr verdict comment)
|
||||
(fn [_]
|
||||
(st/emit! (dwpr/submit-review (:id pr) verdict
|
||||
(let [c (str/trim comment)]
|
||||
(when-not (str/blank? c) c)))
|
||||
(modal/hide))))]
|
||||
|
||||
[:div {:class (stl/css :modal-overlay)}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:div {:class (stl/css :modal-header-icon)}
|
||||
[:> i/icon* {:icon-id i/git-pull-request-arrow}]]
|
||||
[:div {:class (stl/css :modal-header-text)}
|
||||
[:h2 {:class (stl/css :modal-title)} (tr "workspace.pull-requests.review.title")]
|
||||
[:span {:class (stl/css :modal-subtitle)} (:title pr)]]
|
||||
[:> button* {:variant "ghost"
|
||||
:icon i/close
|
||||
:aria-label (tr "labels.close")
|
||||
:on-click on-close}]]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :verdict-options)}
|
||||
[:button {:class (stl/css-case :verdict-option true
|
||||
:is-selected (= verdict "approved"))
|
||||
:data-verdict "approved"
|
||||
:on-click on-select-verdict}
|
||||
[:> i/icon* {:icon-id i/tick}]
|
||||
[:div {:class (stl/css :verdict-option-text)}
|
||||
[:span {:class (stl/css :verdict-option-title)}
|
||||
(tr "workspace.pull-requests.review.approve")]
|
||||
[:span {:class (stl/css :verdict-option-hint)}
|
||||
(tr "workspace.pull-requests.review.approve-hint")]]]
|
||||
|
||||
[:button {:class (stl/css-case :verdict-option true
|
||||
:is-selected (= verdict "changes-requested"))
|
||||
:data-verdict "changes-requested"
|
||||
:on-click on-select-verdict}
|
||||
[:> i/icon* {:icon-id i/triangle-alert}]
|
||||
[:div {:class (stl/css :verdict-option-text)}
|
||||
[:span {:class (stl/css :verdict-option-title)}
|
||||
(tr "workspace.pull-requests.review.request-changes")]
|
||||
[:span {:class (stl/css :verdict-option-hint)}
|
||||
(tr "workspace.pull-requests.review.request-changes-hint")]]]]
|
||||
|
||||
[:div {:class (stl/css :field)}
|
||||
[:label {:class (stl/css :field-label)}
|
||||
(tr "workspace.pull-requests.review.comment-label")]
|
||||
[:textarea {:class (stl/css :textarea)
|
||||
:value comment
|
||||
:placeholder (tr "workspace.pull-requests.review.comment-placeholder")
|
||||
:on-change on-comment-change
|
||||
:rows 3}]]]
|
||||
|
||||
[:div {:class (stl/css :modal-footer)}
|
||||
[:> button* {:variant "secondary" :on-click on-close}
|
||||
(tr "labels.cancel")]
|
||||
[:> button* {:variant "primary"
|
||||
:on-click on-submit}
|
||||
(tr "workspace.pull-requests.review.submit")]]]]))
|
||||
|
||||
;; --- Details dialog
|
||||
|
||||
(mf/defc pull-request-info-dialog*
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :pull-request-info}
|
||||
[{:keys [pr]}]
|
||||
(let [profiles (mf/deref refs/profiles)
|
||||
profile (mf/deref refs/profile)
|
||||
team (mf/deref refs/team)
|
||||
author (get profiles (:created-by pr))
|
||||
author? (= (:created-by pr) (:id profile))
|
||||
admin? (boolean (get-in team [:permissions :is-admin]))
|
||||
can-merge? (boolean (get-in team [:permissions :can-edit]))
|
||||
open? (= "open" (:status pr))
|
||||
closed? (= "closed" (:status pr))
|
||||
;; already inside this pull request's review sandbox: offering to
|
||||
;; open it again would be a no-op
|
||||
reviewing? (= (:id pr)
|
||||
(:pr-id (mf/deref refs/pull-request-preview)))
|
||||
[approvals total] (current-approvals pr)
|
||||
|
||||
on-close
|
||||
(mf/use-fn #(st/emit! (modal/hide)))
|
||||
|
||||
on-merge
|
||||
(mf/use-fn
|
||||
(mf/deps pr)
|
||||
(fn [_]
|
||||
(st/emit! (modal/hide))
|
||||
(confirm-merge! pr)))
|
||||
|
||||
on-open-review
|
||||
(mf/use-fn
|
||||
(mf/deps pr)
|
||||
(fn [_]
|
||||
(st/emit! (modal/hide)
|
||||
(dwpr/open-pull-request pr))))
|
||||
|
||||
on-close-pr
|
||||
(mf/use-fn
|
||||
(mf/deps pr)
|
||||
(fn [_]
|
||||
;; the confirmation dialog replaces this modal
|
||||
(confirm-close! pr)))
|
||||
|
||||
on-reopen-pr
|
||||
(mf/use-fn
|
||||
(mf/deps pr)
|
||||
(fn [_]
|
||||
(st/emit! (modal/hide)
|
||||
(dwpr/reopen-pull-request (:id pr)))))]
|
||||
|
||||
[:div {:class (stl/css :modal-overlay)}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:div {:class (stl/css :modal-header-icon)}
|
||||
[:> i/icon* {:icon-id i/git-pull-request-arrow}]]
|
||||
[:div {:class (stl/css :modal-header-text)}
|
||||
[:h2 {:class (stl/css :modal-title)} (:title pr)]
|
||||
[:div {:class (stl/css :modal-subtitle-row)}
|
||||
[:> pr-state-badge* {:pr pr}]
|
||||
[:span {:class (stl/css :modal-subtitle)}
|
||||
;; the route reads branch → target BRANCH (reviews always merge
|
||||
;; into main); the target file name would be redundant here,
|
||||
;; inside the file itself
|
||||
(dm/str (:branch-name pr) " → " (tr "workspace.branches.main"))]
|
||||
(when (and open? (:outdated pr))
|
||||
[:span {:class (stl/css :pr-badge :badge-outdated)}
|
||||
(tr "workspace.pull-requests.badge.outdated")])
|
||||
(when (and open? (pos? (or (:conflicts pr) 0)))
|
||||
[:span {:class (stl/css :pr-badge :badge-conflicts)}
|
||||
(tr "workspace.pull-requests.badge.conflicts" (dm/str (:conflicts pr)))])]]
|
||||
[:> button* {:variant "ghost"
|
||||
:icon i/close
|
||||
:aria-label (tr "labels.close")
|
||||
:on-click on-close}]]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :pr-info-card)}
|
||||
[:div {:class (stl/css :pr-info-grid)}
|
||||
[:div {:class (stl/css :pr-info-item)}
|
||||
[:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.author")]
|
||||
[:div {:class (stl/css :pr-info-value)}
|
||||
(when author [:> avatar* {:profile author :variant "S"}])
|
||||
[:span (get author :fullname "—")]]]
|
||||
|
||||
[:div {:class (stl/css :pr-info-item)}
|
||||
[:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.created-at")]
|
||||
[:div {:class (stl/css :pr-info-value)}
|
||||
[:span (ct/format-inst (:created-at pr) :localized-date-time)]]]
|
||||
|
||||
[:div {:class (stl/css :pr-info-item)}
|
||||
[:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.updated-at")]
|
||||
[:div {:class (stl/css :pr-info-value)}
|
||||
[:span (ct/format-inst (:review-updated-at pr) :localized-date-time)]]]
|
||||
|
||||
[:div {:class (stl/css :pr-info-item)}
|
||||
[:span {:class (stl/css :field-label)} (tr "workspace.pull-requests.info.approvals")]
|
||||
[:div {:class (stl/css :pr-info-value)}
|
||||
[:span (dm/str approvals "/" total)]]]]
|
||||
|
||||
(when (pos? total)
|
||||
[:div {:class (stl/css :field)}
|
||||
[:label {:class (stl/css :field-label)}
|
||||
(tr "workspace.pull-requests.info.reviewers")]
|
||||
[:ul {:class (stl/css :pr-info-reviewers)}
|
||||
(for [review (:reviews pr)]
|
||||
(let [reviewer (get profiles (:profile-id review))]
|
||||
[:li {:key (dm/str (:profile-id review))
|
||||
:class (stl/css :pr-info-reviewer)}
|
||||
(when reviewer [:> avatar* {:profile reviewer :variant "S"}])
|
||||
[:span {:class (stl/css :reviewer-name)}
|
||||
(get reviewer :fullname "—")]
|
||||
[:span {:class (stl/css-case :reviewer-state true
|
||||
:reviewer-approved (and (= "approved" (:state review))
|
||||
(not (:stale review)))
|
||||
:reviewer-changes (and (= "changes-requested" (:state review))
|
||||
(not (:stale review)))
|
||||
:reviewer-stale (:stale review))}
|
||||
(cond
|
||||
(:stale review)
|
||||
(tr "workspace.pull-requests.review-state.stale")
|
||||
|
||||
(= "approved" (:state review))
|
||||
(tr "workspace.pull-requests.review-state.approved")
|
||||
|
||||
(= "changes-requested" (:state review))
|
||||
(tr "workspace.pull-requests.review-state.changes-requested")
|
||||
|
||||
:else
|
||||
(tr "workspace.pull-requests.review-state.pending"))]
|
||||
(when-not (str/blank? (:comment review))
|
||||
[:p {:class (stl/css :reviewer-comment)} (:comment review)])]))]])]
|
||||
|
||||
(when-not (str/blank? (:description pr))
|
||||
[:div {:class (stl/css :pr-info-description-block)}
|
||||
[:label {:class (stl/css :field-label)}
|
||||
(tr "workspace.pull-requests.info.description")]
|
||||
[:p {:class (stl/css :pr-info-description)} (:description pr)]])]
|
||||
|
||||
[:div {:class (stl/css :modal-footer)}
|
||||
(when (and (or author? admin?) open?)
|
||||
[:> button* {:variant "secondary"
|
||||
:on-click on-close-pr}
|
||||
(tr "workspace.pull-requests.actions.close")])
|
||||
(when (and (or author? admin?) closed?)
|
||||
[:> button* {:variant "secondary"
|
||||
:on-click on-reopen-pr}
|
||||
(tr "workspace.pull-requests.actions.reopen")])
|
||||
(when (and open? (not reviewing?))
|
||||
[:> button* {:variant "primary"
|
||||
:icon i/arrow-up-right
|
||||
:on-click on-open-review}
|
||||
(tr "workspace.pull-requests.actions.open-review")])
|
||||
(when (and open? can-merge?)
|
||||
[:> button* {:variant "primary"
|
||||
:icon i/git-merge
|
||||
:on-click on-merge}
|
||||
(tr "workspace.pull-requests.actions.merge")])]]]))
|
||||
|
||||
;; --- Review sandbox banner (replaces the branch banner inside a sandbox)
|
||||
|
||||
(mf/defc pr-review-banner*
|
||||
[]
|
||||
(let [preview (mf/deref refs/pull-request-preview)
|
||||
pr (:info preview)
|
||||
profile (mf/deref refs/profile)
|
||||
team (mf/deref refs/team)
|
||||
author? (= (:created-by pr) (:id profile))
|
||||
reviewer? (some #(= (:profile-id %) (:id profile)) (:reviews pr))
|
||||
;; merging keeps requiring edition permissions on main: the
|
||||
;; sandbox file permissions are stripped, but the team role tells
|
||||
;; whether this user could integrate the branch
|
||||
can-merge? (boolean (get-in team [:permissions :can-edit]))
|
||||
[approvals total] (current-approvals pr)
|
||||
|
||||
collapsed* (mf/use-state false)
|
||||
collapsed? (deref collapsed*)
|
||||
on-toggle (mf/use-fn #(swap! collapsed* not))
|
||||
|
||||
;; secondary actions live behind the kebab menu so the banner
|
||||
;; only exposes the essential ones (details / compare / review)
|
||||
menu-open* (mf/use-state false)
|
||||
menu-open? (deref menu-open*)
|
||||
on-open-menu (mf/use-fn
|
||||
(fn [event]
|
||||
;; the dropdown closes itself on any outside
|
||||
;; click: the opening one must not reach it
|
||||
(dom/stop-propagation event)
|
||||
(reset! menu-open* true)))
|
||||
on-close-menu (mf/use-fn #(reset! menu-open* false))
|
||||
|
||||
on-info
|
||||
(mf/use-fn (mf/deps pr) #(modal/show! :pull-request-info {:pr pr}))
|
||||
|
||||
on-compare
|
||||
(mf/use-fn (mf/deps pr) #(open-compare! pr))
|
||||
|
||||
on-review
|
||||
(mf/use-fn (mf/deps pr) #(modal/show! :pull-request-review {:pr pr}))
|
||||
|
||||
on-exit
|
||||
(mf/use-fn
|
||||
(fn [_]
|
||||
(reset! menu-open* false)
|
||||
(st/emit! (dwpr/exit-pull-request-preview))))
|
||||
|
||||
on-merge
|
||||
(mf/use-fn
|
||||
(mf/deps pr)
|
||||
(fn [_]
|
||||
(reset! menu-open* false)
|
||||
(confirm-merge! pr)))
|
||||
|
||||
on-play
|
||||
(mf/use-fn
|
||||
(mf/deps pr)
|
||||
(fn [_]
|
||||
(reset! menu-open* false)
|
||||
(st/emit! (dwpr/open-pull-request-viewer pr))))
|
||||
|
||||
on-publish
|
||||
(mf/use-fn
|
||||
(mf/deps pr)
|
||||
(fn [_]
|
||||
(reset! menu-open* false)
|
||||
(st/emit! (dwpr/update-pull-request-snapshot (:id pr))
|
||||
(dwpr/refresh-pull-request-preview-info (:id pr)))))]
|
||||
|
||||
(when (some? pr)
|
||||
[:div {:class (stl/css-case :pr-banner true
|
||||
:is-collapsed collapsed?)}
|
||||
[:button {:class (stl/css :pr-banner-handle)
|
||||
:title (tr (if collapsed?
|
||||
"workspace.branches.banner.expand"
|
||||
"workspace.branches.banner.collapse"))
|
||||
:aria-label (tr (if collapsed?
|
||||
"workspace.branches.banner.expand"
|
||||
"workspace.branches.banner.collapse"))
|
||||
:on-click on-toggle}
|
||||
[:div {:class (stl/css :pr-banner-handle-btn)}]]
|
||||
[:div {:class (stl/css :pr-banner-content)}
|
||||
[:div {:class (stl/css :pr-banner-icon)}
|
||||
[:> i/icon* {:icon-id i/git-pull-request-arrow}]]
|
||||
[:div {:class (stl/css :pr-banner-text)}
|
||||
[:span {:class (stl/css :pr-banner-title)}
|
||||
(tr "workspace.pull-requests.banner.reviewing")]
|
||||
[:span {:class (stl/css :pr-banner-name)} (:title pr)]
|
||||
[:span {:class (stl/css :pr-banner-route)}
|
||||
(dm/str (:branch-name pr) " → " (tr "workspace.branches.main"))]]
|
||||
|
||||
[:div {:class (stl/css :pr-banner-badges)}
|
||||
[:> pr-state-badge* {:pr pr}]
|
||||
(when (:outdated pr)
|
||||
[:span {:class (stl/css :pr-badge :badge-outdated)}
|
||||
(tr "workspace.pull-requests.badge.outdated")])
|
||||
(when (pos? (or (:conflicts pr) 0))
|
||||
[:span {:class (stl/css :pr-badge :badge-conflicts)}
|
||||
(tr "workspace.pull-requests.badge.conflicts" (dm/str (:conflicts pr)))])
|
||||
(when (pos? total)
|
||||
[:span {:class (stl/css :pr-banner-approvals)}
|
||||
[:> i/icon* {:icon-id i/tick :size "s"}]
|
||||
(dm/str approvals "/" total)])]
|
||||
|
||||
[:div {:class (stl/css :pr-banner-actions)}
|
||||
[:> button* {:variant "secondary"
|
||||
:on-click on-info}
|
||||
(tr "workspace.pull-requests.menu.info")]
|
||||
|
||||
[:> button* {:variant "secondary"
|
||||
:icon i/switch
|
||||
:on-click on-compare}
|
||||
(tr "workspace.branches.compare")]
|
||||
|
||||
(when reviewer?
|
||||
[:> button* {:variant "primary"
|
||||
:icon i/tick
|
||||
:on-click on-review}
|
||||
(tr "workspace.pull-requests.banner.review")])
|
||||
|
||||
[:div {:class (stl/css :pr-banner-menu)}
|
||||
[:> icon-button* {:variant "ghost"
|
||||
:icon i/menu
|
||||
:aria-label (tr "labels.options")
|
||||
:on-click on-open-menu}]
|
||||
[:> dropdown-menu* {:show menu-open?
|
||||
:on-close on-close-menu
|
||||
:class (stl/css :pr-banner-options-dropdown)}
|
||||
[:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-play}
|
||||
(tr "workspace.pull-requests.banner.play")]
|
||||
(when (and author? (:outdated pr))
|
||||
[:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-publish}
|
||||
(tr "workspace.pull-requests.banner.publish")])
|
||||
(when can-merge?
|
||||
[:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-merge}
|
||||
(tr "workspace.pull-requests.actions.merge")])
|
||||
[:> dropdown-menu-item* {:class (stl/css :menu-option) :on-click on-exit}
|
||||
(tr "workspace.pull-requests.banner.exit")]]]]]])))
|
||||
711
frontend/src/app/main/ui/workspace/sidebar/pull_requests.scss
Normal file
@ -0,0 +1,711 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/typography.scss" as t;
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
// Same light-theme semantic-tint re-pointing the branches surfaces use
|
||||
// (see branches.scss for the rationale).
|
||||
.pr-section,
|
||||
.pr-banner,
|
||||
.modal-container {
|
||||
:global(.light) & {
|
||||
--color-foreground-success: var(--color-accent-success);
|
||||
--color-foreground-error: var(--color-accent-error);
|
||||
--color-foreground-warning: color-mix(in srgb, var(--color-accent-warning) 60%, #000);
|
||||
--color-foreground-info: var(--color-accent-info);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sidebar section
|
||||
|
||||
.pr-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
|
||||
.pr-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-xs);
|
||||
padding-block: var(--sp-s) var(--sp-xs);
|
||||
padding-inline: var(--sp-s);
|
||||
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
color: var(--color-foreground-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
|
||||
> button {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pr-section-count {
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.pr-section-empty {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
padding-inline: var(--sp-s);
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.pr-entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pr-entry {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: start;
|
||||
gap: var(--sp-s);
|
||||
padding: var(--sp-s);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.pr-entry.is-finished {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.pr-entry.is-menu-open {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pr-entry-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 32px;
|
||||
block-size: 32px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-background-tertiary);
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.pr-entry-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xxs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pr-entry-title {
|
||||
@include t.use-typography("body-medium");
|
||||
|
||||
color: var(--color-foreground-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pr-entry-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pr-entry-route {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
color: var(--color-foreground-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pr-entry-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
|
||||
.pr-entry-aside {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
|
||||
.pr-entry-approvals {
|
||||
@include t.use-typography("code-font");
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-xxs);
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground-success);
|
||||
}
|
||||
|
||||
.pr-options-dropdown {
|
||||
@extend %menu-dropdown;
|
||||
|
||||
inset-block-start: 100%;
|
||||
inset-inline-end: 0;
|
||||
inline-size: $sz-160;
|
||||
}
|
||||
|
||||
.menu-option {
|
||||
@extend %menu-item-base;
|
||||
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pr-finished {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
|
||||
.pr-finished-toggle {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-xs);
|
||||
padding-block: var(--sp-s) var(--sp-xs);
|
||||
padding-inline: var(--sp-s);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-foreground-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// --- State badges
|
||||
|
||||
.pr-badge {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
padding: var(--sp-xxs) var(--sp-s);
|
||||
border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-open {
|
||||
color: var(--color-foreground-info);
|
||||
background-color: var(--color-background-info);
|
||||
}
|
||||
|
||||
.badge-in-review {
|
||||
color: var(--color-foreground-info);
|
||||
background-color: var(--color-background-info);
|
||||
}
|
||||
|
||||
.badge-approved {
|
||||
color: var(--color-foreground-success);
|
||||
background-color: var(--color-background-success);
|
||||
}
|
||||
|
||||
.badge-changes {
|
||||
color: var(--color-foreground-warning);
|
||||
background-color: var(--color-background-warning);
|
||||
}
|
||||
|
||||
.badge-merged {
|
||||
color: var(--color-foreground-success);
|
||||
background-color: var(--color-background-success);
|
||||
}
|
||||
|
||||
.badge-closed {
|
||||
color: var(--color-foreground-secondary);
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
.badge-outdated {
|
||||
color: var(--color-foreground-warning);
|
||||
background-color: var(--color-background-warning);
|
||||
}
|
||||
|
||||
.badge-conflicts {
|
||||
color: var(--color-foreground-error);
|
||||
background-color: var(--color-background-error);
|
||||
}
|
||||
|
||||
// --- Modals (create / review / details)
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
@extend %modal-container-base;
|
||||
|
||||
min-width: deprecated.$s-408;
|
||||
|
||||
// long pull request descriptions: let the card grow past the base
|
||||
// 512px cap up to the viewport, and scroll the CONTENT (header and
|
||||
// footer stay pinned) instead of spilling out of the card
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-block-size: min(90vh, 56rem);
|
||||
max-inline-size: min(90vw, 40rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-s);
|
||||
margin-bottom: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.modal-header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: $sz-32;
|
||||
height: $sz-32;
|
||||
border-radius: 8px;
|
||||
color: var(--color-accent-primary);
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
.modal-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xxs);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-medium-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
color: var(--color-foreground-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.modal-subtitle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@include deprecated.flex-column;
|
||||
|
||||
gap: deprecated.$s-16;
|
||||
margin-bottom: deprecated.$s-24;
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-s);
|
||||
|
||||
// action labels never wrap inside their button; the row wraps instead
|
||||
button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
min-height: $sz-64;
|
||||
padding: var(--sp-s);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-background-quaternary);
|
||||
background-color: var(--color-background-tertiary);
|
||||
color: var(--color-foreground-primary);
|
||||
resize: vertical;
|
||||
|
||||
@include t.use-typography("body-medium");
|
||||
}
|
||||
|
||||
// --- Reviewer picker (create dialog)
|
||||
|
||||
.reviewers-empty {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.reviewers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xxs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-block-size: $sz-200;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.reviewer-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-s);
|
||||
inline-size: 100%;
|
||||
padding: var(--sp-xs) var(--sp-s);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-foreground-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: var(--color-accent-primary);
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
> svg:last-child {
|
||||
margin-inline-start: auto;
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.reviewer-name {
|
||||
@include t.use-typography("body-medium");
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// --- Review dialog (verdict options)
|
||||
|
||||
.verdict-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
.verdict-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-m);
|
||||
padding: var(--sp-m);
|
||||
border: 1px solid var(--color-background-quaternary);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
color: var(--color-foreground-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: var(--color-accent-primary);
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.verdict-option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xxs);
|
||||
}
|
||||
|
||||
.verdict-option-title {
|
||||
@include t.use-typography("body-medium");
|
||||
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.verdict-option-hint {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
// --- Details dialog
|
||||
|
||||
// the pull request metadata (author / dates / approvals / reviewers)
|
||||
// gets its own card, visually separated from the free-text description
|
||||
// (same surface treatment as the reviewer rows and the create dialog's
|
||||
// "derived from" box: tertiary background + 8px radius)
|
||||
.pr-info-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-m);
|
||||
flex-shrink: 0;
|
||||
padding: var(--sp-m);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-background-quaternary);
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
.pr-info-description-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
|
||||
// only the description text scrolls, never the whole modal: this
|
||||
// block absorbs the remaining height of the card
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.pr-info-description {
|
||||
@include t.use-typography("body-medium");
|
||||
|
||||
margin: 0;
|
||||
color: var(--color-foreground-primary);
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
padding-inline-end: var(--sp-s);
|
||||
|
||||
// user content: long words / urls must never push past the card
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pr-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--sp-m);
|
||||
}
|
||||
|
||||
.pr-info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xxs);
|
||||
}
|
||||
|
||||
.pr-info-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-xs);
|
||||
|
||||
@include t.use-typography("body-medium");
|
||||
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
|
||||
.pr-info-reviewers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-block-size: $sz-160;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pr-info-reviewer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-s);
|
||||
padding: var(--sp-xs) var(--sp-s);
|
||||
border-radius: 8px;
|
||||
|
||||
// sits INSIDE the tertiary metadata card: step one surface down so
|
||||
// the rows remain distinguishable
|
||||
background-color: var(--color-background-primary);
|
||||
}
|
||||
|
||||
.reviewer-state {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
margin-inline-start: auto;
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.reviewer-approved {
|
||||
color: var(--color-foreground-success);
|
||||
}
|
||||
|
||||
.reviewer-changes {
|
||||
color: var(--color-foreground-warning);
|
||||
}
|
||||
|
||||
.reviewer-stale {
|
||||
color: var(--color-foreground-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.reviewer-comment {
|
||||
@include t.use-typography("body-small");
|
||||
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
color: var(--color-foreground-secondary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
// --- Review sandbox banner
|
||||
|
||||
.pr-banner {
|
||||
position: absolute;
|
||||
inset-block-end: var(--sp-m);
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-inline-size: 90vw;
|
||||
border-radius: deprecated.$s-8;
|
||||
|
||||
// the review sandbox must never be confused with the editable branch:
|
||||
// accent-colored border instead of the branch banner's neutral one
|
||||
border: deprecated.$s-2 solid var(--color-accent-primary);
|
||||
background-color: var(--color-background-primary);
|
||||
|
||||
// unlike the branch banner this one hosts a dropdown that opens
|
||||
// upwards, outside the banner box: it must not be clipped (the
|
||||
// collapsed state hides its content via block-size + visibility) and
|
||||
// it must beat the viewport controls, which are raised to z-index 1
|
||||
// while a drawing tool is active
|
||||
overflow: visible;
|
||||
z-index: deprecated.$z-index-2;
|
||||
transition:
|
||||
inset-block-end 0.2s,
|
||||
block-size 0.2s;
|
||||
|
||||
&.is-collapsed {
|
||||
inset-block-end: 0;
|
||||
block-size: var(--sp-m);
|
||||
border-radius: 12px 12px 0 0;
|
||||
|
||||
.pr-banner-content {
|
||||
block-size: 0;
|
||||
padding-block: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pr-banner-handle {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.button-style;
|
||||
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
inset-block-start: 0;
|
||||
block-size: var(--sp-m);
|
||||
inline-size: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pr-banner-handle-btn {
|
||||
block-size: var(--sp-xs);
|
||||
inline-size: 100%;
|
||||
max-inline-size: 64px;
|
||||
padding: 0;
|
||||
border-radius: var(--sp-xs);
|
||||
background-color: var(--palette-handler-background-color);
|
||||
}
|
||||
|
||||
.pr-banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-m);
|
||||
padding: var(--sp-m) var(--sp-m) var(--sp-s);
|
||||
|
||||
// no overflow clip: the kebab dropdown opens upwards past the banner
|
||||
// edge (the collapsed state hides this box via block-size + visibility)
|
||||
}
|
||||
|
||||
.pr-banner-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.pr-banner-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-xs);
|
||||
|
||||
@include t.use-typography("body-small");
|
||||
}
|
||||
|
||||
.pr-banner-title,
|
||||
.pr-banner-route {
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.pr-banner-name {
|
||||
color: var(--color-foreground-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pr-banner-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-xs);
|
||||
}
|
||||
|
||||
.pr-banner-approvals {
|
||||
@include t.use-typography("code-font");
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-xxs);
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground-success);
|
||||
}
|
||||
|
||||
.pr-banner-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
.pr-banner-menu {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// the banner sits at the bottom of the canvas, so its options menu
|
||||
// opens upwards
|
||||
.pr-banner-options-dropdown {
|
||||
@extend %menu-dropdown;
|
||||
|
||||
inset-block-end: 100%;
|
||||
inset-inline-end: 0;
|
||||
inline-size: $sz-192;
|
||||
}
|
||||