penpot/frontend/src/app/main/data/workspace/pull_requests.cljs
Juan de la Cruz 720b2f193d
✨ Move branching into the workspace header (#12359)
* ✨ Move the branch switcher to the workspace header

* ✨ Add branch DS components and the all-branches dialog

* 💄 Polish the branch switcher and its menu entry

* ✨ Keep the dashboard to switching branches

* 💄 Polish the branch and pull request review UI

* ♻️ Move the branching UI onto the design system

* 🐛 Address the review of the header branch switcher

The bottom banner turned the nil counts that get-file-branch-info sends
for a branch it cannot compare into zeros, so it said "Up to date with
main". It now says "Couldn't compare this branch with main" in the
warning colour, as the header row already did.

During a review the review banner replaces the branch banner, which was
the only component loading the branch context. The header row now loads
it too, so a review link opened fresh shows the branch under review
instead of "Main".

On a branch, "New branch" in the all-branches dialog is disabled with a
tooltip saying branches are created from main: the server refuses to
branch a branch.

With "Try interactions" gone, the create-review note says reviewers
open the live branch and View mode plays it, and the unused
open-pull-request-viewer event goes.

The pull request specs follow the new banners, and three tests cover
the fixes above.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

---------

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Co-authored-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-22 16:50:19 +02:00

373 lines
15 KiB
Clojure

;; 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. Reviewing opens the live
branch file as a normal, editable workspace with a review context
attached (`:workspace-pr-preview`, which drives the review banner and
header), so a reviewer's edits change the branch and mark the pull
request outdated. The pinned snapshot backs the interactions viewer and
the outdated flag, not the canvas. Mirrors the patterns of
`app.main.data.workspace.branches`."
(:require
[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 CONTEXT (the live, editable branch file plus the review banner)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn open-pull-request
"Navigate into the review of `pr`: the branch file's workspace with the
`pr-id` query param, which attaches the review context once the file is
initialized (`initialize-pull-request-preview`). The canvas is the live
branch, editable as always."
[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- 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))))))