penpot/frontend/src/app/main/ui/dashboard/check_updates.cljs
Andrey Antukh 7c27ed812a
♻️ Consolidate HIGHLIGHTS.md into CHANGES.md 🚀 section (#11531)
* ♻️ Consolidate HIGHLIGHTS.md into CHANGES.md 🚀 section

Eliminate the redundant HIGHLIGHTS.md file and make CHANGES.md
the single source of truth for version highlights.

- Add 🚀 section for 2.15.0 (MCP server integration)
- Add 4 missing highlight entries to 2.17.0 🚀 section
- Rewrite frontend parser to extract from CHANGES.md 🚀
  subsections instead of flat HIGHLIGHTS.md format
- Decouple parse-latest-released-version from highlights
  extraction so it works independently of 🚀 content
- Conditionally render highlights section in modal when non-empty
- Rewrite tests for new parser behavior (11 tests, 21 assertions)
- Delete HIGHLIGHTS.md and remove .gitignore exception
- Add step 8b to update-changelog skill for proactively
  proposing highlights during release workflows
- Add missing-highlights and missing-highlight-reference
  anomaly types to the changelog anomaly report script

Closes #11530

AI-assisted-by: qwen3.7-plus

* ♻️ Use consistent string library and add multi-version test

Address code review findings:

- Use str/split (cuerdas) consistently in extract-rocket-items
  instead of mixing cstr/split (clojure.string)
- Add parse-highlights-extracts-multiple-versions test to verify
  the parser correctly extracts 🚀 items from multiple
  versions in a single CHANGES.md body

AI-assisted-by: qwen3.7-plus

* ♻️ Scope 🚀 checks to X.Y.0 and split gaps from anomalies

Type C now only checks released X.Y.0 versions, since patches never carry 🚀 subsections by design. Type D requires both issue AND PR references with exact format, accepting multi-PR entries. C/D are reported as highlight gaps in their own section and no longer count toward the anomaly total. Key Principles and anomaly definitions updated to match. Addresses review comments on PR #11531.

AI-assisted-by: muse-spark-1.3-contributor

*  Render markdown links and bold in check-updates highlights

The highlights modal showed raw markdown from CHANGES.md 🚀 lines (brackets and URLs). Add a pure parse-highlight-item parser for inline links and bold, render fragments with literal hiccup in the modal (links open in a new tab), and style links and strong elements. Non-http URLs and malformed markup degrade to plain text. Adds 12 unit tests.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Point full changelog link to main instead of staging

The view-changelog button in the check-updates modal linked to the staging branch. Point it to main, which holds the published changelog. Version detection still fetches from staging.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-10 16:40:32 +02:00

375 lines
13 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 SUBSIDIARY SL
(ns app.main.ui.dashboard.check-updates
(:require-macros [app.main.style :as stl])
(:require
[app.common.version :as v]
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.util.dom :as dom]
[app.util.http :as http]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[clojure.string :as cstr]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private telemetry-origin
"check-updates-modal")
(def ^:private changelog-md-url
"https://raw.githubusercontent.com/penpot/penpot/refs/heads/staging/CHANGES.md")
(def ^:private changelog-url
"https://github.com/penpot/penpot/blob/main/CHANGES.md")
(def ^:private release-notes-url
"https://penpot.app/release-notes")
(def ^:private version-heading-re
#"(?m)^##\s+(\d+\.\d+\.\d+)(.*)$")
(def ^:private bullet-re
#"^- (.+)$")
(def ^:private rocket-heading-re
#"(?m)^### :rocket: Epics and highlights\s*$")
(defn- unreleased-suffix?
[suffix]
(str/includes? (str/lower (or suffix "")) "unreleased"))
(defn- parse-section-items
[section]
(->> (cstr/split-lines section)
(keep (fn [line]
(when-let [[_ item] (re-matches bullet-re (str/trim line))]
item)))
vec))
(defn- extract-rocket-items
"Given a version section body, find the :rocket: subsection and
extract its bullet items. Returns nil if no :rocket: or empty."
[version-body]
(when-let [[_ rocket-body] (str/split version-body rocket-heading-re 2)]
(let [subsection (-> (str/split rocket-body #"(?m)(?=^#{2,3}\s)") first)]
(when subsection
(let [items (parse-section-items subsection)]
(when (seq items) items))))))
(def ^:private inline-md-re
#"\[([^\]]+)\]\(((?:\([^)]*\)|[^)\s])*)\)|\*\*([^*]+)\*\*")
(defn- http-url?
[url]
(boolean (re-matches #"https?://.*" (or url ""))))
(defn parse-highlight-item
"Parse one highlight string with inline markdown (links, bold) into a
vector of {:type :text/:link/:bold, :text ..., :href ...} descriptors.
Anything unrecognized degrades to :text. Total over strings; nil and
empty input return []."
[text]
(if (or (not (string? text)) (= "" text))
[]
(loop [out [] s text]
(if (= "" s)
out
(if-let [[m link-text link-href bold-text] (re-find inline-md-re s)]
(let [idx (cstr/index-of s m)
before (subs s 0 idx)
after (subs s (+ idx (count m)))
out (if (= "" before) out (conj out {:type :text :text before}))]
(cond
(some? link-text)
(if (http-url? link-href)
(recur (conj out {:type :link :text link-text :href link-href}) after)
(recur (conj out {:type :text :text m}) after))
:else
(recur (conj out {:type :bold :text bold-text}) after)))
(conj out {:type :text :text s}))))))
(defn parse-highlights
"Parse CHANGES.md into released version sections with bullet items from
the :rocket: Epics and highlights subsection. Skips Unreleased headings,
versions without a :rocket: section, and versions with an empty one.
Preserves file order (newest first)."
[markdown]
(if-not (string? markdown)
[]
(->> (str/split markdown #"(?m)(?=^##\s+\d+\.\d+\.\d+)")
(keep (fn [part]
(when-let [[_ version suffix] (re-find version-heading-re part)]
(when-not (unreleased-suffix? suffix)
(when-let [items (extract-rocket-items part)]
{:version version
:items items})))))
vec)))
(defn parse-latest-released-version
"Return the first non-unreleased `## X.Y.Z` heading from a CHANGES.md body."
[markdown]
(when (string? markdown)
(some->> (re-seq version-heading-re markdown)
(keep (fn [[_ version suffix]]
(when-not (unreleased-suffix? suffix) version)))
first)))
(defn highlights-until-installed
"Keep released sections newer than the installed version (major, minor,
patch). Stops at the installed version or any older section."
[highlights installed]
(into []
(take-while #(v/newer? (:version %) installed))
highlights))
(defn- show-available-dialog
[{:keys [installed latest highlights]}]
(st/emit! (modal/show {:type :check-updates-available
:installed installed
:latest latest
:highlights highlights})))
(defn- show-uptodate-dialog
[version]
(st/emit! (modal/show {:type :check-updates-uptodate
:version version})))
(defn- show-unable-dialog
[]
(st/emit! (modal/show {:type :check-updates-unable})))
(defn- handle-highlights
[installed body]
(let [latest (parse-latest-released-version body)
sections (parse-highlights body)]
(cond
(nil? latest)
(show-unable-dialog)
(not (v/newer? latest installed))
(show-uptodate-dialog installed)
:else
(show-available-dialog
{:installed installed
:latest latest
:highlights (highlights-until-installed sections installed)}))))
(defn check-for-updates!
([current-version]
(check-for-updates! current-version nil))
([current-version {:keys [on-start on-finish]}]
(when on-start (on-start))
(->> (http/send! {:method :get
:mode :cors
:omit-default-headers true
:uri changelog-md-url
:response-type :text})
(rx/subs!
(fn [response]
(when on-finish (on-finish))
(if (http/success? response)
(handle-highlights current-version (:body response))
(show-unable-dialog)))
(fn [_cause]
(when on-finish (on-finish))
(show-unable-dialog))))))
(mf/defc check-updates-unable-modal*
{::mf/register modal/components
::mf/register-as :check-updates-unable}
[_]
(let [on-close
(mf/use-fn #(st/emit! (modal/hide)))
on-try-again
(mf/use-fn
(fn []
(st/emit! (modal/hide))
(check-for-updates! (:base cf/version))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:div {:class (stl/css :modal-title-row)}
[:> icon* {:icon-id i/msg-neutral
:class (stl/css :modal-title-icon)
:aria-hidden true}]
[:> heading* {:level 2
:typography "headline-medium"
:class (stl/css :modal-title)}
(tr "dashboard.check-updates.unable-title")]]
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close
:class (stl/css :modal-close-btn)}]]
[:div {:class (stl/css :modal-content)}
[:> text* {:as "p"
:typography t/body-large
:class (stl/css :modal-msg)}
(tr "dashboard.check-updates.unable-message")]
[:> text* {:as "p"
:typography t/body-large
:class (stl/css :modal-msg)}
(tr "dashboard.check-updates.unable-hint")]]
[:div {:class (stl/css :modal-footer)}
[:> button* {:variant "primary"
:on-click on-try-again}
(tr "dashboard.check-updates.try-again")]]]]))
(mf/defc check-updates-uptodate-modal*
{::mf/register modal/components
::mf/register-as :check-updates-uptodate}
[{:keys [version]}]
(let [on-close
(mf/use-fn #(st/emit! (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-title-row)}
[:> icon* {:icon-id i/tick
:class (stl/css :modal-title-icon)
:aria-hidden true}]
[:> heading* {:level 2
:typography "headline-medium"
:class (stl/css :modal-title)}
(tr "dashboard.check-updates.uptodate-title")]]
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close
:class (stl/css :modal-close-btn)}]]
[:div {:class (stl/css :modal-content)}
[:> text* {:as "p"
:typography t/body-large
:class (stl/css :modal-msg)}
(tr "dashboard.check-updates.uptodate-message")
" "
[:span {:class (stl/css :version)} version]]]
[:div {:class (stl/css :modal-footer)}
[:> button* {:variant "secondary"
:on-click on-close}
(tr "labels.close")]]]]))
(mf/defc check-updates-available-modal*
{::mf/register modal/components
::mf/register-as :check-updates-available}
[{:keys [installed latest highlights]}]
(let [on-close
(mf/use-fn #(st/emit! (modal/hide)))
on-changelog
(mf/use-fn
(mf/deps installed)
(fn []
(st/emit! (ev/event {::ev/name "explore-changelog-click"
::ev/origin telemetry-origin
:version installed}))
(dom/open-new-window changelog-url)))
on-release-notes
(mf/use-fn
(mf/deps installed)
(fn []
(st/emit! (ev/event {::ev/name "explore-product-updates-click"
::ev/origin telemetry-origin
:version installed}))
(dom/open-new-window release-notes-url)))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container :modal-container-available)}
[:div {:class (stl/css :modal-header)}
[:div {:class (stl/css :modal-title-row)}
[:> icon* {:icon-id i/info
:class (stl/css :modal-title-icon)
:aria-hidden true}]
[:> heading* {:level 2
:typography "headline-medium"
:class (stl/css :modal-title)}
(tr "dashboard.check-updates.available-title")]]
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close
:class (stl/css :modal-close-btn)}]]
[:div {:class (stl/css :version-bar)}
[:span {:class (stl/css :version-bar-side)}
(tr "dashboard.check-updates.installed-version")
" "
[:span {:class (stl/css :version)} installed]]
[:> icon* {:icon-id i/arrow-right
:class (stl/css :version-bar-arrow)
:size "s"
:aria-hidden true}]
[:span {:class (stl/css :version-bar-side)}
(tr "dashboard.check-updates.latest-version")
" "
[:span {:class (stl/css :version :version-accent)} latest]]]
[:div {:class (stl/css :modal-content)}
[:> text* {:as "p"
:typography t/body-large
:class (stl/css :modal-msg)}
(tr "dashboard.check-updates.available-message")]
(when (seq highlights)
[:*
[:> text* {:as "h3"
:typography t/headline-small
:class (stl/css :highlights-title)}
(tr "dashboard.check-updates.highlights-title")]
[:div {:class (stl/css :highlights-scroll)}
(for [section highlights]
(let [version (:version section)
items (:items section)]
[:div {:key version
:class (stl/css :highlights-section)}
[:div {:class (stl/css :highlights-version)} version]
[:ul {:class (stl/css :highlights-list)}
(for [item items]
[:li {:key item
:class (stl/css :highlights-item)}
(for [[idx frag] (map-indexed vector (parse-highlight-item item))]
(case (:type frag)
:link
[:a {:key idx
:href (:href frag)
:target "_blank"
:rel "noopener noreferrer"}
(:text frag)]
:bold
[:strong {:key idx} (:text frag)]
(:text frag)))])]]))]])]
[:div {:class (stl/css :modal-footer :modal-footer-available)}
[:> button* {:variant "secondary"
:on-click on-changelog}
(tr "dashboard.check-updates.view-changelog")]
[:> button* {:variant "primary"
:on-click on-release-notes}
(tr "dashboard.check-updates.view-release-notes")]]]]))