♻️ 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
This commit is contained in:
Andrey Antukh 2026-09-10 16:40:32 +02:00 committed by GitHub
parent 286ccb03fa
commit 7c27ed812a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 419 additions and 100 deletions

View File

@ -357,6 +357,39 @@ Insert the new version section right after the `# CHANGELOG` header (before
the previous version entry). Use the `edit` tool with enough context to make the previous version entry). Use the `edit` tool with enough context to make
a unique match. a unique match.
### 8b. Propose and populate the `:rocket: Epics and highlights` subsection
After inserting the version section, proactively create or populate the
`### :rocket: Epics and highlights` subsection. This section surfaces the
most impactful changes for self-hosted users checking for updates.
**When to create:** If the version section does not already have a
`### :rocket: Epics and highlights` subsection, create one. Place it before
`### :sparkles:` (matching existing order in CHANGES.md).
**How to identify highlights:** Review the `:sparkles:` entries for the
version and select 25 of the most impactful/user-visible ones. Criteria:
- New user-visible features (not internal refactors)
- Significant capability additions
- Items that create "FOMO" for self-hosted users on older versions
**Use release notes as hints:** Check
`frontend/src/app/main/ui/releases/v2_<MINOR>.cljs` for the corresponding
version. The slide titles and feature descriptions there are curated
marketing content indicating what the team considers highlight-worthy. Match
those themes to changelog entries. Treat these files as optional hints — they
may not exist for every version.
**Format requirement:** Every `:rocket:` entry MUST follow the standard
changelog format with issue/PR references:
```
- <description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
```
An entry without issue AND PR references is a highlight gap (warning, not an anomaly).
**Preserve existing entries:** If the `:rocket:` section already exists from
a prior run, preserve its entries. Do not remove or rewrite them.
### 9. Verify ### 9. Verify
Read the top of `CHANGES.md` and confirm: Read the top of `CHANGES.md` and confirm:
@ -468,9 +501,8 @@ Markdown viewer.
## What is an anomaly ## What is an anomaly
**An anomaly is a milestone-mismatch between an issue and its referenced **An anomaly is a milestone-mismatch between an issue and its referenced
PR.** It indicates that the changelog claim "this issue is fixed by this PR, PR.** There are two anomaly types, plus two highlight gaps (warnings that
all in milestone M" is inconsistent with the actual milestone assignments. do not count toward the anomaly total):
There are exactly two types:
1. **Issue is in the milestone, but its referenced PR is in a different 1. **Issue is in the milestone, but its referenced PR is in a different
milestone (or has no milestone).** The changelog claims a fix in this milestone (or has no milestone).** The changelog claims a fix in this
@ -486,6 +518,13 @@ There are exactly two types:
PR that closes an issue with no milestone references an issue from PR that closes an issue with no milestone references an issue from
another (probably private) project; that is expected and the issue is another (probably private) project; that is expected and the issue is
not part of this changelog. Do not report it. not part of this changelog. Do not report it.
3. **missing-highlights (gap):** A released X.Y.0 version section has no
`### :rocket: Epics and highlights` subsection. Patches (X.Y.Z) never
carry highlights, so only minors/majors are checked.
4. **missing-highlight-reference (gap):** A `:rocket:` entry lacks the
required issue AND PR references. Every highlight entry must follow the
standard changelog format with `[#ISSUE]` and `(PR: [#PR])` links
(multi-PR `(PR: [#A](...), [#B](...))` accepted).
**Anything else is not an anomaly.** Other discrepancies (exclusion **Anything else is not an anomaly.** Other discrepancies (exclusion
labels on in-changelog issues, missing valid issues, unmerged PR labels on in-changelog issues, missing valid issues, unmerged PR
@ -653,6 +692,40 @@ for pr_num in sorted(changelog_prs):
'issue_milestone': issue_ms, # may be None 'issue_milestone': issue_ms, # may be None
}) })
# --- Type C: released X.Y.0 version sections without :rocket: subsection ---
# Patches (X.Y.Z with Z != 0) never carry :rocket: by design — only minors/majors (X.Y.0).
anomalies_c = [] # list of version strings
rocket_heading_re = re.compile(r'^### :rocket:', re.MULTILINE)
version_sections = re.split(r'(?=^## \d+\.\d+\.\d+)', content, flags=re.MULTILINE)
for vs in version_sections:
m = re.match(r'^## (\d+\.\d+\.\d+)(.*)', vs)
if not m: continue
ver, suffix = m.group(1), m.group(2)
if 'unreleased' in suffix.lower(): continue
if ver.split('.')[2] != '0': continue
if not rocket_heading_re.search(vs):
anomalies_c.append(ver)
# --- Type D: :rocket: entries without issue AND PR references ---
# Both are required: `[#ISSUE](.../issues/N)` and `(PR: [#PR](.../pull/M))`.
# Multi-PR entries `(PR: [#A](...), [#B](...))` are accepted.
anomalies_d = [] # list of dicts: {version, line}
issue_ref_re = re.compile(r'\[#\d+\]\(https://github\.com/penpot/penpot/issues/\d+\)')
pr_ref_re = re.compile(r'\(PR:\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\d+\)(\s*,\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\d+\))*\)')
for vs in version_sections:
m = re.match(r'^## (\d+\.\d+\.\d+)(.*)', vs)
if not m: continue
ver = m.group(1)
rocket_match = rocket_heading_re.search(vs)
if not rocket_match: continue
# Extract the :rocket: subsection body (up to next ### or ##)
rocket_body = vs[rocket_match.end():]
rocket_body = re.split(r'(?m)^#{2,3}\s', rocket_body)[0]
for line in rocket_body.splitlines():
line = line.strip()
if line.startswith('- ') and not (issue_ref_re.search(line) and pr_ref_re.search(line)):
anomalies_d.append({'version': ver, 'line': line[:100]})
# --- Write report --- # --- Write report ---
def fmt_ms(ms): def fmt_ms(ms):
return ms if ms else "_none_" return ms if ms else "_none_"
@ -664,13 +737,17 @@ with open(OUTPUT, 'w') as f:
n_a = len(anomalies_a) n_a = len(anomalies_a)
n_b = len(anomalies_b) n_b = len(anomalies_b)
n_c = len(anomalies_c)
n_d = len(anomalies_d)
f.write('## Summary\n\n') f.write('## Summary\n\n')
f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n') f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n')
f.write(f'- **PR in {MILESTONE}, closing issue in a different milestone:** {n_b}\n') f.write(f'- **PR in {MILESTONE}, closing issue in a different milestone:** {n_b}\n')
f.write(f'- **Total anomalies:** {n_a + n_b}\n\n') f.write(f'- **Total anomalies:** {n_a + n_b}\n')
f.write(f'- **Released X.Y.0 version missing :rocket: section (gap):** {n_c}\n')
f.write(f'- **:rocket: entry without issue AND PR references (gap):** {n_d}\n\n')
# --- Anomalies section --- # --- Anomalies section (milestone mismatches only) ---
if n_a or n_b: if n_a or n_b:
f.write('## Anomalies\n\n') f.write('## Anomalies\n\n')
f.write('These are milestone mismatches between an issue in the changelog ' f.write('These are milestone mismatches between an issue in the changelog '
@ -709,9 +786,37 @@ with open(OUTPUT, 'w') as f:
badge = '🔴' if e['issue_milestone'] is None else '⚠️' badge = '🔴' if e['issue_milestone'] is None else '⚠️'
f.write(f' - {badge} Closing {issue_link(e["issue"])} is in milestone **{ms_label}** (expected: {MILESTONE})\n') f.write(f' - {badge} Closing {issue_link(e["issue"])} is in milestone **{ms_label}** (expected: {MILESTONE})\n')
f.write('\n') f.write('\n')
else: else:
f.write('✅ No anomalies found. All (issue, PR) pairs in the changelog have aligned milestone assignments.\n\n') f.write('✅ No anomalies found. All (issue, PR) pairs in the changelog have aligned milestone assignments.\n\n')
# --- Highlight gaps (warnings, not anomalies) ---
if n_c or n_d:
f.write('## Highlight gaps\n\n')
f.write('These are warnings, not anomalies: they do not affect the '
'milestone-mismatch total above. They track `:rocket:` coverage '
'across all released X.Y.0 versions. Historical entries (e.g. '
'Taiga links) predate the current reference convention and are '
'expected to appear here.\n\n')
if n_c:
f.write(f'### Released X.Y.0 version missing :rocket: section\n\n')
f.write('These released minors/majors have no `### :rocket: Epics and highlights` subsection. '
'Add highlights to help self-hosted users understand what they are missing.\n\n')
for ver in anomalies_c:
f.write(f'- Version **{ver}**\n')
f.write('\n')
if n_d:
f.write(f'### :rocket: entry without issue AND PR references\n\n')
f.write('These highlight entries lack the required issue AND PR references. '
'Add `[#ISSUE](...)` and `(PR: [#PR](...))` links.\n\n')
for d in anomalies_d:
f.write(f'- **{d["version"]}**: `{d["line"]}`\n')
f.write('\n')
elif not (n_a or n_b):
f.write('✅ No highlight gaps found. All released X.Y.0 versions have properly referenced :rocket: entries.\n\n')
# --- Context --- # --- Context ---
f.write('---\n\n') f.write('---\n\n')
f.write('## Context\n\n') f.write('## Context\n\n')
@ -726,8 +831,7 @@ print(f"Anomaly report written to {OUTPUT}")
PYEOF PYEOF
``` ```
This generates `CHANGES-ISSUES.md` containing **only the anomalies** This generates `CHANGES-ISSUES.md` containing anomalies and highlight gaps:
milestone mismatches between issues and their referenced PRs:
1. **Issue in milestone, referenced PR in different milestone or no milestone** 1. **Issue in milestone, referenced PR in different milestone or no milestone**
the changelog claims a fix here, but the PR is released elsewhere. the changelog claims a fix here, but the PR is released elsewhere.
@ -736,6 +840,13 @@ milestone mismatches between issues and their referenced PRs:
(An issue with *no* milestone belongs to another, probably private, (An issue with *no* milestone belongs to another, probably private,
project — milestones are only required on the "Main" project — so it is project — milestones are only required on the "Main" project — so it is
neither an anomaly nor a changelog candidate.) neither an anomaly nor a changelog candidate.)
3. **missing-highlights (gap, warning)** — a released X.Y.0 version section
has no `### :rocket: Epics and highlights` subsection. Patches (X.Y.Z)
never carry highlights.
4. **missing-highlight-reference (gap, warning)** — a `:rocket:` entry lacks
the required issue AND PR references.
Gaps do not count toward the anomaly total.
**Rule violations are not in the report** — they are workflow errors the **Rule violations are not in the report** — they are workflow errors the
LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks). LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks).
@ -809,10 +920,13 @@ self-contained and clickable in any Markdown viewer.
issue from a different project or context. If the PR title and issue title issue from a different project or context. If the PR title and issue title
are clearly unrelated, or the PR predates the issue by years, treat it as a are clearly unrelated, or the PR predates the issue by years, treat it as a
data glitch and skip it. data glitch and skip it.
- **Anomaly = milestone mismatch only.** The report contains only milestone - **Anomaly = milestone mismatch only; gaps are warnings.** The report's
mismatches: (1) the issue is in this milestone but the referenced PR is anomaly total counts only milestone mismatches: (1) the issue is in this
in a different milestone (or unassigned), and (2) the PR is in this milestone but the referenced PR is in a different milestone (or unassigned),
milestone but the issue it closes is in a different milestone. An and (2) the PR is in this milestone but the issue it closes is in a
different milestone. `:rocket:` highlight gaps (missing section on a
released X.Y.0, entry without issue AND PR references) are reported in a
separate `Highlight gaps` section and never count toward the anomaly total. An
*unassigned* (milestone-less) issue closed by a milestone PR is **not** *unassigned* (milestone-less) issue closed by a milestone PR is **not**
an anomaly: milestones are required only for the "Main" project, so such an anomaly: milestones are required only for the "Main" project, so such
issues come from another (probably private) project and are not changelog issues come from another (probably private) project and are not changelog

1
.gitignore vendored
View File

@ -24,7 +24,6 @@ opencode.json
!AGENTS.md !AGENTS.md
!CODE_OF_CONDUCT.md !CODE_OF_CONDUCT.md
!SECURITY.md !SECURITY.md
!HIGHLIGHTS.md
/*.png /*.png
/*.svg /*.svg
/*.sql /*.sql

View File

@ -218,6 +218,10 @@
### :rocket: Epics and highlights ### :rocket: Epics and highlights
- Render prototype viewer with WASM (Skia) engine instead of SVG [#10037](https://github.com/penpot/penpot/issues/10037) (PR: [#10038](https://github.com/penpot/penpot/pull/10038)) - Render prototype viewer with WASM (Skia) engine instead of SVG [#10037](https://github.com/penpot/penpot/issues/10037) (PR: [#10038](https://github.com/penpot/penpot/pull/10038))
- Add layer blur effect for visual depth and styling [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))
- Render guides in WebGL for consistent viewer performance [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
- Add concurrency limiter and status indicators for MCP server communications [#9493](https://github.com/penpot/penpot/issues/9493) (PR: [#9748](https://github.com/penpot/penpot/pull/9748))
- Add typography token row to multiselected texts for better token visibility [#9336](https://github.com/penpot/penpot/issues/9336) (PR: [#9128](https://github.com/penpot/penpot/pull/9128))
### :sparkles: New features & Enhancements ### :sparkles: New features & Enhancements
@ -572,6 +576,10 @@
## 2.15.0 ## 2.15.0
### :rocket: Epics and highlights
- Add MCP server integration for AI-assisted design workflows [#9174](https://github.com/penpot/penpot/issues/9174) (PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321))
### :sparkles: New features & Enhancements ### :sparkles: New features & Enhancements
- Add MCP server integration [GH #9174](https://github.com/penpot/penpot/issues/9174) - Add MCP server integration [GH #9174](https://github.com/penpot/penpot/issues/9174)

View File

@ -1,26 +0,0 @@
# HIGHLIGHTS
## 2.17.0
- Background blur is here
- WebGL rendering gets stronger
- MCP connection status and more
- Design tokens: more visible, more user-friendly
## 2.16.0
- Design tokens in the design panel
- Major community contributions
- WebGL rendering (beta)
## 2.15.0
- AI connected to real design context
- Multi-directional workflow
- Your stack, your model, your decision

View File

@ -29,11 +29,11 @@
(def ^:private telemetry-origin (def ^:private telemetry-origin
"check-updates-modal") "check-updates-modal")
(def ^:private highlights-md-url (def ^:private changelog-md-url
"https://raw.githubusercontent.com/penpot/penpot/refs/heads/staging/HIGHLIGHTS.md") "https://raw.githubusercontent.com/penpot/penpot/refs/heads/staging/CHANGES.md")
(def ^:private changelog-url (def ^:private changelog-url
"https://github.com/penpot/penpot/blob/staging/CHANGES.md") "https://github.com/penpot/penpot/blob/main/CHANGES.md")
(def ^:private release-notes-url (def ^:private release-notes-url
"https://penpot.app/release-notes") "https://penpot.app/release-notes")
@ -44,6 +44,9 @@
(def ^:private bullet-re (def ^:private bullet-re
#"^- (.+)$") #"^- (.+)$")
(def ^:private rocket-heading-re
#"(?m)^### :rocket: Epics and highlights\s*$")
(defn- unreleased-suffix? (defn- unreleased-suffix?
[suffix] [suffix]
(str/includes? (str/lower (or suffix "")) "unreleased")) (str/includes? (str/lower (or suffix "")) "unreleased"))
@ -56,9 +59,54 @@
item))) item)))
vec)) 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 (defn parse-highlights
"Parse HIGHLIGHTS.md into released version sections with bullet items. "Parse CHANGES.md into released version sections with bullet items from
Skips Unreleased headings. Preserves file order (newest first)." 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] [markdown]
(if-not (string? markdown) (if-not (string? markdown)
[] []
@ -66,14 +114,19 @@
(keep (fn [part] (keep (fn [part]
(when-let [[_ version suffix] (re-find version-heading-re part)] (when-let [[_ version suffix] (re-find version-heading-re part)]
(when-not (unreleased-suffix? suffix) (when-not (unreleased-suffix? suffix)
{:version version (when-let [items (extract-rocket-items part)]
:items (parse-section-items part)})))) {:version version
:items items})))))
vec))) vec)))
(defn parse-latest-released-version (defn parse-latest-released-version
"Return the first non-unreleased `## X.Y.Z` heading from a highlights body." "Return the first non-unreleased `## X.Y.Z` heading from a CHANGES.md body."
[markdown] [markdown]
(some-> (parse-highlights markdown) first :version)) (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 (defn highlights-until-installed
"Keep released sections newer than the installed version (major, minor, "Keep released sections newer than the installed version (major, minor,
@ -101,8 +154,8 @@
(defn- handle-highlights (defn- handle-highlights
[installed body] [installed body]
(let [sections (parse-highlights body) (let [latest (parse-latest-released-version body)
latest (some-> sections first :version)] sections (parse-highlights body)]
(cond (cond
(nil? latest) (nil? latest)
(show-unable-dialog) (show-unable-dialog)
@ -124,7 +177,7 @@
(->> (http/send! {:method :get (->> (http/send! {:method :get
:mode :cors :mode :cors
:omit-default-headers true :omit-default-headers true
:uri highlights-md-url :uri changelog-md-url
:response-type :text}) :response-type :text})
(rx/subs! (rx/subs!
(fn [response] (fn [response]
@ -280,23 +333,37 @@
:class (stl/css :modal-msg)} :class (stl/css :modal-msg)}
(tr "dashboard.check-updates.available-message")] (tr "dashboard.check-updates.available-message")]
[:> text* {:as "h3" (when (seq highlights)
:typography t/headline-small [:*
:class (stl/css :highlights-title)} [:> text* {:as "h3"
(tr "dashboard.check-updates.highlights-title")] :typography t/headline-small
:class (stl/css :highlights-title)}
(tr "dashboard.check-updates.highlights-title")]
[:div {:class (stl/css :highlights-scroll)} [:div {:class (stl/css :highlights-scroll)}
(for [section highlights] (for [section highlights]
(let [version (:version section) (let [version (:version section)
items (:items section)] items (:items section)]
[:div {:key version [:div {:key version
:class (stl/css :highlights-section)} :class (stl/css :highlights-section)}
[:div {:class (stl/css :highlights-version)} version] [:div {:class (stl/css :highlights-version)} version]
[:ul {:class (stl/css :highlights-list)} [:ul {:class (stl/css :highlights-list)}
(for [item items] (for [item items]
[:li {:key item [:li {:key item
:class (stl/css :highlights-item)} :class (stl/css :highlights-item)}
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)} [:div {:class (stl/css :modal-footer :modal-footer-available)}
[:> button* {:variant "secondary" [:> button* {:variant "secondary"

View File

@ -162,6 +162,20 @@
border-radius: $br-circle; border-radius: $br-circle;
background-color: var(--color-accent-primary); background-color: var(--color-accent-primary);
} }
a {
color: var(--color-accent-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
strong {
color: var(--color-foreground-primary);
font-weight: 700;
}
} }
.modal-footer { .modal-footer {

View File

@ -10,30 +10,49 @@
[app.main.ui.dashboard.check-updates :as dcu] [app.main.ui.dashboard.check-updates :as dcu]
[cljs.test :as t :include-macros true])) [cljs.test :as t :include-macros true]))
(def ^:private sample-highlights (def ^:private sample-changes
(str "# HIGHLIGHTS\n" (str "# CHANGELOG\n"
"\n" "\n"
"## 2.18.0 (Unreleased)\n" "## 2.18.0 (Unreleased)\n"
"\n" "\n"
"- To do\n" "### :sparkles: New features & Enhancements\n"
"\n" "\n"
"## 2.17.2\n" "- Something WIP\n"
"\n" "\n"
"- Background blur is here\n" "## 2.17.0\n"
"- WebGL rendering gets stronger\n"
"\n" "\n"
"## 2.17.1\n" "### :rocket: Epics and highlights\n"
"\n" "\n"
"- MCP connection status and more\n" "- Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))\n"
"- Design tokens: more visible, more user-friendly\n")) "- WebGL rendering [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))\n"
"\n"
"### :sparkles: New features & Enhancements\n"
"\n"
"- Other stuff\n"
"\n"
"## 2.16.0\n"
"\n"
"### :rocket: Epics and highlights\n"
"\n"
"### :sparkles: New features & Enhancements\n"
"\n"
"- Tokens stuff\n"
"\n"
"## 2.15.0\n"
"\n"
"### :sparkles: New features & Enhancements\n"
"\n"
"- MCP server\n"))
(t/deftest parse-latest-released-version-skips-unreleased ;; --- parse-latest-released-version ---
(t/is (= "2.17.2" (dcu/parse-latest-released-version sample-highlights))))
(t/deftest parse-latest-released-version-from-changes
(t/is (= "2.17.0" (dcu/parse-latest-released-version sample-changes))))
(t/deftest parse-latest-released-version-first-released (t/deftest parse-latest-released-version-first-released
(t/is (= "2.17.2" (t/is (= "2.17.0"
(dcu/parse-latest-released-version (dcu/parse-latest-released-version
"## 2.17.2\n\n- Fix\n\n## 2.17.1\n\n- Fix\n")))) "## 2.17.0\n\n### :sparkles:\n\n- Fix\n\n## 2.16.0\n\n### :sparkles:\n\n- Fix\n"))))
(t/deftest parse-latest-released-version-only-unreleased (t/deftest parse-latest-released-version-only-unreleased
(t/is (nil? (dcu/parse-latest-released-version (t/is (nil? (dcu/parse-latest-released-version
@ -41,30 +60,154 @@
(t/deftest parse-latest-released-version-empty (t/deftest parse-latest-released-version-empty
(t/is (nil? (dcu/parse-latest-released-version ""))) (t/is (nil? (dcu/parse-latest-released-version "")))
(t/is (nil? (dcu/parse-latest-released-version "# HIGHLIGHTS\n")))) (t/is (nil? (dcu/parse-latest-released-version "# CHANGELOG\n"))))
(t/deftest parse-highlights-skips-unreleased-and-collects-bullets ;; --- parse-highlights ---
(t/is (= [{:version "2.17.2"
:items ["Background blur is here" (t/deftest parse-highlights-extracts-rocket-items
"WebGL rendering gets stronger"]} (let [result (dcu/parse-highlights sample-changes)]
{:version "2.17.1" (t/is (= [{:version "2.17.0"
:items ["MCP connection status and more" :items ["Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))"
"Design tokens: more visible, more user-friendly"]}] "WebGL rendering [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))"]}]
(dcu/parse-highlights sample-highlights)))) result))))
(t/deftest parse-highlights-skips-empty-rocket
;; 2.16.0 has an empty :rocket: section — should be skipped
(let [result (dcu/parse-highlights sample-changes)]
(t/is (not (some #(= "2.16.0" (:version %)) result)))))
(t/deftest parse-highlights-skips-missing-rocket
;; 2.15.0 has no :rocket: section — should be skipped
(let [result (dcu/parse-highlights sample-changes)]
(t/is (not (some #(= "2.15.0" (:version %)) result)))))
(t/deftest parse-highlights-skips-unreleased
;; 2.18.0 (Unreleased) should be skipped
(let [result (dcu/parse-highlights sample-changes)]
(t/is (not (some #(= "2.18.0" (:version %)) result)))))
(t/deftest parse-highlights-empty-input
(t/is (= [] (dcu/parse-highlights "")))
(t/is (= [] (dcu/parse-highlights nil)))
(t/is (= [] (dcu/parse-highlights 42))))
(t/deftest parse-highlights-extracts-multiple-versions
(let [input (str "## 2.17.0\n\n### :rocket: Epics and highlights\n\n"
"- Feature A [#1](https://github.com/penpot/penpot/issues/1)\n\n"
"## 2.16.0\n\n### :rocket: Epics and highlights\n\n"
"- Feature B [#2](https://github.com/penpot/penpot/issues/2)\n")
result (dcu/parse-highlights input)]
(t/is (= 2 (count result)))
(t/is (= "2.17.0" (:version (first result))))
(t/is (= "2.16.0" (:version (second result))))))
;; --- version-compare ---
(t/deftest version-compare (t/deftest version-compare
(t/is (zero? (v/compare-versions "2.17.1" "2.17.1"))) (t/is (zero? (v/compare-versions "2.17.0" "2.17.0")))
(t/is (pos? (v/compare-versions "2.17.2" "2.17.1"))) (t/is (pos? (v/compare-versions "2.17.0" "2.16.0")))
(t/is (neg? (v/compare-versions "2.17.1" "2.17.2"))) (t/is (neg? (v/compare-versions "2.16.0" "2.17.0")))
(t/is (pos? (v/compare-versions "3.0.0" "2.99.99"))) (t/is (pos? (v/compare-versions "3.0.0" "2.99.99")))
(t/is (neg? (v/compare-versions "2.17.2" "2.17.10")))) (t/is (neg? (v/compare-versions "2.17.0" "2.17.10"))))
;; --- highlights-until-installed ---
(t/deftest highlights-until-installed (t/deftest highlights-until-installed
(let [sections (dcu/parse-highlights sample-highlights)] (let [sections (dcu/parse-highlights sample-changes)]
(t/is (= [{:version "2.17.2" ;; installed 2.16.0 → shows 2.17.0 highlights
:items ["Background blur is here" (t/is (= [{:version "2.17.0"
"WebGL rendering gets stronger"]}] :items ["Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))"
(dcu/highlights-until-installed sections "2.17.1"))) "WebGL rendering [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))"]}]
(t/is (= [] (dcu/highlights-until-installed sections "2.17.2"))) (dcu/highlights-until-installed sections "2.16.0")))
(t/is (= sections (dcu/highlights-until-installed sections "2.16.0"))) ;; installed 2.17.0 → no newer highlights
(t/is (= [] (dcu/highlights-until-installed sections "2.17.10"))))) (t/is (= [] (dcu/highlights-until-installed sections "2.17.0")))
;; installed older version → shows all available highlights
(t/is (= sections (dcu/highlights-until-installed sections "2.14.0")))
;; installed newer than any highlight → empty
(t/is (= [] (dcu/highlights-until-installed sections "2.99.0")))))
;; --- parse-highlight-item ---
(t/deftest parse-highlight-item-link
(t/is (= [{:type :text :text "See "}
{:type :link :text "#9844"
:href "https://github.com/penpot/penpot/issues/9844"}]
(dcu/parse-highlight-item
"See [#9844](https://github.com/penpot/penpot/issues/9844)"))))
(t/deftest parse-highlight-item-bold
(t/is (= [{:type :bold :text "New plugin system."}]
(dcu/parse-highlight-item "**New plugin system.**"))))
(t/deftest parse-highlight-item-mixed-real-line
(t/is (= [{:type :text :text "Background blur "}
{:type :link :text "#9844"
:href "https://github.com/penpot/penpot/issues/9844"}
{:type :text :text " (PR: "}
{:type :link :text "#10034"
:href "https://github.com/penpot/penpot/pull/10034"}
{:type :text :text ")"}]
(dcu/parse-highlight-item
"Background blur [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))"))))
(t/deftest parse-highlight-item-plain-text
(t/is (= [{:type :text :text "Just plain text, no markdown"}]
(dcu/parse-highlight-item "Just plain text, no markdown"))))
(t/deftest parse-highlight-item-empty
(t/is (= [] (dcu/parse-highlight-item "")))
(t/is (= [] (dcu/parse-highlight-item nil))))
(t/deftest parse-highlight-item-unbalanced-fallback
(t/is (= [{:type :text :text "Broken [link without end"}]
(dcu/parse-highlight-item "Broken [link without end")))
(t/is (= [{:type :text :text "Unclosed **bold"}]
(dcu/parse-highlight-item "Unclosed **bold"))))
(t/deftest parse-highlight-item-non-http-url-is-plain-text
(t/is (= [{:type :text :text "[click](javascript:alert(1))"}]
(dcu/parse-highlight-item "[click](javascript:alert(1))"))))
;; --- parse-highlight-item edge cases ---
(t/deftest parse-highlight-item-taiga-link
(t/is (= [{:type :text :text "Grid CSS layout "}
{:type :link :text "Taiga #4915"
:href "https://tree.taiga.io/project/penpot/epic/4915"}]
(dcu/parse-highlight-item
"Grid CSS layout [Taiga #4915](https://tree.taiga.io/project/penpot/epic/4915)"))))
(t/deftest parse-highlight-item-multi-pr-line
(t/is (= [{:type :text :text "Add MCP "}
{:type :link :text "#9174"
:href "https://github.com/penpot/penpot/issues/9174"}
{:type :text :text " (PR: "}
{:type :link :text "#9032"
:href "https://github.com/penpot/penpot/pull/9032"}
{:type :text :text ", "}
{:type :link :text "#9321"
:href "https://github.com/penpot/penpot/pull/9321"}
{:type :text :text ")"}]
(dcu/parse-highlight-item
"Add MCP [#9174](https://github.com/penpot/penpot/issues/9174) (PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321))"))))
(t/deftest parse-highlight-item-link-only
(t/is (= [{:type :link :text "#1"
:href "https://github.com/penpot/penpot/issues/1"}]
(dcu/parse-highlight-item
"[#1](https://github.com/penpot/penpot/issues/1)"))))
(t/deftest parse-highlight-item-bold-adjacent-to-link
(t/is (= [{:type :bold :text "Hot"}
{:type :text :text " "}
{:type :link :text "#1"
:href "https://github.com/penpot/penpot/issues/1"}]
(dcu/parse-highlight-item
"**Hot** [#1](https://github.com/penpot/penpot/issues/1)"))))
(t/deftest parse-highlight-item-bold-inside-link-is-not-nested
;; Link wins; inner ** stays raw text (documented, no nesting)
(t/is (= [{:type :link :text "a **b**"
:href "https://github.com/penpot/penpot/issues/1"}]
(dcu/parse-highlight-item
"[a **b**](https://github.com/penpot/penpot/issues/1)"))))