Compare commits

...

1 Commits

Author SHA1 Message Date
Ryan Laurie
ddedf3446c parse release 2026-07-07 17:23:24 -06:00
19 changed files with 1528 additions and 9 deletions

View File

@@ -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
View 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
View File

@@ -6,3 +6,4 @@ dist/
version.properties
version-info.json
v*.html
release-report-*.html

View File

@@ -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.

View 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`,
);

View File

@@ -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
View 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);
}
}

View 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
View 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]));
}

View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// 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 &quot; 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");
}

View 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("&lt;script&gt;");
});
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 />");
});
});

View File

@@ -106,7 +106,7 @@ async function getIssuesWithOlderMilestones({
}).map((issue) => issue.number));
}
async function getOriginalIssues({
export async function getOriginalIssues({
github,
repo,
owner,

View 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));

View File

@@ -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));

View 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");
}

View 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");
});
});

View 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>`;
}

View 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,
};
}