Compare commits
1 Commits
python-sec
...
release-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddedf3446c |
2
.github/workflows/set-milestone.yml
vendored
2
.github/workflows/set-milestone.yml
vendored
@@ -85,7 +85,7 @@ jobs:
|
||||
if: ${{ (github.event_name == 'push' && github.event.created == true) || github.event_name == 'workflow_dispatch' }}
|
||||
run: |
|
||||
npm i -g tsx
|
||||
tsx release/src/release-branch-commits.ts ${{ steps.version.outputs.major }} > commit-messages.json
|
||||
tsx release/src/release-branch-commits-run.ts ${{ steps.version.outputs.major }} > commit-messages.json
|
||||
|
||||
- uses: actions/github-script@v9
|
||||
if: ${{ (github.event_name == 'push' && github.event.created == true) || github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
166
backport-audit-report.md
Normal file
166
backport-audit-report.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Backport audit: P0/P1 issues closed since 2026-05-01
|
||||
|
||||
_Generated 2026-07-06. Scope: 105 P0/P1 issues closed as completed since 2026-05-01 in metabase/metabase._
|
||||
|
||||
## Method
|
||||
|
||||
- Issues: closed `Priority:P0`/`Priority:P1` with `stateReason=COMPLETED` since 2026-05-01 (117 closed total; duplicates/not-planned excluded).
|
||||
- Each issue resolved to its closing PR(s) via GitHub's closing references, falling back to cross-referenced merged PRs.
|
||||
- A branch counts as covered if the fix commit is on it (branch cut after merge), a backport commit references the original PR number in its message, the closing PR targeted that release branch directly, or GitHub search found a **merged** backport PR (catches manual backports that omit the original PR number). Backports later reverted on a branch are counted as not covered (none in this set were).
|
||||
- Expected branches: every release branch whose EOL (per the support-dates table) is on/after the fix's master merge date. For fixes merged in May 2026 that includes 54–57 (EOL 2026-05-31); from June onward only 58–62.
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Count |
|
||||
|---|---|
|
||||
| Fully backported to all expected branches | 11 |
|
||||
| Missing on ≥1 currently-supported branch (58–62) | 72 |
|
||||
| Missing only on now-EOL'd branches (54–57) | 2 |
|
||||
| No linked fix PR (manual review needed) | 19 |
|
||||
| Fixed outside metabase/metabase | 1 |
|
||||
|
||||
Missing-fix count per branch (all expected fixes):
|
||||
|
||||
| Branch | EOL | Missing fixes |
|
||||
|---|---|---|
|
||||
| release-x.54.x | 2026-05-31 | 43 |
|
||||
| release-x.55.x | 2026-05-31 | 43 |
|
||||
| release-x.56.x | 2026-05-31 | 43 |
|
||||
| release-x.57.x | 2026-05-31 | 42 |
|
||||
| release-x.58.x | 2027-02-17 (LTS) | 72 |
|
||||
| release-x.59.x | 2026-09-01 | 56 |
|
||||
| release-x.60.x | 2026-09-01 | 31 |
|
||||
| release-x.61.x | 2026-09-01 | 21 |
|
||||
| release-x.62.x | 2026-09-01 | 4 |
|
||||
|
||||
## Gaps on currently-supported branches (58–62)
|
||||
|
||||
`bot-closed` = the backport bot opened a PR for that branch and it was closed without merging (a person decided or CI failed). `none` = no backport PR was ever opened.
|
||||
|
||||
_“Versions in issue” = version numbers mentioned in the issue body — a rough applicability signal; an issue reported only on 62 may not need a 58 backport._
|
||||
|
||||
| Issue | Pri | Fixed | Fix PR(s) | Versions in issue | Missing (active) | Also missing (54–57, EOL'd) | Covered |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| [#70538](https://github.com/metabase/metabase/issues/70538) Can't resolve a comment, or add a subcomment, or add an emoji to a com | P1 | 2026-03-17 | #70802, #70832, #75763 | — | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#72868](https://github.com/metabase/metabase/issues/72868) Verified content Metabot setting not working | P1 | 2026-05-01 | #73498 | — | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#73389](https://github.com/metabase/metabase/issues/73389) Search endpoint pegs app DB CPU to 100% | P1 | 2026-05-01 | #73425 | — | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#73081](https://github.com/metabase/metabase/issues/73081) Transforms failures are not reported consistently | P1 | 2026-05-05 | #73108 | — | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#73298](https://github.com/metabase/metabase/issues/73298) No confirmation to disconnect AI provider | P1 | 2026-05-05 | #73658 | 60 | 58 (none), 59 (none), 61 (none) | 54, 55, 56, 57 | 60, 62 |
|
||||
| [#73541](https://github.com/metabase/metabase/issues/73541) PermissionError in GraalVM Python breaking impersonated native queries | P1 | 2026-05-05 | #73638 | 60 | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#73520](https://github.com/metabase/metabase/issues/73520) databases/schemas with all tables blocked showing as granular in datab | P1 | 2026-05-06 | #73675 | 60 | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#73593](https://github.com/metabase/metabase/issues/73593) Native questions w/ impersonation: SQL parse errors result in a mislea | P1 | 2026-05-06 | #73665 | 60 | 58 (none), 59 (none), 60 (none), 61 (none) | 54, 55, 56, 57 | 62 |
|
||||
| [#73721](https://github.com/metabase/metabase/issues/73721) Adding contains filter on column causes server error | P1 | 2026-05-07 | #73765 | 60 | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#73758](https://github.com/metabase/metabase/issues/73758) Dashboard ID filter crashes on Postgres UUID field filter | P1 | 2026-05-07 | #73778 | — | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#73788](https://github.com/metabase/metabase/issues/73788) Impersonation in Snowflake fails if role name contains `-` (dash) | P1 | 2026-05-07 | #73800 | 60 | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#73804](https://github.com/metabase/metabase/issues/73804) MySQL database settings stopped working on upgrade to v0.60.3.8 | P1 | 2026-05-07 | #73834 | 59, 60 | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#73249](https://github.com/metabase/metabase/issues/73249) Permissions Update Fails with Cluster Lock Error | P1 | 2026-05-08 | #73694, #74734 | — | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#73690](https://github.com/metabase/metabase/issues/73690) Cannot create a chart using Metabot in a document | P1 | 2026-05-08 | #73750 | — | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#73736](https://github.com/metabase/metabase/issues/73736) BigQuery - dashboard cards intermittently fail [Dep/classpath weirdnes | P1 | 2026-05-08 | #73757, #74220, #74685 | 60 | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#73803](https://github.com/metabase/metabase/issues/73803) Scatterplot drill-through omits categorical breakout filter on dashboa | P1 | 2026-05-08 | #73903 | 57, 58 | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#73928](https://github.com/metabase/metabase/issues/73928) /browse/databases/:id freezes for schema-less databases (Mongo/MariaDB | P1 | 2026-05-11 | #73977 | 60, 61 | 58 (none), 59 (none), 60 (none) | 54, 55, 56, 57 | 61, 62 |
|
||||
| [#71558](https://github.com/metabase/metabase/issues/71558) We show the "server issues" screen when the problem is on que query pr | P1 | 2026-05-12 | #74041, #74684 | — | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#71637](https://github.com/metabase/metabase/issues/71637) Native question with variables show generic server error instead of sy | P1 | 2026-05-12 | #74041 | 58, 59 | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#72705](https://github.com/metabase/metabase/issues/72705) Allow overriding c3p0 app-db pool settings + support dynamic DB creden | P1 | 2026-05-12 | #74025 | 59 | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#74047](https://github.com/metabase/metabase/issues/74047) Upgrade to 61 beta from 60 Fails | P1 | 2026-05-12 | #74053 | 61 | 58 (none), 59 (none), 60 (none) | 54, 55, 56, 57 | 61, 62 |
|
||||
| [#72427](https://github.com/metabase/metabase/issues/72427) Search index reindex job throws duplicate upsert error | P1 | 2026-05-13 | #74160 | — | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#73792](https://github.com/metabase/metabase/issues/73792) search missing questions until i click on them since upgrading from 59 | P1 | 2026-05-13 | #74160 | 60 | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#74124](https://github.com/metabase/metabase/issues/74124) Upgrade to 61 beta from 60 Fails (again) | P1 | 2026-05-13 | #74127 | 61 | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#73516](https://github.com/metabase/metabase/issues/73516) Confusing/Generic Server Error When DB Can't be Reached | P1 | 2026-05-15 | #74088 | — | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#74290](https://github.com/metabase/metabase/issues/74290) Cannot setup sandboxing with a SQL question via the UI | P1 | 2026-05-15 | #74297 | — | 58 (none), 59 (none), 60 (none) | 54, 55, 56, 57 | 61, 62 |
|
||||
| [#73880](https://github.com/metabase/metabase/issues/73880) Trying to push remote sync changes can fail with "No remote-syncable c | P1 | 2026-05-19 | #74187 | 61 | 58 (none), 59 (none), 60 (none), 61 (none) | 54, 55, 56, 57 | 62 |
|
||||
| [#74289](https://github.com/metabase/metabase/issues/74289) Model persistence breaks with impersonation | P1 | 2026-05-19 | #74303 | — | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#74417](https://github.com/metabase/metabase/issues/74417) Serialization import fails with H2 sample content present | P1 | 2026-05-19 | #74429 | — | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#73912](https://github.com/metabase/metabase/issues/73912) Conditional formatting colors missing from emaill / Slack subscription | P1 | 2026-05-20 | #74475 | 56, 60 | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#74132](https://github.com/metabase/metabase/issues/74132) Browser console flooded with "Error getting setting graph.tooltip_colu | P1 | 2026-05-20 | #74237 | 60 | 58 (none), 59 (none), 60 (none), 61 (none) | 54, 55, 56, 57 | 62 |
|
||||
| [#74412](https://github.com/metabase/metabase/issues/74412) Remote sync setup deadlocks on report_card when enabling sync on an in | P1 | 2026-05-20 | #74544 | 60 | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#74425](https://github.com/metabase/metabase/issues/74425) Tables created with transforms cannot be imported using serialization | P1 | 2026-05-20 | #74473 | 61 | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#74442](https://github.com/metabase/metabase/issues/74442) Existing database tables cannot be migrated to use transforms via seri | P1 | 2026-05-20 | #74474 | 61 | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#74443](https://github.com/metabase/metabase/issues/74443) Direct upgrade from v59 to v61 fails migration | P1 | 2026-05-20 | #74463, #74594 | — | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#73339](https://github.com/metabase/metabase/issues/73339) A table configured with CLS can result in Invalid SQL Generation | P1 | 2026-05-21 | #74537 | — | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#74599](https://github.com/metabase/metabase/issues/74599) Frontend errors are always posted to an invalid endpoint | P1 | 2026-05-22 | #74623 | 61 | 58 (none), 59 (none), 60 (none) | 54, 55, 56, 57 | 61, 62 |
|
||||
| [#72921](https://github.com/metabase/metabase/issues/72921) Metabot: "Regenerate suggested prompts" fails | P1 | 2026-05-26 | #74359, #74356→59 | — | 58 (none) | 54, 55, 56, 57 | 59, 60, 61, 62 |
|
||||
| [#74284](https://github.com/metabase/metabase/issues/74284) SQLGlot rewrites queries with null values | P1 | 2026-05-27 | #74440 | — | 58 (none), 59 (none) | 54, 55, 56, 57 | 60, 61, 62 |
|
||||
| [#74571](https://github.com/metabase/metabase/issues/74571) Maximum update depth exceeded on Database page | P1 | 2026-05-29 | #71351 | — | 58 (none), 59 (none), 60 (none), 61 (none) | 54, 55, 56, 57 | 62 |
|
||||
| [#74950](https://github.com/metabase/metabase/issues/74950) Upgrading from 55 to 57 messed up Pivot settings | P1 | 2026-05-29 | #74972 | 57 | 58 (none), 59 (none), 60 (none) | 54, 55, 56, 57 | 61, 62 |
|
||||
| [#74860](https://github.com/metabase/metabase/issues/74860) `between` custom expression for timestamp columns does not include upp | P1 | 2026-06-02 | #74875 | 61 | 58 (none), 59 (none), 60 (none) | — | 61, 62 |
|
||||
| [#75117](https://github.com/metabase/metabase/issues/75117) Some Japanese names in Custom Expression break when used as sorting in | P1 | 2026-06-04 | #75123 | 57, 61 | 58 (none), 59 (none), 60 (none) | — | 61, 62 |
|
||||
| [#75187](https://github.com/metabase/metabase/issues/75187) API serialization export.log is always empty | P1 | 2026-06-04 | #75255 | — | 58 (none), 59 (none) | — | 60, 61, 62 |
|
||||
| [#75230](https://github.com/metabase/metabase/issues/75230) Serialization (Remote Sync) - Result Metadata is not extracted for Nat | P1 | 2026-06-04 | #75259 | 57, 61 | 58 (none), 59 (none) | — | 60, 61, 62 |
|
||||
| [#74542](https://github.com/metabase/metabase/issues/74542) Relative dates in SQL field filters use UTC date boundaries, not the r | P1 | 2026-06-08 | #75029 | 61 | 58 (none), 59 (none), 60 (none), 61 (none) | — | 62 |
|
||||
| [#74561](https://github.com/metabase/metabase/issues/74561) Switching the database on SQL models will make dependant cards not to | P1 | 2026-06-08 | #75268 | — | 58 (none), 59 (none), 60 (none), 61 (none), 62 (none) | — | — |
|
||||
| [#74956](https://github.com/metabase/metabase/issues/74956) Removing Create queries permission on 1 Table in MYSQL removes permiss | P1 | 2026-06-08 | #74988 | 61 | 58 (none), 59 (none), 60 (none), 61 (none) | — | 62 |
|
||||
| [#75252](https://github.com/metabase/metabase/issues/75252) Password login can't be disabled when OIDC is the only SSO provider | P1 | 2026-06-08 | #75361 | 61 | 58 (none), 59 (none), 60 (none) | — | 61, 62 |
|
||||
| [#75310](https://github.com/metabase/metabase/issues/75310) Apache Druid transient errors | P1 | 2026-06-08 | #75350 | 61 | 58 (none), 59 (none), 60 (none), 61 (none) | — | 62 |
|
||||
| [#75524](https://github.com/metabase/metabase/issues/75524) Investigate High CPU Utilization for Cloud Customer | P1 | 2026-06-09 | #75548 | — | 58 (none), 59 (none) | — | 60, 61, 62 |
|
||||
| [#75458](https://github.com/metabase/metabase/issues/75458) 62.x won't work with MariaDB (upgrade or new instance) | P1 | 2026-06-10 | #75496 | — | 58 (none), 59 (none), 60 (none), 61 (none) | — | 62 |
|
||||
| [#75570](https://github.com/metabase/metabase/issues/75570) Field Filters Broken for BigQuery | P1 | 2026-06-11 | #75644 | 60, 62 | 58 (none), 59 (none), 60 (none), 61 (none) | — | 62 |
|
||||
| [#75748](https://github.com/metabase/metabase/issues/75748) Investigate OOMs on Dependency Checks | P1 | 2026-06-12 | #75769 | — | 58 (none), 59 (none), 60 (none), 61 (none) | — | 62 |
|
||||
| [#75752](https://github.com/metabase/metabase/issues/75752) Investigate customer instance restarts due to app DB connection exhaus | P1 | 2026-06-12 | #75770 | 60 | 58 (none), 59 (none) | — | 60, 61, 62 |
|
||||
| [#75757](https://github.com/metabase/metabase/issues/75757) Native SQL editor search ignores selected database | P1 | 2026-06-12 | #75772 | — | 58 (none), 59 (none), 60 (none) | — | 61, 62 |
|
||||
| [#75929](https://github.com/metabase/metabase/issues/75929) MySQL/PlanetScale — upgrading to 1.62 marks all tables inactive; GUI q | P1 | 2026-06-17 | #75971 | 61, 62 | 58 (none), 59 (none), 60 (none), 61 (none) | — | 62 |
|
||||
| [#60460](https://github.com/metabase/metabase/issues/60460) [Spike] Improve PDF exports performance | P1 | 2026-06-18 | #76016 | — | 58 (none), 59 (none) | — | 60, 61, 62 |
|
||||
| [#75144](https://github.com/metabase/metabase/issues/75144) API-created filters open Custom Expression editor | P1 | 2026-06-18 | #75998 | — | 58 (none), 59 (none), 60 (none), 61 (none), 62 (none) | — | — |
|
||||
| [#75302](https://github.com/metabase/metabase/issues/75302) OIDC "Check connection" always fails for Microsoft Entra ID | P1 | 2026-06-18 | #75416 | 61 | 58 (none), 59 (none) | — | 60, 61, 62 |
|
||||
| [#76077](https://github.com/metabase/metabase/issues/76077) OOM during database sync: set-default-table-permissions! loads the ent | P1 | 2026-06-18 | #76100 | — | 58 (none), 59 (none), 60 (none) | — | 61, 62 |
|
||||
| [#75604](https://github.com/metabase/metabase/issues/75604) Custom column type is not persisted after saving question based on an | P1 | 2026-06-22 | #75663 | 62 | 58 (none), 59 (none), 60 (none) | — | 61, 62 |
|
||||
| [#76174](https://github.com/metabase/metabase/issues/76174) Search results sorting degradation after recent changes | P1 | 2026-06-22 | #76190→62 | 62 | 58 (none), 59 (none), 60 (none), 61 (none) | — | 62 |
|
||||
| [#76222](https://github.com/metabase/metabase/issues/76222) Table has no Fields associated with it (MySql) | P1 | 2026-06-22 | #76286 | 60 | 58 (none), 59 (none), 60 (none), 61 (none), 62 (none) | — | — |
|
||||
| [#76304](https://github.com/metabase/metabase/issues/76304) Cannot delete a workspace with databases that are not :unprovisioned; | P1 | 2026-06-25 | #76350 | — | 58 (none), 59 (none), 60 (none), 61 (none) | — | 62 |
|
||||
| [#76465](https://github.com/metabase/metabase/issues/76465) v0.62.3.x migration task_run_notification_id fails on MySQL with "Trun | P1 | 2026-06-25 | #76509 | 62 | 58 (none), 59 (none), 60 (none), 61 (none) | — | 62 |
|
||||
| [#76510](https://github.com/metabase/metabase/issues/76510) v0.62.3 (and v0.62.2) doesn't migrate successfully with MySQL 8.4 (rev | P1 | 2026-06-25 | #76540 | 62 | 58 (none), 59 (none), 60 (none), 61 (none) | — | 62 |
|
||||
| [#76542](https://github.com/metabase/metabase/issues/76542) unable to render this card on pdf subscription | P1 | 2026-06-29 | #76605 | — | 58 (none), 59 (none), 60 (none), 61 (none), 62 (none) | — | — |
|
||||
| [#76511](https://github.com/metabase/metabase/issues/76511) Sdk - controlled parameters prop leaks into drill-through target dashb | P1 | 2026-07-01 | #76716 | 62 | 58 (none), 59 (none), 60 (none), 61 (none) | — | 62 |
|
||||
| [#76585](https://github.com/metabase/metabase/issues/76585) ClickHouse: Day bucketing ignores the report timezone for model / nest | P1 | 2026-07-02 | #76697 | 62 | 58 (none), 59 (none) | — | 60, 61, 62 |
|
||||
| [#76702](https://github.com/metabase/metabase/issues/76702) Embedding SDK: `MetabotQuestion` intermittently does not show the SQL | P1 | 2026-07-02 | #76738 | 62 | 58 (none) | — | 59, 60, 61, 62 |
|
||||
| [#76856](https://github.com/metabase/metabase/issues/76856) Query cache extends cache instead of updating it if refresh is interru | P1 | 2026-07-03 | #76898 | — | 58 (none), 59 (none) | — | 60, 61, 62 |
|
||||
|
||||
## Missing only on now-EOL'd 54–57
|
||||
|
||||
- [#71223](https://github.com/metabase/metabase/issues/71223) Visualizer breaks internal dimension remapping for integer Category fields (column renaming doesn't update `remapped_from` references) — fixed 2026-05-21 (#74446), missing from [54, 55, 56], covered [57, 58, 59, 60, 61, 62]
|
||||
- [#73224](https://github.com/metabase/metabase/issues/73224) <metabase-dashboard> web component does not auto-resize to fit content (guest embedding, 1.58.11) — fixed 2026-04-29 (#73338), missing from [54, 55, 56, 57], covered [58, 59, 60, 61, 62]
|
||||
|
||||
## Fully backported to all expected branches
|
||||
|
||||
- [#74954](https://github.com/metabase/metabase/issues/74954) Instance hangs indefinitely on SearchIndexReindex job - no crash, health check green, Quartz misfires — fixed 2026-06-03, covered [58, 59, 60, 61, 62]
|
||||
- [#75305](https://github.com/metabase/metabase/issues/75305) Mongo RLS users lose access to view Object Type columns — fixed 2026-06-11, covered [58, 59, 60, 61, 62]
|
||||
- [#75334](https://github.com/metabase/metabase/issues/75334) Security center won't work advisories with patch versions — fixed 2026-06-08, covered [58, 59, 60, 61, 62]
|
||||
- [#75579](https://github.com/metabase/metabase/issues/75579) Athena Driver fails to sync fields for partitioned tables — fixed 2026-06-11, covered [58, 59, 60, 61, 62]
|
||||
- [#75602](https://github.com/metabase/metabase/issues/75602) Download diagnostics — dashboard definition never included — fixed 2026-06-18, covered [58, 59, 60, 61, 62]
|
||||
- [#76044](https://github.com/metabase/metabase/issues/76044) Metrics don't work for users in a Customer RCLS Permission Group — fixed 2026-06-22, covered [58, 59, 60, 61, 62]
|
||||
- [#76136](https://github.com/metabase/metabase/issues/76136) Hiding a table column via dashboard card Visualization Options does not persist after clicking Done — fixed 2026-06-29, covered [58, 59, 60, 61, 62]
|
||||
- [#76249](https://github.com/metabase/metabase/issues/76249) Dashboard filter not showing dropdown on remote synced collection — fixed 2026-06-25, covered [58, 59, 60, 61, 62]
|
||||
- [#76710](https://github.com/metabase/metabase/issues/76710) Query on tableA fails when tableA has FK to tableB and user lacks Create queries on tableB — fixed 2026-06-30, covered [58, 59, 60, 61, 62]
|
||||
- [#76722](https://github.com/metabase/metabase/issues/76722) [mongodb double join] invalid character for a variable name: ':'' — fixed 2026-07-02, covered [58, 59, 60, 61, 62]
|
||||
- [#76758](https://github.com/metabase/metabase/issues/76758) Add more info to "Request canceled before finishing" exception — fixed 2026-07-03, covered [58, 59, 60, 61, 62]
|
||||
|
||||
## Closed as completed but no linked fix PR (manual review)
|
||||
|
||||
- [#71378](https://github.com/metabase/metabase/issues/71378) [P1] Model section is not showing Models (closed 2026-06-29)
|
||||
- [#71703](https://github.com/metabase/metabase/issues/71703) [P1] axios@1.14.1 and axios@0.30.4 are compromised. Metabase has an unpinned axios version that could resolve to 1.14.1 (closed 2026-05-05)
|
||||
- [#72430](https://github.com/metabase/metabase/issues/72430) [P1] Alerts fail to send due to permission error (closed 2026-06-01)
|
||||
- [#73196](https://github.com/metabase/metabase/issues/73196) [P1] GraalPython execution in clojure-agent-send-off-pool causes OOM via huge Python list materialization (closed 2026-05-12)
|
||||
- [#73382](https://github.com/metabase/metabase/issues/73382) [P1] Investigate Performance Degradation on Permission Checks (closed 2026-05-04)
|
||||
- [#73453](https://github.com/metabase/metabase/issues/73453) [P1] Intermittent customer-facing 404s on hosted instances (closed 2026-06-10)
|
||||
- [#73560](https://github.com/metabase/metabase/issues/73560) [P1] Oracle driver fails to load in v1.60.3.5+ (closed 2026-05-07)
|
||||
- [#73619](https://github.com/metabase/metabase/issues/73619) [P1] Metabot SQL query creation fails with FileSystemAlreadyExistsException (closed 2026-05-20)
|
||||
- [#73645](https://github.com/metabase/metabase/issues/73645) [P1] Cards get deleted after Metabase Cloud Storage database change/pod restart (v1.60.3.6 to v1.60.3.8) (closed 2026-05-08)
|
||||
- [#73781](https://github.com/metabase/metabase/issues/73781) [P1] MCP server struggles to handle questions with joins (closed 2026-06-08)
|
||||
- [#74008](https://github.com/metabase/metabase/issues/74008) [P1] Model result metadata is not updated when underlying table is updated, breaking queries affected by RLS (closed 2026-05-11)
|
||||
- [#74405](https://github.com/metabase/metabase/issues/74405) [P1] App crashes after selecting question for sandboxing (closed 2026-05-20)
|
||||
- [#74765](https://github.com/metabase/metabase/issues/74765) [P1] Metabot sometimes can't see databases or tables when it clearly should (closed 2026-05-27)
|
||||
- [#75064](https://github.com/metabase/metabase/issues/75064) [P1] Search Sometimes Doesn't Return Results (closed 2026-06-29)
|
||||
- [#76149](https://github.com/metabase/metabase/issues/76149) [P1] H2 dump leaves some columns encrypted (closed 2026-06-23)
|
||||
- [#76386](https://github.com/metabase/metabase/issues/76386) [P1] BigQuery field sync fails with ISeq error (closed 2026-06-24)
|
||||
- [#76395](https://github.com/metabase/metabase/issues/76395) [P1] BigQuery CSV download truncates silently on large result sets (closed 2026-06-24)
|
||||
- [#76564](https://github.com/metabase/metabase/issues/76564) [P1] setup-token exposed again in /api/session/properties after setup is complete (regression in 1.62.2) (closed 2026-06-30)
|
||||
- [#77093](https://github.com/metabase/metabase/issues/77093) [P1] Metabot: claude-sonnet-5 crashes agent loop with "No matching clause: :thinking" (closed 2026-07-06)
|
||||
|
||||
## Fixed outside this repo
|
||||
|
||||
- [#76243](https://github.com/metabase/metabase/issues/76243) Updates section in Admin page fails to show embedded release notes and changelog — fixed in metabase/metabase.github.io#5957 (website), no backport applicable
|
||||
|
||||
## Caveats
|
||||
|
||||
- Applicability is not verified per-issue: a fix for a bug introduced in v61 does not need a 58 backport even though this audit lists it as a gap. The 'Versions in issue' column helps triage but each gap on 58/59 should be checked against when the bug was introduced.
|
||||
- Issues closed since 2026-05-01 whose fixes merged earlier are included; the mandate window is interpreted as issue-close date.
|
||||
- Open (unmerged) backport PRs would appear as gaps; none were found open for these fixes at audit time.
|
||||
1
release/.gitignore
vendored
1
release/.gitignore
vendored
@@ -6,3 +6,4 @@ dist/
|
||||
version.properties
|
||||
version-info.json
|
||||
v*.html
|
||||
release-report-*.html
|
||||
|
||||
@@ -60,3 +60,21 @@ In case you want to preview release notes generation, or re-generate them after
|
||||
```sh
|
||||
bun run generate-changelog v0.77.0 > changelog.log
|
||||
```
|
||||
|
||||
### Release report
|
||||
|
||||
Generate a self-contained HTML report for a newly-cut major release branch. It
|
||||
traces every commit on `release-x.<major>.x` back to where the previous major
|
||||
forked from master, resolves the linked GitHub issues and Linear projects,
|
||||
previews the release notes, and asks the `claude` CLI for a "major themes"
|
||||
summary.
|
||||
|
||||
```sh
|
||||
bun run generate-release-report 63 # writes release-report-v63.html
|
||||
```
|
||||
|
||||
Requires `GITHUB_TOKEN` / `GITHUB_OWNER` / `GITHUB_REPO` (in `release/.env`) and
|
||||
`LINEAR_API_KEY` (in the repo-root `.env`). The `release-x.<major>.x`,
|
||||
`release-x.<major-1>.x`, and `master` branches must be fetchable. The theme
|
||||
summary shells out to the `claude` CLI in headless mode; if it isn't on `PATH`
|
||||
the report still renders with a deterministic project-based fallback.
|
||||
|
||||
82
release/generate-release-report.ts
Normal file
82
release/generate-release-report.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Generate a self-contained HTML release report for a newly-cut major release
|
||||
// branch. Traces every commit back to the previous major's fork point, resolves
|
||||
// the linked GitHub issues and Linear projects, previews the release notes, and
|
||||
// asks the `claude` CLI for a "major themes" summary.
|
||||
//
|
||||
// cd release && bun run generate-release-report 63
|
||||
//
|
||||
// Requires GITHUB_TOKEN / GITHUB_OWNER / GITHUB_REPO and LINEAR_API_KEY in the
|
||||
// environment (the repo .env at the project root already has LINEAR_API_KEY).
|
||||
// The release-x.<major>.x and master branches must be fetched locally.
|
||||
|
||||
import fs from "fs";
|
||||
|
||||
import { $ } from "zx";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
|
||||
import { generateThemeSummary } from "./src/ai-summary";
|
||||
import { getReleaseReportData } from "./src/release-report";
|
||||
import { renderReleaseReportHtml } from "./src/release-report-html";
|
||||
|
||||
const { GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO, LINEAR_API_KEY } = process.env as Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
|
||||
const majorVersion = Number(process.argv[2]);
|
||||
|
||||
if (!Number.isInteger(majorVersion) || majorVersion <= 0) {
|
||||
console.error("Usage: bun run generate-release-report <major-version> (e.g. 63)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!GITHUB_TOKEN || !GITHUB_OWNER || !GITHUB_REPO) {
|
||||
console.error("Missing GITHUB_TOKEN / GITHUB_OWNER / GITHUB_REPO in the environment.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!LINEAR_API_KEY) {
|
||||
console.error("Missing LINEAR_API_KEY in the environment (needed to resolve Linear projects).");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const github = new Octokit({ auth: GITHUB_TOKEN });
|
||||
|
||||
// Make sure the branches we diff exist locally and are current.
|
||||
$.verbose = false;
|
||||
try {
|
||||
console.log(`Fetching origin/release-x.${majorVersion}.x, origin/release-x.${majorVersion - 1}.x, master…`);
|
||||
await $`git fetch origin release-x.${majorVersion}.x release-x.${majorVersion - 1}.x master`;
|
||||
} catch {
|
||||
console.warn("git fetch failed (offline?); continuing with whatever refs are present locally.");
|
||||
}
|
||||
|
||||
const data = await getReleaseReportData({
|
||||
github,
|
||||
owner: GITHUB_OWNER,
|
||||
repo: GITHUB_REPO,
|
||||
majorVersion,
|
||||
linearApiKey: LINEAR_API_KEY,
|
||||
});
|
||||
|
||||
console.log("Generating theme summary via the claude CLI…");
|
||||
const summaryMarkdown = await generateThemeSummary({
|
||||
version: data.version,
|
||||
issueCount: data.issues.length,
|
||||
linearIssueCount: data.linearIssueCount,
|
||||
projects: data.projects,
|
||||
notesMarkdown: data.releaseNotesMarkdown,
|
||||
});
|
||||
|
||||
const generatedAt = new Date().toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
const html = renderReleaseReportHtml({ data, summaryMarkdown, generatedAt });
|
||||
|
||||
const outFile = `release-report-v${majorVersion}.html`;
|
||||
fs.writeFileSync(outFile, html);
|
||||
|
||||
console.log(`\n✅ Wrote ${outFile}`);
|
||||
console.log(
|
||||
` ${data.issues.length} issues · ${data.prCount} PRs · ${data.linearIssueCount} Linear issues · ${
|
||||
data.projects.filter(p => p.project !== null).length
|
||||
} projects`,
|
||||
);
|
||||
@@ -10,6 +10,7 @@
|
||||
"scripts": {
|
||||
"build": "esbuild src/index.ts --bundle --outfile=dist/index.cjs --platform=node --target=node16",
|
||||
"generate-changelog": "bun generate-changelog.ts",
|
||||
"generate-release-report": "bun generate-release-report.ts",
|
||||
"format": "oxfmt --write",
|
||||
"release-offline": "tsx release-offline.ts",
|
||||
"test:unit": "jest --testPathPattern unit",
|
||||
|
||||
125
release/src/ai-summary.ts
Normal file
125
release/src/ai-summary.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// AI-generated "major themes" summary for a release report.
|
||||
//
|
||||
// Rather than depend on an API key, we shell out to the `claude` CLI in headless
|
||||
// print mode (`claude -p`). This means the report self-drives anywhere Claude
|
||||
// Code is installed (a developer laptop, or CI with the CLI on PATH). If the CLI
|
||||
// is unavailable or errors, we degrade gracefully to a deterministic summary so
|
||||
// the report still renders.
|
||||
|
||||
import { spawn } from "child_process";
|
||||
|
||||
import type { ProjectSummary } from "./linear";
|
||||
|
||||
export type SummaryInput = {
|
||||
version: string;
|
||||
issueCount: number;
|
||||
linearIssueCount: number;
|
||||
projects: ProjectSummary[];
|
||||
/** The categorized release-notes markdown (used as grounding context). */
|
||||
notesMarkdown: string;
|
||||
};
|
||||
|
||||
export type RunClaude = (prompt: string) => Promise<string>;
|
||||
|
||||
// Keep the grounding context bounded — release notes for a major can be long,
|
||||
// and the theme summary only needs the shape of what shipped, not every line.
|
||||
const MAX_NOTES_CHARS = 12_000;
|
||||
|
||||
export function buildSummaryPrompt(input: SummaryInput): string {
|
||||
const { version, issueCount, linearIssueCount, projects, notesMarkdown } = input;
|
||||
|
||||
const namedProjects = projects.filter(p => p.project !== null);
|
||||
const projectLines = namedProjects
|
||||
.map(p => `- ${p.name} (${p.issueCount} issue${p.issueCount === 1 ? "" : "s"})`)
|
||||
.join("\n");
|
||||
|
||||
const notes =
|
||||
notesMarkdown.length > MAX_NOTES_CHARS
|
||||
? notesMarkdown.slice(0, MAX_NOTES_CHARS) + "\n\n…(truncated)…"
|
||||
: notesMarkdown;
|
||||
|
||||
return [
|
||||
`You are writing the "major themes" section for the Metabase ${version} release notes.`,
|
||||
``,
|
||||
`Below is the data for this release: ${issueCount} GitHub issues/PRs shipped, of which ${linearIssueCount} map to Linear issues across ${namedProjects.length} Linear projects.`,
|
||||
``,
|
||||
`## Linear projects in this release (by issue count)`,
|
||||
projectLines || "(none)",
|
||||
``,
|
||||
`## Categorized release notes`,
|
||||
notes,
|
||||
``,
|
||||
`---`,
|
||||
`Write a concise, engaging summary of the MAJOR THEMES of this release in Markdown. Requirements:`,
|
||||
`- Start with a single italicized one-sentence TL;DR line.`,
|
||||
`- Then 3 to 6 themes. Each theme is a "### " heading (a short punchy title) followed by 1-3 sentences.`,
|
||||
`- Ground every claim in the data above. Do NOT invent features, numbers, or version details that are not present.`,
|
||||
`- Prefer themes that span multiple projects or many issues. It's fine to mention notable individual projects by name.`,
|
||||
`- Do not include a top-level "# " heading, and do not restate these instructions.`,
|
||||
`- Output ONLY the Markdown summary.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Default runner: pipe the prompt to `claude -p` on stdin and collect stdout.
|
||||
const spawnClaude: RunClaude = (prompt: string) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const child = spawn("claude", ["-p", "--output-format", "text"], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("claude CLI timed out"));
|
||||
}, 180_000);
|
||||
|
||||
child.stdout.on("data", d => (stdout += d.toString()));
|
||||
child.stderr.on("data", d => (stderr += d.toString()));
|
||||
child.on("error", err => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
child.on("close", code => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim());
|
||||
} else {
|
||||
reject(new Error(`claude CLI exited with code ${code}: ${stderr.trim()}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(prompt);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
function fallbackSummary(input: SummaryInput): string {
|
||||
const named = input.projects.filter(p => p.project !== null).slice(0, 6);
|
||||
const bullets = named
|
||||
.map(p => `- **${p.name}** — ${p.issueCount} issue${p.issueCount === 1 ? "" : "s"}`)
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
`_AI summary unavailable (the \`claude\` CLI could not be reached); showing the top projects by issue count._`,
|
||||
``,
|
||||
bullets || "_No Linear projects were detected in this release._",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the "major themes" summary as Markdown. Never throws — on any failure
|
||||
* it returns a deterministic fallback so the report always renders.
|
||||
*/
|
||||
export async function generateThemeSummary(
|
||||
input: SummaryInput,
|
||||
runClaude: RunClaude = spawnClaude,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const summary = await runClaude(buildSummaryPrompt(input));
|
||||
const trimmed = summary.trim();
|
||||
return trimmed.length > 0 ? trimmed : fallbackSummary(input);
|
||||
} catch (error) {
|
||||
console.warn(`Theme summary generation failed: ${(error as Error).message}`);
|
||||
return fallbackSummary(input);
|
||||
}
|
||||
}
|
||||
65
release/src/ai-summary.unit.spec.ts
Normal file
65
release/src/ai-summary.unit.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { buildSummaryPrompt, generateThemeSummary, type SummaryInput } from "./ai-summary";
|
||||
import type { ProjectSummary } from "./linear";
|
||||
|
||||
const projectSummary = (name: string | null, issueCount: number): ProjectSummary => ({
|
||||
project: name ? { id: name, name, url: `https://linear.app/${name}`, state: "started" } : null,
|
||||
name: name ?? "No Linear project",
|
||||
issueCount,
|
||||
issues: [],
|
||||
});
|
||||
|
||||
const input: SummaryInput = {
|
||||
version: "v0.63.0",
|
||||
issueCount: 200,
|
||||
linearIssueCount: 150,
|
||||
projects: [
|
||||
projectSummary("Offer PDF attachments", 8),
|
||||
projectSummary("Accelerate Typescript migration", 3),
|
||||
projectSummary(null, 100),
|
||||
],
|
||||
notesMarkdown: "## Bug fixes\n\n**Querying**\n\n- Fixed a thing (#1)",
|
||||
};
|
||||
|
||||
describe("buildSummaryPrompt", () => {
|
||||
it("includes version, counts, named projects, and notes as grounding", () => {
|
||||
const prompt = buildSummaryPrompt(input);
|
||||
expect(prompt).toContain("Metabase v0.63.0");
|
||||
expect(prompt).toContain("200 GitHub issues/PRs");
|
||||
expect(prompt).toContain("150 map to Linear");
|
||||
expect(prompt).toContain("Offer PDF attachments (8 issues)");
|
||||
expect(prompt).toContain("Accelerate Typescript migration (3 issues)");
|
||||
// The "No Linear project" bucket is not a theme source.
|
||||
expect(prompt).not.toContain("No Linear project (100");
|
||||
expect(prompt).toContain("Fixed a thing (#1)");
|
||||
});
|
||||
|
||||
it("truncates very long notes", () => {
|
||||
const long = { ...input, notesMarkdown: "x".repeat(20_000) };
|
||||
const prompt = buildSummaryPrompt(long);
|
||||
expect(prompt).toContain("…(truncated)…");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateThemeSummary", () => {
|
||||
it("returns the CLI output when it succeeds", async () => {
|
||||
const runClaude = jest.fn(async () => "### Theme\nGreat stuff.");
|
||||
const summary = await generateThemeSummary(input, runClaude);
|
||||
expect(summary).toBe("### Theme\nGreat stuff.");
|
||||
expect(runClaude).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to top projects when the CLI throws", async () => {
|
||||
const runClaude = jest.fn(async () => {
|
||||
throw new Error("claude not found");
|
||||
});
|
||||
const summary = await generateThemeSummary(input, runClaude);
|
||||
expect(summary).toContain("AI summary unavailable");
|
||||
expect(summary).toContain("**Offer PDF attachments** — 8 issues");
|
||||
});
|
||||
|
||||
it("falls back when the CLI returns empty output", async () => {
|
||||
const runClaude = jest.fn(async () => " ");
|
||||
const summary = await generateThemeSummary(input, runClaude);
|
||||
expect(summary).toContain("AI summary unavailable");
|
||||
});
|
||||
});
|
||||
282
release/src/linear.ts
Normal file
282
release/src/linear.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
// Linear seam for release reporting.
|
||||
//
|
||||
// Metabase links Linear issues to GitHub PRs/issues via attachments (the
|
||||
// `attachmentsForURL` bridge — see .github/scripts/find-linear-issue.js). Given
|
||||
// a set of GitHub URLs we resolve the Linear issues behind them, then roll the
|
||||
// issues up by Linear *project* so we can report which projects shipped work in
|
||||
// a release and how many issues each contributed.
|
||||
//
|
||||
// The API key is sent RAW in the Authorization header (no `Bearer` prefix) —
|
||||
// this matches how CI uses LINEAR_API_KEY elsewhere in the repo.
|
||||
|
||||
const LINEAR_API_URL = "https://api.linear.app/graphql";
|
||||
|
||||
// attachmentsForURL is one graph traversal per URL; batching many into a single
|
||||
// request with field aliases keeps us well under Linear's complexity limit
|
||||
// while cutting round-trips ~25x.
|
||||
const DEFAULT_BATCH_SIZE = 25;
|
||||
|
||||
export type LinearProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
state: string | null;
|
||||
};
|
||||
|
||||
export type LinearTeam = {
|
||||
key: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type LinearIssue = {
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
team: LinearTeam | null;
|
||||
project: LinearProject | null;
|
||||
/** The GitHub URL that resolved to this Linear issue. */
|
||||
sourceUrl: string;
|
||||
};
|
||||
|
||||
export type ProjectSummary = {
|
||||
/** null project means the issues had no Linear project (e.g. one-off bugs). */
|
||||
project: LinearProject | null;
|
||||
name: string;
|
||||
issueCount: number;
|
||||
issues: LinearIssue[];
|
||||
};
|
||||
|
||||
type FetchLike = (
|
||||
url: string,
|
||||
init: {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
},
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<any>;
|
||||
}>;
|
||||
|
||||
export type GetLinearIssuesOptions = {
|
||||
urls: string[];
|
||||
apiKey: string;
|
||||
batchSize?: number;
|
||||
/** Injectable for tests; defaults to global fetch. */
|
||||
fetchFn?: FetchLike;
|
||||
/** Injectable for tests; defaults to a real setTimeout-based sleep. */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
onProgress?: (done: number, total: number) => void;
|
||||
};
|
||||
|
||||
const defaultSleep = (ms: number) =>
|
||||
new Promise<void>(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// A Linear issue can be legitimately linked to multiple GitHub URLs (the issue
|
||||
// *and* its PR). We use `_key` internally to make each alias unique and map the
|
||||
// response back to the original URL.
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
out.push(items.slice(i, i + size));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildBatchQuery(urls: string[]): string {
|
||||
const fields = urls
|
||||
.map(
|
||||
(url, i) => `
|
||||
u${i}: attachmentsForURL(url: ${JSON.stringify(url)}) {
|
||||
nodes {
|
||||
issue {
|
||||
identifier
|
||||
title
|
||||
url
|
||||
team { key name }
|
||||
project { id name url state }
|
||||
}
|
||||
}
|
||||
}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `query ReleaseAttachments {${fields}\n}`;
|
||||
}
|
||||
|
||||
async function queryLinearWithBackoff({
|
||||
query,
|
||||
apiKey,
|
||||
fetchFn,
|
||||
sleep,
|
||||
maxRetries = 6,
|
||||
}: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
fetchFn: FetchLike;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
maxRetries?: number;
|
||||
}): Promise<any> {
|
||||
let delay = 500;
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetchFn(LINEAR_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: apiKey,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Linear API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
if (body.errors) {
|
||||
throw new Error(`Linear GraphQL error: ${JSON.stringify(body.errors)}`);
|
||||
}
|
||||
|
||||
return body.data ?? {};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
await sleep(delay);
|
||||
// exponential backoff with jitter
|
||||
delay = Math.min(delay * 2 * (0.8 + Math.random() * 0.4), 15_000);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a list of GitHub PR/issue URLs to the Linear issues attached to them.
|
||||
* Returns a flat, de-duplicated list of Linear issues (deduped by identifier —
|
||||
* the same Linear issue linked from both a PR and an issue URL appears once).
|
||||
*/
|
||||
export async function getLinearIssuesForUrls({
|
||||
urls,
|
||||
apiKey,
|
||||
batchSize = DEFAULT_BATCH_SIZE,
|
||||
fetchFn = globalThis.fetch as unknown as FetchLike,
|
||||
sleep = defaultSleep,
|
||||
onProgress,
|
||||
}: GetLinearIssuesOptions): Promise<LinearIssue[]> {
|
||||
const uniqueUrls = Array.from(new Set(urls));
|
||||
const batches = chunk(uniqueUrls, batchSize);
|
||||
const byIdentifier = new Map<string, LinearIssue>();
|
||||
|
||||
let done = 0;
|
||||
for (const batch of batches) {
|
||||
const data = await queryLinearWithBackoff({
|
||||
query: buildBatchQuery(batch),
|
||||
apiKey,
|
||||
fetchFn,
|
||||
sleep,
|
||||
});
|
||||
|
||||
batch.forEach((url, i) => {
|
||||
const nodes = data?.[`u${i}`]?.nodes ?? [];
|
||||
for (const node of nodes) {
|
||||
const issue = node?.issue;
|
||||
if (!issue?.identifier) {
|
||||
continue;
|
||||
}
|
||||
// First link wins for sourceUrl; identifier de-dupes across PR+issue.
|
||||
if (!byIdentifier.has(issue.identifier)) {
|
||||
byIdentifier.set(issue.identifier, {
|
||||
identifier: issue.identifier,
|
||||
title: issue.title ?? "",
|
||||
url: issue.url ?? "",
|
||||
team: issue.team
|
||||
? { key: issue.team.key, name: issue.team.name ?? issue.team.key }
|
||||
: null,
|
||||
project: issue.project
|
||||
? {
|
||||
id: issue.project.id,
|
||||
name: issue.project.name,
|
||||
url: issue.project.url ?? "",
|
||||
state: issue.project.state ?? null,
|
||||
}
|
||||
: null,
|
||||
sourceUrl: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
done += batch.length;
|
||||
onProgress?.(done, uniqueUrls.length);
|
||||
}
|
||||
|
||||
return Array.from(byIdentifier.values());
|
||||
}
|
||||
|
||||
const NO_PROJECT_NAME = "No Linear project";
|
||||
|
||||
/**
|
||||
* Group Linear issues by project and count them. Issues without a project are
|
||||
* collected under a single "No Linear project" bucket, which is always sorted
|
||||
* last regardless of size. Named projects are sorted by issue count desc, then
|
||||
* name asc for stable output.
|
||||
*/
|
||||
export function collectLinearProjects(issues: LinearIssue[]): ProjectSummary[] {
|
||||
const groups = new Map<string, ProjectSummary>();
|
||||
|
||||
for (const issue of issues) {
|
||||
const key = issue.project ? issue.project.id : NO_PROJECT_NAME;
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.issueCount += 1;
|
||||
existing.issues.push(issue);
|
||||
} else {
|
||||
groups.set(key, {
|
||||
project: issue.project,
|
||||
name: issue.project ? issue.project.name : NO_PROJECT_NAME,
|
||||
issueCount: 1,
|
||||
issues: [issue],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.values()).sort((a, b) => {
|
||||
const aNoProject = a.project === null;
|
||||
const bNoProject = b.project === null;
|
||||
if (aNoProject !== bNoProject) {
|
||||
return aNoProject ? 1 : -1; // "No Linear project" always last
|
||||
}
|
||||
if (b.issueCount !== a.issueCount) {
|
||||
return b.issueCount - a.issueCount;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the GitHub URL variants we query Linear with. Engineers most often
|
||||
* attach Linear to the PR, but sometimes to the issue, so we try both.
|
||||
*/
|
||||
export function githubUrlsForReport({
|
||||
owner,
|
||||
repo,
|
||||
prNumbers,
|
||||
issueNumbers,
|
||||
}: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
prNumbers: number[];
|
||||
issueNumbers: number[];
|
||||
}): string[] {
|
||||
const base = `https://github.com/${owner}/${repo}`;
|
||||
const prUrls = prNumbers.map(n => `${base}/pull/${n}`);
|
||||
const issueUrls = issueNumbers.map(n => `${base}/issues/${n}`);
|
||||
return Array.from(new Set([...prUrls, ...issueUrls]));
|
||||
}
|
||||
160
release/src/linear.unit.spec.ts
Normal file
160
release/src/linear.unit.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
collectLinearProjects,
|
||||
getLinearIssuesForUrls,
|
||||
githubUrlsForReport,
|
||||
type LinearIssue,
|
||||
} from "./linear";
|
||||
|
||||
const project = (id: string, name: string) => ({
|
||||
id,
|
||||
name,
|
||||
url: `https://linear.app/metabase/project/${id}`,
|
||||
state: "started",
|
||||
});
|
||||
|
||||
const issue = (overrides: Partial<LinearIssue> & { identifier: string }): LinearIssue => ({
|
||||
title: `title ${overrides.identifier}`,
|
||||
url: `https://linear.app/metabase/issue/${overrides.identifier}`,
|
||||
team: { key: "GIT", name: "GitHub" },
|
||||
project: null,
|
||||
sourceUrl: "https://github.com/metabase/metabase/pull/1",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("collectLinearProjects", () => {
|
||||
it("groups issues by project and counts them", () => {
|
||||
const pdf = project("p1", "Offer PDF attachments");
|
||||
const summaries = collectLinearProjects([
|
||||
issue({ identifier: "EMB-1", project: pdf }),
|
||||
issue({ identifier: "EMB-2", project: pdf }),
|
||||
issue({ identifier: "QUE-1", project: project("p2", "Query improvements") }),
|
||||
]);
|
||||
|
||||
expect(summaries).toHaveLength(2);
|
||||
expect(summaries[0]).toMatchObject({ name: "Offer PDF attachments", issueCount: 2 });
|
||||
expect(summaries[0].issues.map(i => i.identifier)).toEqual(["EMB-1", "EMB-2"]);
|
||||
expect(summaries[1]).toMatchObject({ name: "Query improvements", issueCount: 1 });
|
||||
});
|
||||
|
||||
it("sorts named projects by count desc then name asc", () => {
|
||||
const summaries = collectLinearProjects([
|
||||
issue({ identifier: "A-1", project: project("a", "Alpha") }),
|
||||
issue({ identifier: "B-1", project: project("b", "Bravo") }),
|
||||
issue({ identifier: "B-2", project: project("b", "Bravo") }),
|
||||
issue({ identifier: "C-1", project: project("c", "Charlie") }),
|
||||
]);
|
||||
|
||||
// Bravo (2) first; Alpha & Charlie tie at 1 -> alphabetical
|
||||
expect(summaries.map(s => s.name)).toEqual(["Bravo", "Alpha", "Charlie"]);
|
||||
});
|
||||
|
||||
it("always sorts the no-project bucket last, even when largest", () => {
|
||||
const summaries = collectLinearProjects([
|
||||
issue({ identifier: "N-1", project: null }),
|
||||
issue({ identifier: "N-2", project: null }),
|
||||
issue({ identifier: "N-3", project: null }),
|
||||
issue({ identifier: "P-1", project: project("p", "Small project") }),
|
||||
]);
|
||||
|
||||
expect(summaries.map(s => s.name)).toEqual(["Small project", "No Linear project"]);
|
||||
expect(summaries[1].issueCount).toBe(3);
|
||||
expect(summaries[1].project).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("githubUrlsForReport", () => {
|
||||
it("builds pull + issue url variants and de-dupes", () => {
|
||||
const urls = githubUrlsForReport({
|
||||
owner: "metabase",
|
||||
repo: "metabase",
|
||||
prNumbers: [10, 10, 11],
|
||||
issueNumbers: [20],
|
||||
});
|
||||
|
||||
expect(urls).toEqual([
|
||||
"https://github.com/metabase/metabase/pull/10",
|
||||
"https://github.com/metabase/metabase/pull/11",
|
||||
"https://github.com/metabase/metabase/issues/20",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLinearIssuesForUrls", () => {
|
||||
const okResponse = (data: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data }),
|
||||
});
|
||||
|
||||
it("batches requests and de-dupes the same Linear issue across PR + issue URLs", async () => {
|
||||
const calls: string[] = [];
|
||||
const fetchFn = jest.fn(async (_url: string, init: any) => {
|
||||
const body = JSON.parse(init.body);
|
||||
calls.push(body.query);
|
||||
// batchSize 2 -> two batches of the 3 urls (pull/1, issues/2), (pull/3)
|
||||
// pull/1 and issues/2 both resolve to the SAME Linear issue EMB-7.
|
||||
return okResponse({
|
||||
u0: {
|
||||
nodes: [
|
||||
{
|
||||
issue: {
|
||||
identifier: "EMB-7",
|
||||
title: "PDF work",
|
||||
url: "https://linear.app/x/EMB-7",
|
||||
team: { key: "EMB", name: "Embedding" },
|
||||
project: { id: "p1", name: "PDF", url: "https://linear.app/p1", state: "started" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
u1: {
|
||||
nodes: [{ issue: { identifier: "EMB-7", title: "PDF work", url: "", team: null, project: null } }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const issues = await getLinearIssuesForUrls({
|
||||
urls: [
|
||||
"https://github.com/metabase/metabase/pull/1",
|
||||
"https://github.com/metabase/metabase/issues/2",
|
||||
"https://github.com/metabase/metabase/pull/3",
|
||||
],
|
||||
apiKey: "lin_test",
|
||||
batchSize: 2,
|
||||
fetchFn: fetchFn as any,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
// Two batches => two fetch calls.
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
// De-duped to a single EMB-7, keeping the first (richer) resolution.
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]).toMatchObject({ identifier: "EMB-7", project: { name: "PDF" } });
|
||||
// Raw key sent to Linear was the actual GitHub URL.
|
||||
expect(calls[0]).toContain("metabase/metabase/pull/1");
|
||||
});
|
||||
|
||||
it("retries on transient failure then succeeds", async () => {
|
||||
let attempt = 0;
|
||||
const fetchFn = jest.fn(async () => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
throw new Error("network blip");
|
||||
}
|
||||
return okResponse({ u0: { nodes: [] } });
|
||||
});
|
||||
|
||||
const sleep = jest.fn(async () => {});
|
||||
|
||||
const issues = await getLinearIssuesForUrls({
|
||||
urls: ["https://github.com/metabase/metabase/pull/1"],
|
||||
apiKey: "lin_test",
|
||||
fetchFn: fetchFn as any,
|
||||
sleep,
|
||||
});
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
expect(sleep).toHaveBeenCalledTimes(1);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
});
|
||||
113
release/src/markdown.ts
Normal file
113
release/src/markdown.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// A small, dependency-free Markdown -> HTML renderer.
|
||||
//
|
||||
// We deliberately avoid pulling in a markdown library: the input is either our
|
||||
// own machine-generated release notes (headings / bold / links / bullet lists)
|
||||
// or an LLM-authored theme summary, both of which stick to a common subset.
|
||||
// Everything is HTML-escaped *first*, so the output is safe to inline into the
|
||||
// self-contained report page even though the summary text is model-generated.
|
||||
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Inline formatting is applied to already-escaped text. Order matters: code
|
||||
// spans first (their contents are taken verbatim), then links, then emphasis.
|
||||
function renderInline(escaped: string): string {
|
||||
let out = escaped;
|
||||
|
||||
// `code`
|
||||
out = out.replace(/`([^`]+)`/g, (_m, code) => `<code>${code}</code>`);
|
||||
|
||||
// [text](url) — url was escaped, so quotes are " and can't break out of
|
||||
// the attribute. We only allow http(s) and mailto to avoid javascript: URIs.
|
||||
out = out.replace(
|
||||
/\[([^\]]+)\]\(((?:https?:\/\/|mailto:)[^)\s]+)\)/g,
|
||||
(_m, text, href) => `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`,
|
||||
);
|
||||
|
||||
// **bold**
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, (_m, b) => `<strong>${b}</strong>`);
|
||||
|
||||
// _italic_ or *italic* (single, not part of **)
|
||||
out = out.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, (_m, pre, i) => `${pre}<em>${i}</em>`);
|
||||
out = out.replace(/(^|[^\w])_([^_\n]+)_(?![\w])/g, (_m, pre, i) => `${pre}<em>${i}</em>`);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render a Markdown string to an HTML fragment (no wrapping element). */
|
||||
export function renderMarkdown(markdown: string): string {
|
||||
const lines = escapeHtml(markdown ?? "").split("\n");
|
||||
const html: string[] = [];
|
||||
|
||||
let inList = false;
|
||||
let paragraph: string[] = [];
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraph.length) {
|
||||
html.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
|
||||
paragraph = [];
|
||||
}
|
||||
};
|
||||
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html.push("</ul>");
|
||||
inList = false;
|
||||
}
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\s+$/, "");
|
||||
|
||||
if (line.trim() === "") {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^(---|\*\*\*|___)$/.test(line.trim())) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
html.push("<hr />");
|
||||
continue;
|
||||
}
|
||||
|
||||
// ATX headings (#, ##, ...)
|
||||
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
const level = heading[1].length;
|
||||
html.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list items (-, *, +)
|
||||
const listItem = line.match(/^\s*[-*+]\s+(.*)$/);
|
||||
if (listItem) {
|
||||
flushParagraph();
|
||||
if (!inList) {
|
||||
html.push("<ul>");
|
||||
inList = true;
|
||||
}
|
||||
html.push(`<li>${renderInline(listItem[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise accumulate into the current paragraph.
|
||||
closeList();
|
||||
paragraph.push(line.trim());
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
closeList();
|
||||
|
||||
return html.join("\n");
|
||||
}
|
||||
47
release/src/markdown.unit.spec.ts
Normal file
47
release/src/markdown.unit.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { renderMarkdown } from "./markdown";
|
||||
|
||||
describe("renderMarkdown", () => {
|
||||
it("renders headings at the right level", () => {
|
||||
expect(renderMarkdown("## Bug fixes")).toBe("<h2>Bug fixes</h2>");
|
||||
expect(renderMarkdown("#### Deep")).toBe("<h4>Deep</h4>");
|
||||
});
|
||||
|
||||
it("renders unordered lists", () => {
|
||||
const html = renderMarkdown("- one\n- two");
|
||||
expect(html).toBe("<ul>\n<li>one</li>\n<li>two</li>\n</ul>");
|
||||
});
|
||||
|
||||
it("renders bold, italic and inline code", () => {
|
||||
expect(renderMarkdown("**Querying**")).toBe("<p><strong>Querying</strong></p>");
|
||||
expect(renderMarkdown("a `code` span")).toBe("<p>a <code>code</code> span</p>");
|
||||
expect(renderMarkdown("some _emphasis_ here")).toBe("<p>some <em>emphasis</em> here</p>");
|
||||
});
|
||||
|
||||
it("renders safe links and opens them in a new tab", () => {
|
||||
const html = renderMarkdown("see [#123](https://github.com/metabase/metabase/issues/123)");
|
||||
expect(html).toContain(
|
||||
'<a href="https://github.com/metabase/metabase/issues/123" target="_blank" rel="noopener noreferrer">#123</a>',
|
||||
);
|
||||
});
|
||||
|
||||
it("groups consecutive text lines into a paragraph and splits on blank lines", () => {
|
||||
const html = renderMarkdown("line one\nline two\n\nsecond para");
|
||||
expect(html).toBe("<p>line one line two</p>\n<p>second para</p>");
|
||||
});
|
||||
|
||||
it("escapes HTML to prevent injection from model-generated summaries", () => {
|
||||
const html = renderMarkdown('Watch out <script>alert("x")</script>');
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
});
|
||||
|
||||
it("does not turn a javascript: link into an anchor", () => {
|
||||
const html = renderMarkdown("[click](javascript:alert(1))");
|
||||
expect(html).not.toContain("<a ");
|
||||
expect(html).toContain("[click]");
|
||||
});
|
||||
|
||||
it("renders a horizontal rule", () => {
|
||||
expect(renderMarkdown("---")).toBe("<hr />");
|
||||
});
|
||||
});
|
||||
@@ -106,7 +106,7 @@ async function getIssuesWithOlderMilestones({
|
||||
}).map((issue) => issue.number));
|
||||
}
|
||||
|
||||
async function getOriginalIssues({
|
||||
export async function getOriginalIssues({
|
||||
github,
|
||||
repo,
|
||||
owner,
|
||||
|
||||
9
release/src/release-branch-commits-run.ts
Normal file
9
release/src/release-branch-commits-run.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// CLI runner for release-branch-commits. Called from the set-milestone
|
||||
// workflow as `tsx release/src/release-branch-commits-run.ts <major>`.
|
||||
import { getReleaseBranchCommits } from './release-branch-commits';
|
||||
|
||||
const versionNumber = Number(process.argv[2]);
|
||||
|
||||
const commits = await getReleaseBranchCommits({ versionNumber });
|
||||
|
||||
console.log(JSON.stringify(commits, null, 2));
|
||||
@@ -1,5 +1,9 @@
|
||||
import {$} from 'zx';
|
||||
import { $ } from "zx";
|
||||
|
||||
// Trace every commit that landed on a newly-cut release branch since the
|
||||
// previous major was cut from master. We find where the *previous* major's
|
||||
// branch split off master (its merge-base) and list everything on this major's
|
||||
// branch after that point — i.e. the commits unique to this release.
|
||||
export async function getReleaseBranchCommits(
|
||||
{ versionNumber }: { versionNumber: number }
|
||||
) {
|
||||
@@ -11,9 +15,3 @@ export async function getReleaseBranchCommits(
|
||||
|
||||
return commitMessages.split('\n')
|
||||
}
|
||||
|
||||
const versionNumber = Number(process.argv[2]);
|
||||
|
||||
const commits = await getReleaseBranchCommits({ versionNumber });
|
||||
|
||||
console.log(JSON.stringify(commits, null, 2));
|
||||
|
||||
61
release/src/release-notes-markdown.ts
Normal file
61
release/src/release-notes-markdown.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Render a set of GitHub issues into the categorized release-notes Markdown used
|
||||
// in the report page. This reuses the release-notes categorization (bug fix /
|
||||
// enhancement / under-the-hood, grouped by product category) but emits a lean,
|
||||
// preview-oriented Markdown body without the docker-image / download / upgrade
|
||||
// boilerplate that the published changelog template carries.
|
||||
|
||||
import { categorizeIssues } from "./release-notes";
|
||||
import type { Issue } from "./types";
|
||||
|
||||
const OTHER_CATEGORY = "Other";
|
||||
|
||||
// categorizeIssues keys its result by a private string enum; index it via the
|
||||
// enum's underlying string values (kept in sync with release-notes.ts).
|
||||
type GroupedIssues = Record<string, Record<string, Issue[]>>;
|
||||
|
||||
const SECTION_ORDER: { key: string; label: string }[] = [
|
||||
{ key: "enhancements", label: "Enhancements" },
|
||||
{ key: "bugFixes", label: "Bug fixes" },
|
||||
{ key: "alreadyFixedIssues", label: "Already fixed" },
|
||||
{ key: "underTheHoodIssues", label: "Under the hood" },
|
||||
];
|
||||
|
||||
function sortCategories(categories: string[]): string[] {
|
||||
const named = categories.filter(c => c !== OTHER_CATEGORY).sort((a, b) => a.localeCompare(b));
|
||||
const other = categories.filter(c => c === OTHER_CATEGORY);
|
||||
return [...named, ...other];
|
||||
}
|
||||
|
||||
function issueLine(issue: Issue): string {
|
||||
const url =
|
||||
issue.html_url ?? `https://github.com/metabase/metabase/issues/${issue.number}`;
|
||||
return `- ${issue.title.trim()} ([#${issue.number}](${url}))`;
|
||||
}
|
||||
|
||||
export function renderReleaseNotesMarkdown(issues: Issue[]): string {
|
||||
const grouped = categorizeIssues(issues) as unknown as GroupedIssues;
|
||||
const out: string[] = [];
|
||||
|
||||
for (const { key, label } of SECTION_ORDER) {
|
||||
const categoryMap = grouped[key] ?? {};
|
||||
const categories = sortCategories(Object.keys(categoryMap));
|
||||
|
||||
const total = categories.reduce((n, c) => n + (categoryMap[c]?.length ?? 0), 0);
|
||||
if (total === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(`## ${label}`);
|
||||
|
||||
for (const category of categories) {
|
||||
const categoryIssues = categoryMap[category] ?? [];
|
||||
if (categoryIssues.length === 0) {
|
||||
continue;
|
||||
}
|
||||
out.push(`**${category}**`);
|
||||
out.push(categoryIssues.map(issueLine).join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
return out.join("\n\n");
|
||||
}
|
||||
45
release/src/release-notes-markdown.unit.spec.ts
Normal file
45
release/src/release-notes-markdown.unit.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { renderReleaseNotesMarkdown } from "./release-notes-markdown";
|
||||
import type { Issue } from "./types";
|
||||
|
||||
const makeIssue = (n: number, title: string, labels: string[]): Issue =>
|
||||
({
|
||||
number: n,
|
||||
title,
|
||||
html_url: `https://github.com/metabase/metabase/issues/${n}`,
|
||||
labels: labels.map(name => ({ name })),
|
||||
}) as Issue;
|
||||
|
||||
describe("renderReleaseNotesMarkdown", () => {
|
||||
it("groups issues by section and product category with issue links", () => {
|
||||
const md = renderReleaseNotesMarkdown([
|
||||
makeIssue(1, "Add a chart type", ["Visualization"]),
|
||||
makeIssue(2, "Fix a query crash", ["Type:Bug", "Querying"]),
|
||||
makeIssue(3, "Tidy up CI", [".CI & Tests"]),
|
||||
]);
|
||||
|
||||
expect(md).toContain("## Enhancements");
|
||||
expect(md).toContain("**Visualization**");
|
||||
expect(md).toContain("- Add a chart type ([#1](https://github.com/metabase/metabase/issues/1))");
|
||||
|
||||
expect(md).toContain("## Bug fixes");
|
||||
expect(md).toContain("**Querying**");
|
||||
expect(md).toContain("- Fix a query crash ([#2](https://github.com/metabase/metabase/issues/2))");
|
||||
|
||||
expect(md).toContain("## Under the hood");
|
||||
|
||||
// Enhancements section comes before Bug fixes.
|
||||
expect(md.indexOf("## Enhancements")).toBeLessThan(md.indexOf("## Bug fixes"));
|
||||
});
|
||||
|
||||
it("omits hidden issues and empty sections", () => {
|
||||
const md = renderReleaseNotesMarkdown([
|
||||
makeIssue(10, "Secret internal note", ["no-release-notes"]),
|
||||
makeIssue(11, "A real enhancement", ["Database"]),
|
||||
]);
|
||||
|
||||
expect(md).not.toContain("Secret internal note");
|
||||
expect(md).toContain("## Enhancements");
|
||||
expect(md).not.toContain("## Bug fixes");
|
||||
expect(md).not.toContain("## Under the hood");
|
||||
});
|
||||
});
|
||||
188
release/src/release-report-html.ts
Normal file
188
release/src/release-report-html.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// Render a self-contained HTML release report. No external assets, fonts, or
|
||||
// scripts — everything is inlined so the file can be opened straight from disk
|
||||
// or shipped as a CI artifact.
|
||||
|
||||
import { escapeHtml, renderMarkdown } from "./markdown";
|
||||
import type { ProjectSummary } from "./linear";
|
||||
import type { ReleaseReportData } from "./release-report";
|
||||
|
||||
function stat(value: string | number, label: string): string {
|
||||
return `
|
||||
<div class="stat">
|
||||
<div class="stat-value">${escapeHtml(String(value))}</div>
|
||||
<div class="stat-label">${escapeHtml(label)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function projectRow(summary: ProjectSummary, maxCount: number): string {
|
||||
const width = Math.max(3, Math.round((summary.issueCount / maxCount) * 100));
|
||||
const isNamed = summary.project !== null;
|
||||
|
||||
const nameCell = isNamed
|
||||
? `<a href="${escapeHtml(summary.project!.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(
|
||||
summary.name,
|
||||
)}</a>`
|
||||
: `<span class="muted">${escapeHtml(summary.name)}</span>`;
|
||||
|
||||
const state = summary.project?.state
|
||||
? `<span class="pill">${escapeHtml(summary.project.state)}</span>`
|
||||
: "";
|
||||
|
||||
const issueChips = summary.issues
|
||||
.map(
|
||||
issue =>
|
||||
`<a class="issue-chip" href="${escapeHtml(issue.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(
|
||||
issue.identifier,
|
||||
)} — ${escapeHtml(issue.title)}</a>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="project ${isNamed ? "" : "project--none"}">
|
||||
<div class="project-head">
|
||||
<div class="project-name">${nameCell} ${state}</div>
|
||||
<div class="project-count">${summary.issueCount}</div>
|
||||
</div>
|
||||
<div class="bar"><div class="bar-fill" style="width:${width}%"></div></div>
|
||||
<details>
|
||||
<summary>${summary.issueCount} Linear issue${summary.issueCount === 1 ? "" : "s"}</summary>
|
||||
<div class="issue-list">${issueChips}</div>
|
||||
</details>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderReleaseReportHtml({
|
||||
data,
|
||||
summaryMarkdown,
|
||||
generatedAt,
|
||||
}: {
|
||||
data: ReleaseReportData;
|
||||
summaryMarkdown: string;
|
||||
generatedAt: string;
|
||||
}): string {
|
||||
const namedProjects = data.projects.filter(p => p.project !== null);
|
||||
const maxCount = data.projects.reduce((m, p) => Math.max(m, p.issueCount), 1);
|
||||
|
||||
const title = `Metabase ${data.majorVersion} — Release Report`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--card: #ffffff;
|
||||
--ink: #2e353b;
|
||||
--muted: #74838f;
|
||||
--line: #edf0f2;
|
||||
--brand: #509ee3;
|
||||
--brand-ink: #227fd2;
|
||||
--accent: #a989c5;
|
||||
--good: #84bb4c;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.06), 0 8px 24px rgba(15,42,68,.06);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap { max-width: 960px; margin: 0 auto; padding: 40px 24px 96px; }
|
||||
header.hero {
|
||||
background: linear-gradient(135deg, var(--brand) 0%, var(--brand-ink) 100%);
|
||||
color: #fff; border-radius: 16px; padding: 32px 32px 28px; box-shadow: var(--shadow);
|
||||
}
|
||||
header.hero .eyebrow { text-transform: uppercase; letter-spacing: .08em; font-size: 12px; font-weight: 700; opacity: .85; }
|
||||
header.hero h1 { margin: 6px 0 4px; font-size: 30px; letter-spacing: -.02em; }
|
||||
header.hero .sub { opacity: .9; font-size: 14px; }
|
||||
header.hero .sub code { background: rgba(255,255,255,.18); padding: 1px 6px; border-radius: 5px; }
|
||||
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 22px; }
|
||||
.stat { background: rgba(255,255,255,.14); border-radius: 10px; padding: 14px 16px; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; line-height: 1.1; }
|
||||
.stat-label { font-size: 12px; opacity: .9; margin-top: 2px; }
|
||||
section { margin-top: 34px; }
|
||||
.card { background: var(--card); border-radius: 14px; box-shadow: var(--shadow); padding: 24px 28px; }
|
||||
h2.section-title { font-size: 13px; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin: 0 0 14px; }
|
||||
.summary :is(h1,h2,h3) { font-size: 17px; margin: 20px 0 6px; letter-spacing: -.01em; }
|
||||
.summary :is(h1,h2,h3):first-child { margin-top: 0; }
|
||||
.summary p { margin: 6px 0 12px; }
|
||||
.summary em { color: var(--muted); }
|
||||
.project { padding: 14px 0; border-top: 1px solid var(--line); }
|
||||
.project:first-child { border-top: 0; }
|
||||
.project-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.project-name { font-weight: 600; }
|
||||
.project-name a { color: var(--brand-ink); text-decoration: none; }
|
||||
.project-name a:hover { text-decoration: underline; }
|
||||
.project--none .project-name { font-weight: 500; }
|
||||
.project-count { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--ink); }
|
||||
.pill { font-size: 11px; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px; margin-left: 6px; text-transform: capitalize; }
|
||||
.bar { height: 6px; background: var(--line); border-radius: 999px; margin: 8px 0 6px; overflow: hidden; }
|
||||
.bar-fill { height: 100%; background: linear-gradient(90deg, var(--brand), var(--accent)); border-radius: 999px; }
|
||||
.project--none .bar-fill { background: #c7d0d8; }
|
||||
details { margin-top: 4px; }
|
||||
summary { cursor: pointer; color: var(--muted); font-size: 13px; }
|
||||
.issue-list { display: flex; flex-direction: column; gap: 4px; margin: 10px 0 4px; padding-left: 2px; }
|
||||
.issue-chip { font-size: 13px; color: var(--ink); text-decoration: none; padding: 3px 0; border-bottom: 1px dotted var(--line); }
|
||||
.issue-chip:hover { color: var(--brand-ink); }
|
||||
.muted { color: var(--muted); }
|
||||
.notes :is(h2) { font-size: 20px; margin: 26px 0 4px; padding-top: 18px; border-top: 2px solid var(--line); letter-spacing: -.01em; }
|
||||
.notes :is(h2):first-child { border-top: 0; padding-top: 0; margin-top: 0; }
|
||||
.notes p { margin: 14px 0 4px; font-weight: 700; color: var(--brand-ink); font-size: 13px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.notes ul { margin: 4px 0 10px; padding-left: 20px; }
|
||||
.notes li { margin: 3px 0; }
|
||||
.notes a { color: var(--muted); text-decoration: none; font-variant-numeric: tabular-nums; }
|
||||
.notes a:hover { color: var(--brand-ink); }
|
||||
footer { color: var(--muted); font-size: 12px; text-align: center; margin-top: 40px; }
|
||||
@media (max-width: 640px) {
|
||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||
header.hero, .card { padding: 20px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="hero">
|
||||
<div class="eyebrow">Release Report</div>
|
||||
<h1>Metabase ${escapeHtml(String(data.majorVersion))}</h1>
|
||||
<div class="sub">Everything on <code>${escapeHtml(data.branch)}</code> since <code>${escapeHtml(
|
||||
String(data.previousMajorVersion),
|
||||
)}</code> was cut from master</div>
|
||||
<div class="stats">
|
||||
${stat(data.issues.length, "Issues shipped")}
|
||||
${stat(data.prCount, "Pull requests")}
|
||||
${stat(data.linearIssueCount, "Linear issues")}
|
||||
${stat(namedProjects.length, "Linear projects")}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<div class="card">
|
||||
<h2 class="section-title">Major themes</h2>
|
||||
<div class="summary">${renderMarkdown(summaryMarkdown)}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="card">
|
||||
<h2 class="section-title">Projects in this release · ${namedProjects.length} projects</h2>
|
||||
${data.projects.map(p => projectRow(p, maxCount)).join("\n")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="card">
|
||||
<h2 class="section-title">Release notes preview</h2>
|
||||
<div class="notes">${renderMarkdown(data.releaseNotesMarkdown)}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>Generated ${escapeHtml(generatedAt)} · Metabase release automation</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
158
release/src/release-report.ts
Normal file
158
release/src/release-report.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
// Release report orchestrator.
|
||||
//
|
||||
// Fuses the existing release automation into a single dataset for a newly-cut
|
||||
// major release branch:
|
||||
// 1. Trace every commit on release-x.<major>.x back to where the previous
|
||||
// major forked from master (reuses getReleaseBranchCommits).
|
||||
// 2. Extract the squash-merged PR numbers from those commits.
|
||||
// 3. Resolve each PR back to its original GitHub issue(s), following backports
|
||||
// and linked issues (reuses getOriginalIssues from the milestone backfill).
|
||||
// 4. Resolve the PRs + issues to their linked Linear issues, and roll those up
|
||||
// by Linear project (reuses the attachmentsForURL bridge, via linear.ts).
|
||||
// 5. Render the categorized release-notes Markdown from the GitHub issues.
|
||||
//
|
||||
// The AI theme summary and HTML rendering are intentionally NOT done here — this
|
||||
// function returns pure data so it stays easy to reason about and drive.
|
||||
|
||||
import { getIssueWithCache } from "./github";
|
||||
import {
|
||||
collectLinearProjects,
|
||||
getLinearIssuesForUrls,
|
||||
githubUrlsForReport,
|
||||
type LinearIssue,
|
||||
type ProjectSummary,
|
||||
} from "./linear";
|
||||
import { getPRsFromCommitMessage } from "./linked-issues";
|
||||
import { getOriginalIssues } from "./milestones";
|
||||
import { getReleaseBranchCommits } from "./release-branch-commits";
|
||||
import { renderReleaseNotesMarkdown } from "./release-notes-markdown";
|
||||
import type { GithubProps, Issue } from "./types";
|
||||
|
||||
export type ReleaseReportData = {
|
||||
majorVersion: number;
|
||||
version: string;
|
||||
previousMajorVersion: number;
|
||||
branch: string;
|
||||
prCount: number;
|
||||
prNumbers: number[];
|
||||
issues: Issue[];
|
||||
linearIssues: LinearIssue[];
|
||||
linearIssueCount: number;
|
||||
projects: ProjectSummary[];
|
||||
releaseNotesMarkdown: string;
|
||||
};
|
||||
|
||||
function uniq<T>(array: T[]): T[] {
|
||||
return Array.from(new Set(array));
|
||||
}
|
||||
|
||||
const isNotNull = <T>(value: T | null | undefined): value is T => value != null;
|
||||
|
||||
// Run an async mapper over items with a bounded concurrency. Keeps us fast
|
||||
// without blowing the GitHub rate limit like a naive Promise.all(600) would.
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length);
|
||||
let cursor = 0;
|
||||
|
||||
async function worker() {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor++;
|
||||
results[index] = await mapper(items[index], index);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getReleaseReportData({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
majorVersion,
|
||||
linearApiKey,
|
||||
log = console.log,
|
||||
}: GithubProps & {
|
||||
majorVersion: number;
|
||||
linearApiKey: string;
|
||||
log?: (message: string) => void;
|
||||
}): Promise<ReleaseReportData> {
|
||||
const previousMajorVersion = majorVersion - 1;
|
||||
const branch = `release-x.${majorVersion}.x`;
|
||||
|
||||
log(`Tracing commits on ${branch} back to the ${previousMajorVersion} fork point…`);
|
||||
const commitSubjects = await getReleaseBranchCommits({ versionNumber: majorVersion });
|
||||
|
||||
const prNumbers = uniq(
|
||||
commitSubjects.flatMap(subject => getPRsFromCommitMessage(subject) ?? []),
|
||||
);
|
||||
log(`Found ${commitSubjects.length} commits → ${prNumbers.length} unique PRs.`);
|
||||
|
||||
// Resolve PRs → original GitHub issues. getOriginalIssues is chatty (it logs
|
||||
// per PR as it walks backports/linked issues); silence that here and print our
|
||||
// own coarse progress instead so the tool output stays readable at ~600 PRs.
|
||||
log(`Resolving PRs to their original issues…`);
|
||||
const originalLog = console.log;
|
||||
const issueNumberSet = new Set<number>();
|
||||
let processed = 0;
|
||||
console.log = () => {};
|
||||
try {
|
||||
await mapWithConcurrency(prNumbers, 8, async prNumber => {
|
||||
const originals = await getOriginalIssues({ github, owner, repo, issueNumber: prNumber });
|
||||
originals.forEach(n => issueNumberSet.add(n));
|
||||
processed += 1;
|
||||
if (processed % 100 === 0 || processed === prNumbers.length) {
|
||||
originalLog(` …resolved ${processed}/${prNumbers.length} PRs`);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
|
||||
const issueNumbers = Array.from(issueNumberSet);
|
||||
log(`Fetching ${issueNumbers.length} GitHub issues…`);
|
||||
const issues = (
|
||||
await mapWithConcurrency(issueNumbers, 8, issueNumber =>
|
||||
getIssueWithCache({ github, owner, repo, issueNumber }),
|
||||
)
|
||||
).filter(isNotNull);
|
||||
|
||||
log(`Resolving Linear issues + projects…`);
|
||||
const urls = githubUrlsForReport({ owner, repo, prNumbers, issueNumbers });
|
||||
const linearIssues = await getLinearIssuesForUrls({
|
||||
urls,
|
||||
apiKey: linearApiKey,
|
||||
onProgress: (done, total) => {
|
||||
if (done % 200 === 0 || done === total) {
|
||||
log(` …queried ${done}/${total} GitHub URLs against Linear`);
|
||||
}
|
||||
},
|
||||
});
|
||||
const projects = collectLinearProjects(linearIssues);
|
||||
log(
|
||||
`Linear: ${linearIssues.length} issues across ${
|
||||
projects.filter(p => p.project !== null).length
|
||||
} projects.`,
|
||||
);
|
||||
|
||||
const releaseNotesMarkdown = renderReleaseNotesMarkdown(issues);
|
||||
|
||||
return {
|
||||
majorVersion,
|
||||
version: `v0.${majorVersion}.0`,
|
||||
previousMajorVersion,
|
||||
branch,
|
||||
prCount: prNumbers.length,
|
||||
prNumbers,
|
||||
issues,
|
||||
linearIssues,
|
||||
linearIssueCount: linearIssues.length,
|
||||
projects,
|
||||
releaseNotesMarkdown,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user