Compare commits
373 Commits
test-docs-
...
python-run
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2076d909d9 | ||
|
|
b2c2816d86 | ||
|
|
d8104c911d | ||
|
|
5d5930ab64 | ||
|
|
2738ec81b7 | ||
|
|
cd77eb5140 | ||
|
|
2a4046736a | ||
|
|
660c1ddfe6 | ||
|
|
6845e78a5d | ||
|
|
eea9a750d6 | ||
|
|
123a4f966d | ||
|
|
659d0a37b1 | ||
|
|
59a52b2631 | ||
|
|
a83196324b | ||
|
|
9ab2bfbae8 | ||
|
|
c261122999 | ||
|
|
0b65ccce3a | ||
|
|
bb2adc5f46 | ||
|
|
c5dcdc3238 | ||
|
|
5537f0f258 | ||
|
|
bfdd8a0b23 | ||
|
|
3c05a30b8e | ||
|
|
106a707fcc | ||
|
|
21c8136e7e | ||
|
|
7319599ceb | ||
|
|
13bc693357 | ||
|
|
2b11a3bed2 | ||
|
|
c910ca3fd3 | ||
|
|
d8b40292d1 | ||
|
|
b773f68f23 | ||
|
|
647861b624 | ||
|
|
e741639713 | ||
|
|
5765b0df15 | ||
|
|
40d00b8e7f | ||
|
|
58cde65c4e | ||
|
|
5995621da8 | ||
|
|
70d4a78914 | ||
|
|
cf6e1c032a | ||
|
|
e70f22e0eb | ||
|
|
4fd189509f | ||
|
|
f7fd7fe5c5 | ||
|
|
a0e28bc775 | ||
|
|
a5b4f10c10 | ||
|
|
57502d5ef1 | ||
|
|
b2f981e967 | ||
|
|
fb8f72b7bd | ||
|
|
502b66528c | ||
|
|
2838b6501c | ||
|
|
7603550836 | ||
|
|
ba8074bb87 | ||
|
|
90097715d2 | ||
|
|
12839276af | ||
|
|
2ef536a0fe | ||
|
|
9ad40ab1c3 | ||
|
|
6351e5bc0e | ||
|
|
18d8dadf1b | ||
|
|
d5f606e8f2 | ||
|
|
acbf476d8b | ||
|
|
21af2827f2 | ||
|
|
04b141b975 | ||
|
|
ff9d9acdac | ||
|
|
d7848ee998 | ||
|
|
47c037a3f8 | ||
|
|
ab1ccbf8ec | ||
|
|
8662aaaa87 | ||
|
|
81b10ad959 | ||
|
|
0a67d24d79 | ||
|
|
258305b914 | ||
|
|
21b67b2892 | ||
|
|
3dcbfef5c5 | ||
|
|
1ccf800dee | ||
|
|
d4d83d2f8e | ||
|
|
3e655a0bb2 | ||
|
|
0409c4a20b | ||
|
|
b51c0c9efa | ||
|
|
51f8fb9af1 | ||
|
|
bfb0b42b42 | ||
|
|
09544d950d | ||
|
|
03dd34ec4a | ||
|
|
d08e561420 | ||
|
|
214c38b3f2 | ||
|
|
52ae8a14db | ||
|
|
e153a3290b | ||
|
|
2830b3c94a | ||
|
|
2fa8abc247 | ||
|
|
be2a29d138 | ||
|
|
ab089eb84d | ||
|
|
b40d76f5b4 | ||
|
|
b12dfa4fd9 | ||
|
|
5e2e510811 | ||
|
|
c6a2880b31 | ||
|
|
5cd8e165fa | ||
|
|
05c26a4363 | ||
|
|
8ceeb1fce4 | ||
|
|
49a3037d2a | ||
|
|
fa959aaab4 | ||
|
|
eadbd4b00d | ||
|
|
1da8e5751a | ||
|
|
d47651a2cf | ||
|
|
92001b00de | ||
|
|
186c9dca65 | ||
|
|
42aebbf137 | ||
|
|
df7d834e2e | ||
|
|
bc7ffe094f | ||
|
|
205205174c | ||
|
|
05567456f2 | ||
|
|
9bfb9ed7a4 | ||
|
|
b161e19442 | ||
|
|
c28beea344 | ||
|
|
b22ab2010d | ||
|
|
6f4d71c279 | ||
|
|
c8d5e94ffa | ||
|
|
22a1f275f0 | ||
|
|
ce8660f161 | ||
|
|
dcf260f4c9 | ||
|
|
4897452a08 | ||
|
|
c87ad18e21 | ||
|
|
78a8c59d71 | ||
|
|
336c2a8b1a | ||
|
|
567bbd68bb | ||
|
|
ba6895665f | ||
|
|
09c04d91bb | ||
|
|
8977542307 | ||
|
|
ec678a17fc | ||
|
|
6eda44fc53 | ||
|
|
4219500b7a | ||
|
|
2f2ab17ebb | ||
|
|
89c1712267 | ||
|
|
ba03f8d368 | ||
|
|
6d84d80c06 | ||
|
|
b130d0d812 | ||
|
|
caafceff99 | ||
|
|
5fe95dbbae | ||
|
|
65d9029a55 | ||
|
|
e4ec44c4af | ||
|
|
0e9fd4c48c | ||
|
|
cd32f3b89c | ||
|
|
6d79bbc173 | ||
|
|
7c8125d4bb | ||
|
|
54439261a4 | ||
|
|
18706c2456 | ||
|
|
3d5b673c9a | ||
|
|
0e1325ba1e | ||
|
|
9c801dfbd9 | ||
|
|
c27eee3f51 | ||
|
|
1cfc71fed7 | ||
|
|
619f2b2be6 | ||
|
|
64978428b8 | ||
|
|
58d2cd37b6 | ||
|
|
6901847ed2 | ||
|
|
10286754ee | ||
|
|
8f1606adc5 | ||
|
|
5b6e3071ac | ||
|
|
76e96af834 | ||
|
|
92017bab3f | ||
|
|
c86caa092e | ||
|
|
29590b8e09 | ||
|
|
e822288497 | ||
|
|
a6c90cc7ff | ||
|
|
1571e58188 | ||
|
|
ae1e0d6311 | ||
|
|
3f547462c9 | ||
|
|
05aaad4f3a | ||
|
|
92ec0df7b1 | ||
|
|
35b542b651 | ||
|
|
ab7e4712b1 | ||
|
|
47471f9518 | ||
|
|
86868639b4 | ||
|
|
c61c93e825 | ||
|
|
6dfde80586 | ||
|
|
009911d60a | ||
|
|
237bbe2919 | ||
|
|
c8b1767e66 | ||
|
|
18e327042f | ||
|
|
f02d26aa14 | ||
|
|
8056c1835a | ||
|
|
04055423f0 | ||
|
|
4e7d009a53 | ||
|
|
a41303a180 | ||
|
|
8474a8f7f5 | ||
|
|
2b61cce77b | ||
|
|
0a0625d163 | ||
|
|
bb42762372 | ||
|
|
420c4e27e0 | ||
|
|
7d8f3849bf | ||
|
|
def46a7c0c | ||
|
|
3a69ad8f49 | ||
|
|
3a62030c09 | ||
|
|
8a93fb4fcd | ||
|
|
483117981c | ||
|
|
88f48ce996 | ||
|
|
7eb9d27107 | ||
|
|
c5b8eef4d9 | ||
|
|
9c44b33887 | ||
|
|
3b030bcd58 | ||
|
|
4b9c2e7720 | ||
|
|
57586f2427 | ||
|
|
d813be6b09 | ||
|
|
ccd3e61ff0 | ||
|
|
5e647df5be | ||
|
|
04a5c90dab | ||
|
|
bbc00a8677 | ||
|
|
a43d62a505 | ||
|
|
693ac2a9ce | ||
|
|
d4a888c2ec | ||
|
|
73a53fd72d | ||
|
|
7a88dfba69 | ||
|
|
b6b44f3cd6 | ||
|
|
e586b78513 | ||
|
|
330fb2727e | ||
|
|
ea89a862ec | ||
|
|
0e6915287b | ||
|
|
d0ffa5115d | ||
|
|
86dcec8883 | ||
|
|
b2c736e4ca | ||
|
|
4a3e155ac9 | ||
|
|
0f687a4a6b | ||
|
|
5274ca8dcb | ||
|
|
e0123c6246 | ||
|
|
4301a00c1b | ||
|
|
7dc0929cc1 | ||
|
|
cf40c24b36 | ||
|
|
5c4c8753d7 | ||
|
|
d6540d2d62 | ||
|
|
d7f7d8285f | ||
|
|
cd68964a13 | ||
|
|
5e35a33837 | ||
|
|
1734750c21 | ||
|
|
bb7604b53e | ||
|
|
ea43ff2816 | ||
|
|
e8c624b479 | ||
|
|
97d10fee1c | ||
|
|
5c6250fe4d | ||
|
|
21a620c6e8 | ||
|
|
1e3b836a7b | ||
|
|
a6ae573321 | ||
|
|
14740891cd | ||
|
|
908ca1a1d6 | ||
|
|
94309cd070 | ||
|
|
d54e6b64e2 | ||
|
|
763d5e1777 | ||
|
|
b746306e12 | ||
|
|
f0c90980ff | ||
|
|
e53f226b40 | ||
|
|
40cbf2ffb5 | ||
|
|
55a0afbd2a | ||
|
|
f759d9f473 | ||
|
|
39951a570a | ||
|
|
6b369ef818 | ||
|
|
f3c352d920 | ||
|
|
9c0576cc5c | ||
|
|
fc00027fca | ||
|
|
3be69174e6 | ||
|
|
11a73d8621 | ||
|
|
d1e7b483dd | ||
|
|
aa66ab25d6 | ||
|
|
8207fc3181 | ||
|
|
b429839f0a | ||
|
|
451f080ede | ||
|
|
4f00289729 | ||
|
|
369d410d4d | ||
|
|
b8c13482df | ||
|
|
126eb6b1b1 | ||
|
|
4ab19dce4d | ||
|
|
39b4a21830 | ||
|
|
3ba0f90cf0 | ||
|
|
6e799b2ef6 | ||
|
|
dcd2d7394d | ||
|
|
3c693179e4 | ||
|
|
b01732fd69 | ||
|
|
a75ee49fd1 | ||
|
|
dbfe313b4a | ||
|
|
088bd180d5 | ||
|
|
0226eaa5d2 | ||
|
|
3d68aa1ac4 | ||
|
|
c18c08b02c | ||
|
|
851bedf5bb | ||
|
|
9981cd5323 | ||
|
|
264604b61e | ||
|
|
a5a0773eaf | ||
|
|
c463756c60 | ||
|
|
0f7e8983ca | ||
|
|
3401c9e902 | ||
|
|
0aeda87a3d | ||
|
|
b1ece80c0d | ||
|
|
486a6fc826 | ||
|
|
29f75c1251 | ||
|
|
66dc6b6cdc | ||
|
|
7433f38a22 | ||
|
|
71606de98c | ||
|
|
d2e8fcac5b | ||
|
|
e4d1706837 | ||
|
|
cc1361936a | ||
|
|
69c0a2ca90 | ||
|
|
3d3bbe22db | ||
|
|
ba0ddc9a32 | ||
|
|
da223ea0a1 | ||
|
|
f53c8d1513 | ||
|
|
51d7669d7d | ||
|
|
eb20991259 | ||
|
|
ec4874cfe5 | ||
|
|
8f442ca39b | ||
|
|
8c6e4aa60b | ||
|
|
3d3d49dfca | ||
|
|
bba60088cb | ||
|
|
981113ef47 | ||
|
|
9c8e505c9f | ||
|
|
f4bbf2984f | ||
|
|
273f1677a1 | ||
|
|
12e6b46118 | ||
|
|
f27597b904 | ||
|
|
b2889824b3 | ||
|
|
28cfae2e67 | ||
|
|
60707e20ec | ||
|
|
a4e418f280 | ||
|
|
c20bf2a943 | ||
|
|
c66d31fec1 | ||
|
|
93cb3bc327 | ||
|
|
52325a7503 | ||
|
|
dadf042982 | ||
|
|
18e1fb02aa | ||
|
|
5dd8b26770 | ||
|
|
b6c1a51630 | ||
|
|
09ee4178ca | ||
|
|
a68b644ec8 | ||
|
|
ca002fd953 | ||
|
|
ba5de0f2fa | ||
|
|
b4f717609e | ||
|
|
3f898be45a | ||
|
|
071fc345b4 | ||
|
|
08c4a89f91 | ||
|
|
7d2d6571cb | ||
|
|
f6f2a31b6a | ||
|
|
1342901722 | ||
|
|
a554da88bd | ||
|
|
7fa10713ea | ||
|
|
43e3fa694d | ||
|
|
370a4dff14 | ||
|
|
dc319ca76a | ||
|
|
b08d62e316 | ||
|
|
ba302fc0ef | ||
|
|
716a26ef75 | ||
|
|
f1de74d0d2 | ||
|
|
54bd11c5b9 | ||
|
|
2e3efef978 | ||
|
|
9d9433b5b0 | ||
|
|
f56c4e39cb | ||
|
|
f3fa8226cf | ||
|
|
4376ecc379 | ||
|
|
2c04482d27 | ||
|
|
be492dc637 | ||
|
|
b2fb97a98a | ||
|
|
347f3f11b4 | ||
|
|
d59d58e61a | ||
|
|
c8d8369fec | ||
|
|
ecaff38ac0 | ||
|
|
951f74665d | ||
|
|
acf4530cc6 | ||
|
|
8eccd8a0f9 | ||
|
|
83fdafad01 | ||
|
|
71cfad67bb | ||
|
|
103e9056e1 | ||
|
|
984cf44eca | ||
|
|
d2f7234d7f | ||
|
|
4ab6f9df02 | ||
|
|
4c0c9aa08f | ||
|
|
32a00c99e5 | ||
|
|
559c2f27de | ||
|
|
fd775c440d | ||
|
|
4a79872533 | ||
|
|
127132f8a6 | ||
|
|
140d1adcb7 | ||
|
|
639c8debc9 |
@@ -12,14 +12,7 @@
|
||||
|
||||
## Testing
|
||||
|
||||
- **Run a test:** `./bin/mage run-tests namespace/test-name`
|
||||
- **Run all tests in a namespace:** `./bin/mage run-tests namespace`
|
||||
- **Run all tests for a module:** `./bin/mage run-tests test/metabase/notification` Because the module lives in that directory.
|
||||
|
||||
Note: the `./bin/mage run-tests` command accepts multiple args, so you can pass
|
||||
`./bin/mage run-tests namespace/test-name namespace/other-test namespace/third-test`
|
||||
to run 3 tests, or
|
||||
`./bin/mage run-tests test/metabase/module1 test/metabase/module2` to run 2 modules.
|
||||
Use `./bin/test-agent` to run backend tests. See the top-level CLAUDE.md for usage.
|
||||
|
||||
## Code Readability
|
||||
|
||||
|
||||
357
.claude/skills/add-tracing/SKILL.md
Normal file
@@ -0,0 +1,357 @@
|
||||
--b-
|
||||
name: add-tracing
|
||||
description: Add OpenTelemetry tracing spans to Clojure code following Metabase tracing conventions. Use when instrumenting backend code with trace coverage.
|
||||
---
|
||||
|
||||
# Add Tracing Spans to Clojure Code
|
||||
|
||||
This skill helps you add OpenTelemetry (OTel) tracing spans to the Metabase backend codebase using the custom `tracing/with-span` macro.
|
||||
|
||||
## Reference Files
|
||||
|
||||
- `src/metabase/tracing/core.clj` - `with-span` macro, group registry, SDK lifecycle, `best-effort-sanitize-sql`, Pyroscope integration
|
||||
- `src/metabase/task/impl.clj` - `defjob` macro that wraps Quartz jobs with root spans
|
||||
- `.clj-kondo/config/modules/config.edn` - Module boundary configuration
|
||||
|
||||
## Module Architecture
|
||||
|
||||
The tracing module has a deliberately minimal API surface. **Only 2 namespaces are public** (listed in `:api` in the module config):
|
||||
|
||||
| Namespace | Role | Status |
|
||||
|---|---|---|
|
||||
| `tracing.core` | Primary API: `with-span`, groups, SDK lifecycle, Pyroscope, MDC, `best-effort-sanitize-sql` | **Public API** |
|
||||
| `tracing.init` | Side-effect loader for `quartz` and `settings` | **Public API** (init convention) |
|
||||
| `tracing.attributes` | `best-effort-sanitize-sql` implementation (re-exported via `tracing.core`) | Internal |
|
||||
| `tracing.settings` | Setting definitions (`MB_TRACING_*` env vars) | Internal |
|
||||
| `tracing.quartz` | Quartz JDBC proxy + JobListener | Internal |
|
||||
|
||||
**Rules:**
|
||||
- Only require `[metabase.tracing.core :as tracing]` from outside the module. `tracing/best-effort-sanitize-sql` and all other public functions are available from this single namespace.
|
||||
- Do not add new API namespaces. Add new public functions to `tracing.core` instead.
|
||||
- Do not require internal namespaces (`tracing.attributes`, `tracing.settings`, `tracing.quartz`) from outside the module.
|
||||
- `:uses :any` on the `core` module does NOT bypass the target module's `:api` check — internal namespaces are still enforced.
|
||||
|
||||
### Cyclic Dependency Avoidance
|
||||
|
||||
`tracing/core.clj` is required by many modules across the codebase. It **must NOT** compile-time require `tracing.settings`, as this creates transitive cyclic load dependencies (e.g., `settings/core -> tracing/settings -> tracing/core -> events/impl -> events/core`).
|
||||
|
||||
Instead, `tracing/core.clj` uses `requiring-resolve` for settings access:
|
||||
|
||||
```clojure
|
||||
;; CORRECT — lazy runtime resolution, no compile-time dependency
|
||||
((requiring-resolve 'metabase.tracing.settings/tracing-enabled))
|
||||
|
||||
;; WRONG — creates cyclic load dependency
|
||||
(require '[metabase.tracing.settings :as settings])
|
||||
(settings/tracing-enabled)
|
||||
```
|
||||
|
||||
External library namespaces (clj-otel API, SDK, exporters) are safe to require normally — they don't participate in Metabase namespace cycles.
|
||||
|
||||
**Important:** `requiring-resolve` must use **literal quoted symbols**. Kondo hooks validate that `required-namespaces` are all simple symbols, so dynamic construction fails:
|
||||
|
||||
```clojure
|
||||
;; CORRECT — literal quoted symbol
|
||||
(requiring-resolve 'metabase.tracing.settings/tracing-endpoint)
|
||||
|
||||
;; WRONG — kondo hook rejects this: "Assert failed: (every? simple-symbol? required-namespaces)"
|
||||
(requiring-resolve (symbol "metabase.tracing.settings" "tracing-endpoint"))
|
||||
```
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
When adding tracing spans:
|
||||
|
||||
- [ ] Module has `tracing` in its `:uses` set in `.clj-kondo/config/modules/config.edn`
|
||||
- [ ] Added `[metabase.tracing.core :as tracing]` to ns requires (alphabetically sorted)
|
||||
- [ ] Span wraps a meaningful I/O boundary (not pure computation)
|
||||
- [ ] Group matches the domain (check `src/metabase/tracing/core.clj` for registered groups; add a new one if none fit)
|
||||
- [ ] Span name follows dot-notation convention (`"domain.subsystem.operation"`)
|
||||
- [ ] Attributes use namespaced keywords (`:search/query-length`, `:db/id`)
|
||||
- [ ] No sensitive data in attributes (use `best-effort-sanitize-sql` for HoneySQL, never raw SQL)
|
||||
- [ ] No new tracing namespaces created (add to `tracing.core` instead)
|
||||
- [ ] No `DO_NOT_ADD_NEW_FILES_HERE.txt` violations in the target directory
|
||||
- [ ] Run `clj-kondo --lint <files>` to verify 0 errors, 0 warnings
|
||||
- [ ] Add or update tests in the corresponding `test/` path (see Testing section below)
|
||||
- [ ] Run tests: `clojure -X:dev:test :only <test-ns>`
|
||||
|
||||
## The `with-span` Macro
|
||||
|
||||
```clojure
|
||||
(tracing/with-span group span-name attrs & body)
|
||||
```
|
||||
|
||||
- **group** - A keyword selecting which trace group this span belongs to (e.g., `:tasks`, `:sync`)
|
||||
- **span-name** - A string identifying the span in traces (e.g., `"search.execute"`)
|
||||
- **attrs** - A map of span attributes (e.g., `{:db/id 42}`)
|
||||
- **body** - The code to execute inside the span
|
||||
|
||||
**When disabled:** zero overhead -- single atom deref + boolean check, body runs directly.
|
||||
**When enabled:** creates OTel span AND injects `trace_id`/`span_id` into Log4j2 MDC for log-to-trace correlation.
|
||||
|
||||
## Trace Groups
|
||||
|
||||
Groups are registered in `src/metabase/tracing/core.clj`. Check that file for the current list. The general rule: **match the group to the domain, not the call site.** If code runs inside a Quartz job but is logically search work, use `:search`, not `:tasks`.
|
||||
|
||||
To add a new group:
|
||||
|
||||
```clojure
|
||||
;; In src/metabase/tracing/core.clj
|
||||
(register-group! :my-domain "Description of what this covers")
|
||||
```
|
||||
|
||||
Users enable groups via `MB_TRACING_GROUPS=tasks,search,sync` (comma-separated, or `"all"`).
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Span Names
|
||||
|
||||
Use dot-separated hierarchical names: `"domain.subsystem.operation"`. The domain prefix should match the group name:
|
||||
|
||||
```
|
||||
search.execute -- `:search` group
|
||||
sync.fingerprint.table -- `:sync` group
|
||||
task.session-cleanup.delete -- `:tasks` group
|
||||
db-app.collection-items -- `:db-app` group
|
||||
```
|
||||
|
||||
### Attributes
|
||||
|
||||
Use namespaced keywords. The namespace groups related attributes:
|
||||
|
||||
```clojure
|
||||
:db/id -- Database ID (integer)
|
||||
:db/engine -- Database engine name (string)
|
||||
:db/statement -- Sanitized SQL (string, via best-effort-sanitize-sql)
|
||||
:search/engine -- Search engine name (string)
|
||||
:search/query-length -- Query string length (integer)
|
||||
:sync/table -- Table name (string)
|
||||
:sync/step -- Sync step name (string)
|
||||
:task/name -- Task name (string)
|
||||
:http/method -- HTTP method (string)
|
||||
:http/url -- Request URL (string)
|
||||
```
|
||||
|
||||
Invent new namespaced attributes as needed (e.g., `:pulse/id`, `:transform/count`). Keep values as primitives (strings, numbers, booleans) -- no maps or collections.
|
||||
|
||||
## Step-by-Step: Adding a Span
|
||||
|
||||
### 1. Check module boundaries
|
||||
|
||||
Look up the module for your namespace in `.clj-kondo/config/modules/config.edn`. If `tracing` is not in the module's `:uses` set, add it (keep alphabetically sorted):
|
||||
|
||||
```edn
|
||||
my-module
|
||||
{:team "MyTeam"
|
||||
:uses #{analytics config tracing util}}
|
||||
```
|
||||
|
||||
### 2. Add the require
|
||||
|
||||
```clojure
|
||||
(ns metabase.my-module.thing
|
||||
(:require
|
||||
[metabase.tracing.core :as tracing]
|
||||
[metabase.util :as u]))
|
||||
```
|
||||
|
||||
`best-effort-sanitize-sql` is available from `tracing.core` — no additional require needed.
|
||||
|
||||
### 3. Identify the I/O boundary
|
||||
|
||||
Only wrap code at **meaningful I/O boundaries**:
|
||||
|
||||
**DO trace:**
|
||||
- External API calls (embedding APIs, metabot, webhooks)
|
||||
- Database queries (both app DB and user DB)
|
||||
- Network requests (HTTP calls to external services)
|
||||
- Heavy batch processing (batch indexing, batch embedding)
|
||||
- Top-level orchestration functions that coordinate multiple sub-operations
|
||||
|
||||
**DO NOT trace:**
|
||||
- Pure computation (sorting, filtering, mapping)
|
||||
- Simple single-row lookups (`t2/select-one :model/Setting :key k`)
|
||||
- Every function in a call chain (only boundaries matter)
|
||||
- Trivial operations (string formatting, hash calculations)
|
||||
|
||||
### 4. Wrap with `with-span`
|
||||
|
||||
```clojure
|
||||
;; Simple span (no attributes needed)
|
||||
(tracing/with-span :search "search.init-index" {}
|
||||
(do-expensive-thing))
|
||||
|
||||
;; Span with static attributes
|
||||
(tracing/with-span :sync "sync.fingerprint.table"
|
||||
{:db/id (:db_id table)
|
||||
:sync/table (:name table)}
|
||||
(fingerprint-fields! table fields))
|
||||
|
||||
;; Span with computed attributes
|
||||
(tracing/with-span :search "search.execute"
|
||||
{:search/engine (name (:search-engine ctx))
|
||||
:search/query-length (count (:search-string ctx))}
|
||||
(search.engine/results ctx))
|
||||
|
||||
;; Span with sanitized SQL (for dynamic HoneySQL queries)
|
||||
(let [hsql {:delete-from [(t2/table-name :model/Session)]
|
||||
:where [:< :created_at oldest-allowed]}]
|
||||
(tracing/with-span :tasks "task.session-cleanup.delete"
|
||||
{:db/statement (tracing/best-effort-sanitize-sql hsql)}
|
||||
(t2/query-one hsql)))
|
||||
|
||||
;; Sub-spans breaking a function into I/O phases
|
||||
(let [embedding (tracing/with-span :search "search.semantic.embedding"
|
||||
{:search.semantic/provider (:provider model)}
|
||||
(get-embedding model search-string))
|
||||
results (tracing/with-span :search "search.semantic.db-query" {}
|
||||
(into [] xform reducible))]
|
||||
(process results))
|
||||
|
||||
;; Per-item iteration spans
|
||||
(doseq [e (search.engine/active-engines)]
|
||||
(tracing/with-span :search "search.ingestion.update" {:search/engine (name e)}
|
||||
(search.engine/update! e batch)))
|
||||
```
|
||||
|
||||
### 5. Add tests
|
||||
|
||||
Create or update tests in the corresponding `test/` path. Follow the patterns in existing tracing tests:
|
||||
|
||||
- **Reference tests:** `test/metabase/tracing/quartz_test.clj`, `test/metabase/server/middleware/trace_test.clj`
|
||||
- Use `tracing/init-enabled-groups!` / `tracing/shutdown-groups!` with `try`/`finally` to manage group lifecycle
|
||||
- Test both enabled and disabled paths (verify zero overhead when group is off)
|
||||
- Use `reify` mocks for Java interfaces (Connection, PreparedStatement, JobListener, etc.)
|
||||
- Add `(set! *warn-on-reflection* true)` and type-hint proxy/reify calls to avoid reflection warnings
|
||||
|
||||
```clojure
|
||||
(deftest my-span-enabled-test
|
||||
(testing "when group is enabled, span is created"
|
||||
(try
|
||||
(tracing/init-enabled-groups! "my-group" "INFO")
|
||||
;; ... test that span behavior occurs ...
|
||||
(finally
|
||||
(tracing/shutdown-groups!)))))
|
||||
|
||||
(deftest my-span-disabled-test
|
||||
(testing "when group is disabled, code runs without tracing"
|
||||
(tracing/shutdown-groups!)
|
||||
;; ... test that code still works, no wrapping applied ...
|
||||
))
|
||||
```
|
||||
|
||||
### 6. Lint and run tests
|
||||
|
||||
```bash
|
||||
# Lint modified source and test files — expect 0 errors, 0 warnings
|
||||
clj-kondo --lint path/to/modified/file.clj path/to/test/file.clj
|
||||
|
||||
# Run tests (requires Java 21+)
|
||||
clojure -X:dev:test :only my-ns.test-ns
|
||||
```
|
||||
|
||||
Expect: all tests pass, 0 failures, 0 errors, no reflection warnings from your files.
|
||||
|
||||
## Sanitizing SQL for Attributes
|
||||
|
||||
When including SQL in span attributes, **always** use `tracing/best-effort-sanitize-sql`. This converts HoneySQL maps to parameterized SQL strings where values become `?` placeholders -- no data leaks.
|
||||
|
||||
```clojure
|
||||
(let [hsql {:delete-from [:core_session]
|
||||
:where [:< :created_at some-timestamp]}]
|
||||
(tracing/with-span :tasks "task.cleanup.delete"
|
||||
{:db/statement (tracing/best-effort-sanitize-sql hsql)}
|
||||
(t2/query-one hsql)))
|
||||
;; Trace attribute: db/statement = "DELETE FROM core_session WHERE created_at < ?"
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Never put raw SQL strings or user-provided values in attributes
|
||||
- Use `best-effort-sanitize-sql` only for app DB (HoneySQL) queries
|
||||
- For external/user DB queries, trace only timing and counts, not SQL content
|
||||
|
||||
## Defjob and Root Spans
|
||||
|
||||
The `defjob` macro in `metabase.task.impl` automatically wraps every Quartz job with a `:tasks` root span:
|
||||
|
||||
```clojure
|
||||
(task/defjob ^{DisallowConcurrentExecution true} SessionCleanup [_]
|
||||
(cleanup-sessions!))
|
||||
;; Automatically creates span: "task.SessionCleanup" {:task/name "SessionCleanup"}
|
||||
```
|
||||
|
||||
You do NOT need a root span inside `defjob` bodies. Add **child spans** for I/O inside the job.
|
||||
|
||||
For code on plain `Thread`s (not Quartz), add the root span manually:
|
||||
|
||||
```clojure
|
||||
(defn init! []
|
||||
(tracing/with-span :search "search.task.init" {}
|
||||
(search/init-index!)))
|
||||
```
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
### Span Usage Mistakes
|
||||
|
||||
```clojure
|
||||
;; WRONG - pure computation, no I/O
|
||||
(tracing/with-span :search "search.format-results" {}
|
||||
(map format-result results))
|
||||
|
||||
;; WRONG - trivial single-row lookup
|
||||
(tracing/with-span :db-app "db-app.get-setting" {}
|
||||
(t2/select-one :model/Setting :key "my-setting"))
|
||||
|
||||
;; WRONG - raw SQL in attributes (data leak)
|
||||
(tracing/with-span :tasks "task.cleanup" {:db/statement raw-sql-string}
|
||||
(execute! raw-sql-string))
|
||||
|
||||
;; WRONG - wrong group (search work should use :search, not :tasks)
|
||||
(tracing/with-span :tasks "search.execute" {} ...)
|
||||
|
||||
;; WRONG - redundant nesting (do-search already has a span)
|
||||
(tracing/with-span :search "search.process" {}
|
||||
(let [results (do-search ctx)]
|
||||
(tracing/with-span :search "search.format" {}
|
||||
(format-results results))))
|
||||
```
|
||||
|
||||
### Architecture Mistakes
|
||||
|
||||
```clojure
|
||||
;; WRONG - creating a new tracing namespace
|
||||
(ns metabase.tracing.my-feature ...)
|
||||
|
||||
;; WRONG - requiring internal tracing namespaces from outside the module
|
||||
(ns metabase.my-module.thing
|
||||
(:require [metabase.tracing.attributes :as trace-attrs] ;; internal!
|
||||
[metabase.tracing.settings :as tracing.settings] ;; internal!
|
||||
[metabase.tracing.quartz :as tracing.quartz])) ;; internal!
|
||||
|
||||
;; WRONG - adding compile-time requires to tracing/core.clj for settings or SDK
|
||||
;; This creates cyclic load dependencies because tracing/core is widely required
|
||||
(ns metabase.tracing.core
|
||||
(:require [metabase.tracing.settings :as settings])) ;; causes cycle!
|
||||
|
||||
;; WRONG - dynamic symbol construction with requiring-resolve (kondo rejects it)
|
||||
(requiring-resolve (symbol "metabase.tracing.settings" "tracing-enabled"))
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings are env-var-only (defined in `src/metabase/tracing/settings.clj`):
|
||||
|
||||
```bash
|
||||
# Core
|
||||
MB_TRACING_ENABLED=true # Enable tracing (default: false)
|
||||
MB_TRACING_ENDPOINT=host:4317 # OTLP collector endpoint (default: http://localhost:4317)
|
||||
MB_TRACING_GROUPS=tasks,search,sync # Comma-separated groups or "all" (default: all)
|
||||
MB_TRACING_SERVICE_NAME=metabase # Service name in traces (default: hostname)
|
||||
MB_TRACING_LOG_LEVEL=DEBUG # Log threshold for traced threads: TRACE/DEBUG/INFO (default: INFO)
|
||||
|
||||
# Batch span processor tuning
|
||||
MB_TRACING_MAX_QUEUE_SIZE=2048 # Max spans queued for export; drops when full (default: 2048)
|
||||
MB_TRACING_EXPORT_TIMEOUT_MS=10000 # Max wait for batch export to complete (default: 10000)
|
||||
MB_TRACING_SCHEDULE_DELAY_MS=5000 # Delay between consecutive batch exports (default: 5000)
|
||||
```
|
||||
260
.claude/skills/e2e-test-create/SKILL.md
Normal file
@@ -0,0 +1,260 @@
|
||||
---
|
||||
name: e2e-test-create
|
||||
description: >
|
||||
Analyze React component source code to understand UI structure,
|
||||
then generate idiomatic Cypress E2E tests following Metabase conventions.
|
||||
Falls back to Playwright MCP browser exploration only when code reading
|
||||
and screenshot debugging are insufficient.
|
||||
disable-model-invocation: true
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Grep
|
||||
- Glob
|
||||
- Skill
|
||||
- "mcp__playwright__*"
|
||||
---
|
||||
|
||||
# Code-Reading-First → Generate Cypress Tests (Metabase)
|
||||
|
||||
You are writing Cypress E2E tests for the **Metabase** codebase.
|
||||
Before generating ANY test code, you MUST analyze React component source code
|
||||
to understand DOM structure, selectors, and user flows.
|
||||
|
||||
## Phase 0 — Research
|
||||
|
||||
1. Read existing helpers before writing anything:
|
||||
- `e2e/support/helpers/` — all shared helpers (restore, signInAs, openOrdersTable, etc.)
|
||||
- `e2e/support/cypress_sample_database.ts` — table/field schema constants (ORDERS, PRODUCTS, etc.)
|
||||
- `e2e/support/cypress_sample_instance_data.ts` — instance-specific IDs (ORDERS_DASHBOARD_ID, NORMAL_USER_ID, etc.)
|
||||
2. Glob `e2e/test/scenarios/` to find the closest existing spec to the area under test.
|
||||
Study its patterns — match them exactly.
|
||||
3. Glob `frontend/src/metabase/` to find React components for the feature area.
|
||||
|
||||
## Phase 1 — Code Analysis
|
||||
|
||||
Read React component source to understand DOM structure. No browser needed — source code has everything.
|
||||
|
||||
1. **Find relevant components**: Glob and grep `frontend/src/metabase/` for the feature area.
|
||||
2. **Extract selectors**: Grep for `data-testid` in relevant components.
|
||||
3. **Note visible text**: Read component JSX for button labels, headings, placeholders.
|
||||
4. **Note aria attributes**: Grep for `aria-label` in relevant components.
|
||||
5. **Understand user flows**: Read event handlers (onClick, onSubmit, onChange) to understand interactions.
|
||||
6. **Find API calls**: Grep for `Api.use`, `fetch`, `useQuery`, endpoint definitions to identify API calls to intercept.
|
||||
7. **Cross-reference with existing specs**: Find specs in the same area and reuse their proven selectors and `cy.intercept` patterns.
|
||||
|
||||
## Phase 2 — Start Backend
|
||||
|
||||
Use `MB_EDITION=oss` by default. Only use `MB_EDITION=ee` when the user explicitly asks to write an enterprise test.
|
||||
|
||||
Start the backend using `run_in_background: true` (NOT `&`).
|
||||
`bin/e2e-backend` automatically detects if a backend is already running and reuses it.
|
||||
```bash
|
||||
MB_EDITION=oss bin/e2e-backend
|
||||
```
|
||||
|
||||
Do NOT manually generate snapshots by running unrelated test specs.
|
||||
The `bun test-cypress` runner has `GENERATE_SNAPSHOTS: true` by default and automatically
|
||||
generates snapshots before running any spec. When running tests in Phase 4 via the `/e2e-test` skill,
|
||||
snapshots will be generated on the first run if they don't already exist.
|
||||
|
||||
Restore clean test data:
|
||||
```bash
|
||||
curl -sf -X POST http://localhost:4000/api/testing/restore/default
|
||||
```
|
||||
|
||||
## Phase 3 — Generate Cypress Spec
|
||||
|
||||
### File location & naming
|
||||
|
||||
Place specs in `e2e/test/scenarios/<area>/` mirroring the URL structure.
|
||||
Name: `<feature>.cy.spec.js` (NOT `.cy.spec.ts`).
|
||||
|
||||
### Metabase conventions (mandatory)
|
||||
|
||||
- **Helpers via `cy.H`**: All helpers are accessed via `const { H } = cy;` — NOT via direct imports from `e2e/support/helpers`.
|
||||
|
||||
```js
|
||||
const { H } = cy;
|
||||
|
||||
describe("feature name", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- **Sample Database schema** (table/field definitions): Import from `cypress_sample_database`.
|
||||
|
||||
```js
|
||||
import { ORDERS, ORDERS_ID, PRODUCTS } from "e2e/support/cypress_sample_database";
|
||||
```
|
||||
|
||||
- **Instance data** (dashboard IDs, user IDs, etc.): Import from `cypress_sample_instance_data`.
|
||||
|
||||
```js
|
||||
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
```
|
||||
|
||||
- **Selectors** (priority order):
|
||||
1. `cy.findByText()` / `cy.findByLabelText()` / `cy.findByRole()` — from `@testing-library/cypress`
|
||||
2. `cy.findByTestId()` — for `data-testid` attributes
|
||||
3. `cy.get("[data-testid='...']")` — fallback
|
||||
4. NEVER use positional selectors, CSS class names, or XPaths.
|
||||
|
||||
- **Navigation helpers**: Use existing helpers like `H.openOrdersTable()`, `H.openNativeEditor()`,
|
||||
`H.visitDashboard(id)`, `H.visitQuestion(id)` instead of raw `cy.visit()` chains.
|
||||
Grep `e2e/support/helpers/` to discover what's available for your area.
|
||||
|
||||
- **API setup over UI setup**: Use `cy.request()` or existing API helpers to set up state.
|
||||
Only use the UI for the flow you're actually testing.
|
||||
|
||||
- **Assertions**: Assert on visible text, URL, aria state — not DOM structure.
|
||||
|
||||
- **Waits**: Never use `cy.wait(ms)`. Use `cy.intercept()` + `cy.wait("@alias")` for API calls,
|
||||
or `cy.findByText().should("be.visible")` for DOM readiness.
|
||||
|
||||
- **Isolation**: Each `it()` block must be independently runnable.
|
||||
Don't depend on state from a previous `it()`.
|
||||
|
||||
### Spec structure template
|
||||
|
||||
```js
|
||||
const { H } = cy;
|
||||
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import { ORDERS, ORDERS_ID } from "e2e/support/cypress_sample_database";
|
||||
|
||||
describe("area > sub-area > feature (#issue-number)", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should do the primary happy-path thing", () => {
|
||||
// test
|
||||
});
|
||||
|
||||
it("should handle the edge case", () => {
|
||||
// test
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Intercepts
|
||||
|
||||
When you identified API calls during code analysis, stub or wait on them:
|
||||
|
||||
```js
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
// ... trigger action ...
|
||||
cy.wait("@dataset");
|
||||
```
|
||||
|
||||
## Phase 4 — Validate
|
||||
|
||||
After generating specs:
|
||||
1. Check that all imported helpers exist (Grep `e2e/support/helpers/`).
|
||||
2. **You MUST use the `/e2e-test` skill** to run tests — do NOT run `bun test-cypress` directly.
|
||||
The `/e2e-test` skill handles edition selection, snapshot management, and correct env vars.
|
||||
`/e2e-test GREP="should do the thing" --spec e2e/test/scenarios/<path>`
|
||||
If you created multiple `it()` blocks, run each one individually to isolate failures.
|
||||
|
||||
## Phase 5 — Fix Failures (up to 2 attempts)
|
||||
|
||||
When a test fails, **try to fix it from Cypress output first**:
|
||||
1. Read the failure screenshot (path printed under `(Screenshots)`).
|
||||
2. Read the error message and code frame from the console output.
|
||||
3. Fix the test and re-run (back to Phase 4, step 2).
|
||||
|
||||
If you cannot diagnose the issue after 2 attempts, proceed to Phase 6.
|
||||
|
||||
## Phase 6 — Playwright Fallback
|
||||
|
||||
Only reach this phase after 2 failed fix attempts from Phase 5. The backend is already running.
|
||||
|
||||
Restore clean test data:
|
||||
```bash
|
||||
curl -sf -X POST http://localhost:4000/api/testing/restore/default
|
||||
```
|
||||
|
||||
**Bypass CSP** headers before navigating (Metabase serves strict CSP that blocks dev server scripts).
|
||||
Use `browser_run_code` to set this up:
|
||||
|
||||
```js
|
||||
async (page) => {
|
||||
// Strip CSP headers so the page loads (mirrors Cypress chromeWebSecurity: false)
|
||||
await page.context().route('**/*', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const headers = { ...response.headers() };
|
||||
delete headers['content-security-policy'];
|
||||
delete headers['content-security-policy-report-only'];
|
||||
await route.fulfill({ response, headers });
|
||||
});
|
||||
|
||||
// Sign in via API
|
||||
const response = await page.request.post('http://localhost:4000/api/session', {
|
||||
data: { username: 'admin@metabase.test', password: '12341234' }
|
||||
});
|
||||
const session = await response.json();
|
||||
await page.context().addCookies([{
|
||||
name: 'metabase.DEVICE',
|
||||
value: session.id,
|
||||
domain: 'localhost',
|
||||
path: '/'
|
||||
}]);
|
||||
|
||||
await page.goto('http://localhost:4000');
|
||||
await page.waitForLoadState('networkidle');
|
||||
return 'signed in';
|
||||
}
|
||||
```
|
||||
|
||||
**Maintain an observation log incrementally.** After EVERY significant Playwright interaction,
|
||||
IMMEDIATELY append what you observed to the scratch file BEFORE performing the next interaction:
|
||||
|
||||
```bash
|
||||
cat >> /tmp/e2e-observations.md << 'OBSERVATION'
|
||||
## [Page/Flow name]
|
||||
- URL: /question/notebook#...
|
||||
- Clicked: "Box plot" button → visible text "Box plot", role: radio
|
||||
- Selectors: data-testid="viz-type-button", findByText("Box plot")
|
||||
- API call: POST /api/dataset (triggered on viz change)
|
||||
- Key state: after selecting viz type, summary sidebar shows metric picker
|
||||
OBSERVATION
|
||||
```
|
||||
|
||||
For each page/flow:
|
||||
- Take an accessibility snapshot (`browser_snapshot`).
|
||||
- Click through interactive elements, fill forms, trigger modals.
|
||||
- **Append to the observation log immediately after each step**: URLs, visible text, aria labels, `data-testid` attrs, API calls.
|
||||
- Screenshot key states.
|
||||
|
||||
After exploration:
|
||||
1. Read back your observation log: `cat /tmp/e2e-observations.md`
|
||||
2. Fix the test using observed selectors and behavior.
|
||||
3. Re-run the test (back to Phase 4).
|
||||
4. Clean up: `rm -f /tmp/e2e-observations.md`
|
||||
|
||||
## Phase 7 — Cleanup
|
||||
|
||||
After all tests pass (or after giving up on fixing failures), **always kill the backend on port 4000**:
|
||||
```bash
|
||||
lsof -ti:4000 | xargs kill 2>/dev/null || true
|
||||
```
|
||||
|
||||
Do NOT use broad `pkill` patterns — there may be other Metabase instances on different ports.
|
||||
The backend process started in Phase 2 will NOT be killed automatically when the Claude session ends.
|
||||
Leaving it running wastes resources and can interfere with future sessions. Always clean up.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Do NOT use Playwright as the first step — always analyze source code first.
|
||||
- Do NOT kill the backend between phases — it stays running throughout.
|
||||
- Do NOT invent selectors you didn't find in source code or observe in the browser.
|
||||
- Do NOT hardcode database IDs — import from `cypress_sample_database` or `cypress_sample_instance_data`.
|
||||
- Do NOT use `cy.wait(1000)` or any numeric wait.
|
||||
- Do NOT create setup flows through the UI when an API helper exists.
|
||||
- Do NOT put tests in `cypress/e2e/` — Metabase uses `e2e/test/scenarios/`.
|
||||
- Do NOT import helpers directly from `e2e/support/helpers` — use `const { H } = cy;`.
|
||||
78
.claude/skills/e2e-test/SKILL.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: e2e-test
|
||||
description: Run Cypress E2E tests, analyze failures including screenshots, and stress test for flakiness
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
Run Cypress E2E test: $ARGUMENTS
|
||||
|
||||
## Edition selection
|
||||
|
||||
**Default: `MB_EDITION=oss`** — runs without enterprise tokens, simpler and faster.
|
||||
|
||||
Only use `MB_EDITION=ee` when the user explicitly asks to write or run an enterprise test.
|
||||
|
||||
**For `MB_EDITION=ee`:**
|
||||
These tokens must be exported in the shell before starting Claude Code:
|
||||
- `CYPRESS_MB_ALL_FEATURES_TOKEN` — all features (most EE tests need this)
|
||||
- `CYPRESS_MB_PRO_SELF_HOSTED_TOKEN` — pro self-hosted (permissions, SSO, sandboxing)
|
||||
- `CYPRESS_MB_STARTER_CLOUD_TOKEN` — starter plan (rarely needed)
|
||||
- `CYPRESS_MB_PRO_CLOUD_TOKEN` — pro cloud (rarely needed)
|
||||
|
||||
**NEVER echo, print, or log token values. Only check if they are set:**
|
||||
```bash
|
||||
echo "ALL_FEATURES: ${CYPRESS_MB_ALL_FEATURES_TOKEN:+set}" && echo "PRO_SELF_HOSTED: ${CYPRESS_MB_PRO_SELF_HOSTED_TOKEN:+set}" && echo "STARTER: ${CYPRESS_MB_STARTER_CLOUD_TOKEN:+set}" && echo "PRO_CLOUD: ${CYPRESS_MB_PRO_CLOUD_TOKEN:+set}"
|
||||
```
|
||||
|
||||
If tokens are missing, tell the user: "EE tokens are not set. Either export them and restart Claude Code, or add `MB_EDITION=oss` to run OSS-only tests."
|
||||
|
||||
## Running
|
||||
|
||||
First check if snapshots exist:
|
||||
```bash
|
||||
ls e2e/snapshots/default.sql 2>/dev/null && echo "snapshots exist" || echo "snapshots missing"
|
||||
```
|
||||
|
||||
If snapshots exist, skip regeneration for speed:
|
||||
```bash
|
||||
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false GENERATE_SNAPSHOTS=false bun test-cypress $ARGUMENTS
|
||||
```
|
||||
|
||||
If snapshots are missing (first run), let the runner generate them:
|
||||
```bash
|
||||
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false bun test-cypress $ARGUMENTS
|
||||
```
|
||||
|
||||
When running enterprise tests, replace `MB_EDITION=oss` with `MB_EDITION=ee` in the commands above.
|
||||
|
||||
If `$ARGUMENTS` is empty, ask the user which spec to run.
|
||||
|
||||
If the user provides a test name or fuzzy name instead of a spec path, find the spec file:
|
||||
```bash
|
||||
find e2e/test/scenarios -name "*FUZZY_NAME*.cy.spec.*" -type f
|
||||
```
|
||||
Then run with `--spec "path/to/matched.cy.spec.js"`.
|
||||
|
||||
To filter by test name, use the `GREP` env var (not `--env grep=`, which breaks on commas):
|
||||
```bash
|
||||
GREP="test name here" MB_EDITION=oss bun test-cypress --spec path/to/spec.js
|
||||
```
|
||||
|
||||
## Stress test (flaky detection)
|
||||
|
||||
When the user asks to verify a test is not flaky:
|
||||
|
||||
```bash
|
||||
MB_EDITION=oss bin/e2e-stress-test $ARGUMENTS
|
||||
```
|
||||
|
||||
Set `E2E_STRESS_RUNS=N` for more iterations (default: 5). Stops on first failure and prints screenshot paths. Read those screenshots to analyze the failure.
|
||||
|
||||
## Analyzing failures
|
||||
|
||||
When tests fail:
|
||||
|
||||
1. Read the **screenshot** for each failure — paths are printed in Cypress output under `(Screenshots)`, typically at `cypress/screenshots/<spec-name>/<test-name> (failed).png`
|
||||
2. Read the error message and code frame from the console output
|
||||
3. Identify the root cause: is it a test bug, a product bug, or a timing issue?
|
||||
4. Suggest a fix with specific code changes
|
||||
@@ -5,227 +5,110 @@ description: Run mutation testing on a Clojure namespace, generate tests to kill
|
||||
|
||||
# Mutation Testing Skill
|
||||
|
||||
This skill runs mutation testing on a target namespace, writes tests to kill surviving mutations, and creates draft PRs with Linear issues for each function.
|
||||
This skill runs mutation testing end-to-end: generates a coverage report, groups functions, shells out to `claude -p` to write tests, verifies mutations are killed, and creates draft PRs with Linear issues.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running nREPL connected to the Metabase dev environment
|
||||
- `LINEAR_API_KEY` environment variable set with a valid Linear personal API key
|
||||
- `gh` CLI authenticated with GitHub
|
||||
- `claude` CLI available on PATH
|
||||
|
||||
## Reference Files
|
||||
|
||||
- `dev/src/dev/coverage.clj` — mutation testing tool (generates reports, runs mutations)
|
||||
- `dev/src/dev/mutation_testing.clj` — Linear API client and PR template helpers
|
||||
- `mutation-testing-report.lib.card.md` — example report from the pilot
|
||||
- `dev/src/dev/coverage.clj` — mutation testing engine (generates reports, runs mutations)
|
||||
- `dev/src/dev/mutation_testing.clj` — orchestration, Linear API, PR helpers
|
||||
|
||||
## Invocation
|
||||
|
||||
The argument is a Clojure namespace, e.g.:
|
||||
The argument is a Clojure namespace, optionally followed by a base branch:
|
||||
|
||||
```
|
||||
/mutation-testing metabase.lib.order-by
|
||||
/mutation-testing metabase.lib.order-by --base-branch release-x.52.x
|
||||
/mutation-testing metabase.lib.order-by --project-id abc-123
|
||||
/mutation-testing metabase.lib.order-by --base-branch release-x.52.x --project-id abc-123
|
||||
```
|
||||
|
||||
## Workflow
|
||||
- `--base-branch`: defaults to the value in config (set via `set-config!`), or `"master"` if not configured.
|
||||
- `--project-id`: if provided, issues are added to this existing Linear project instead of creating a new one.
|
||||
|
||||
### Step 1: Parse the namespace
|
||||
## Steps
|
||||
|
||||
From the argument (e.g., `metabase.lib.order-by`), derive:
|
||||
- **Test namespace**: append `-test` (e.g., `metabase.lib.order-by-test`)
|
||||
- **Short name**: the last segment (e.g., `order-by`)
|
||||
- **Source path**: `src/metabase/lib/order_by.cljc` (replace `-` with `_` in path segments)
|
||||
- **Test path**: `test/metabase/lib/order_by_test.cljc`
|
||||
### 1. Parse arguments
|
||||
|
||||
### Step 2: Set up Linear and load tools
|
||||
Parse `$ARGUMENTS` to extract:
|
||||
- **namespace** (required) — the first positional argument
|
||||
- **`--base-branch`** (optional) — if present, the next argument is the base branch name
|
||||
- **`--project-id`** (optional) — if present, the next argument is an existing Linear project ID
|
||||
|
||||
### 2. Load and configure
|
||||
|
||||
```clojure
|
||||
(require '[dev.coverage :as cov] :reload)
|
||||
(require '[dev.mutation-testing :as mut-test] :reload)
|
||||
(require '[dev.coverage :as cov] :reload)
|
||||
```
|
||||
|
||||
If this is the first invocation (or the REPL was restarted), set up the Linear team:
|
||||
|
||||
```clojure
|
||||
;; Find the team
|
||||
(mut-test/list-teams!)
|
||||
;; Set team ID from the output
|
||||
(mut-test/set-config! {:team-id "<team-id>"})
|
||||
(mut-test/set-config! {:team-id "<id>"})
|
||||
```
|
||||
|
||||
If config is already set from a previous invocation, skip this.
|
||||
|
||||
### Step 3: Generate baseline report and create Linear project
|
||||
### 3. Run
|
||||
|
||||
```clojure
|
||||
(cov/generate-report
|
||||
'<target-ns>
|
||||
['<test-ns>]
|
||||
"mutation-testing-report.lib.<short-name>.before.md")
|
||||
(mut-test/run! '<target-ns>)
|
||||
;; Or with options:
|
||||
(mut-test/run! '<target-ns> {:base-branch "release-x.52.x"})
|
||||
(mut-test/run! '<target-ns> {:project-id "abc-123"})
|
||||
(mut-test/run! '<target-ns> {:base-branch "release-x.52.x" :project-id "abc-123"})
|
||||
```
|
||||
|
||||
Read the generated report file. It has three sections:
|
||||
- **Uncovered Functions** — never called by any test
|
||||
- **Partially Covered Functions** — called but with surviving mutations
|
||||
- **Fully Covered Functions** — all mutations killed
|
||||
Pass the opts map with `:base-branch` and/or `:project-id` if those flags were provided in the arguments.
|
||||
|
||||
Then create a Linear project for this namespace (uses report stats in the description):
|
||||
This will:
|
||||
1. Generate a baseline mutation testing report
|
||||
2. Create a Linear project for the namespace
|
||||
3. Group functions by coverage relationships
|
||||
4. For each group: create branch → invoke Claude to write tests → verify → retry if needed → commit & push → create Linear issue → create draft PR
|
||||
5. Print a summary with PR links
|
||||
|
||||
### 4. Handle failures
|
||||
|
||||
If a group fails mid-processing, `run!` catches the error and continues to the next group. To retry a single group manually:
|
||||
|
||||
```clojure
|
||||
(mut-test/create-project-for-namespace!
|
||||
"<target-ns>"
|
||||
"mutation-testing-report.lib.<short-name>.before.md")
|
||||
;; => {:project-id "...", :name "Mutation Testing: metabase.lib.order-by"}
|
||||
;; Automatically sets :project-id in config for subsequent create-issue! calls
|
||||
;; Get the parsed namespace info
|
||||
(def parsed (mut-test/parse-namespace '<target-ns>))
|
||||
|
||||
;; Run coverage to get the data
|
||||
(def coverage-results (cov/test-namespace (:target-ns parsed) [(:test-ns parsed)]))
|
||||
|
||||
;; Group and find the one you want
|
||||
(def groups (mut-test/group-functions coverage-results))
|
||||
|
||||
;; Process just that group
|
||||
(mut-test/process-group! parsed (nth groups <index>))
|
||||
```
|
||||
|
||||
### Step 4: Plan the work
|
||||
### 5. Verify the prompt (dry run)
|
||||
|
||||
Before creating branches, analyze the report to plan how to group the work:
|
||||
|
||||
1. **Identify killable functions.** For each function with surviving mutations, assess whether any mutations are killable. Run `cov/test-mutations` to see the full mutation list. If ALL mutations for a function are unkillable (schema-only, semantically equivalent, etc.), skip that function — no branch or PR needed.
|
||||
|
||||
2. **Group private functions by public entry point.** Private functions cannot be tested directly. Group them with the public function that exercises them. Create one branch/PR per group, not per private function.
|
||||
|
||||
3. **Track mutations per group.** The `:mutations-before` and `:not-killed` fields in the PR should only reflect mutations for functions covered by that specific PR — never mutations from other functions.
|
||||
|
||||
### Step 5: Process each group
|
||||
|
||||
For each group of functions that has killable mutations:
|
||||
|
||||
#### 5a. Create a branch
|
||||
To inspect what Claude will see without invoking it:
|
||||
|
||||
```clojure
|
||||
(mut-test/create-branch! "<target-ns>" "<primary-fn-name>")
|
||||
;; => "mutation-testing-lib-order-by-orderable-columns"
|
||||
(def parsed (mut-test/parse-namespace '<target-ns>))
|
||||
(def coverage-results (cov/test-namespace (:target-ns parsed) [(:test-ns parsed)]))
|
||||
(def groups (mut-test/group-functions coverage-results))
|
||||
(println (mut-test/build-test-prompt
|
||||
(merge (select-keys parsed [:target-ns :test-ns :source-path :test-path])
|
||||
(select-keys (first groups) [:fn-names :mutations]))))
|
||||
```
|
||||
|
||||
Use the primary public function name for the branch. If the group covers private functions too, name the branch after the public entry point.
|
||||
|
||||
#### 5b. Read context
|
||||
|
||||
- Read the source function(s) from the source file
|
||||
- Read the full test file to understand existing test patterns, helpers, and metadata providers used
|
||||
- If the function is private, identify which public function(s) call it
|
||||
|
||||
#### 5c. Write tests
|
||||
|
||||
Generate the **simplest tests** that kill the surviving mutations while still making semantic sense. Guidelines:
|
||||
|
||||
- **Never call private functions directly.** Always test through public API functions. Use the coverage data to identify which public functions exercise the private function.
|
||||
- **Keep tests simple.** Each test should verify one meaningful behavior. Don't over-engineer tests just to chase mutation kills.
|
||||
- **Follow existing patterns.** Match the style of the existing tests: same metadata providers, same helper functions, same assertion patterns.
|
||||
- **Insert tests near related existing tests** for the same function, not at the end of the file. This minimizes merge conflicts between branches. If there are no existing tests for the function, find the most logical location based on the order of functions in the source file.
|
||||
|
||||
**IMPORTANT: Use `file_edit` (not `clojure_edit`) when editing test files.** The `clojure_edit` tool reformats the entire file, introducing unnecessary whitespace changes that pollute the diff. Use `file_edit` with exact string matching to make surgical insertions that only touch the lines you intend to change.
|
||||
|
||||
#### 5d. Verify mutations are killed
|
||||
|
||||
Use the REPL to check that the new tests kill the targeted mutations:
|
||||
|
||||
```clojure
|
||||
;; Reload the test namespace to pick up new tests
|
||||
(require '<test-ns> :reload)
|
||||
|
||||
;; Test mutations for the specific function
|
||||
(cov/test-mutations
|
||||
'<target-ns>/<fn-name>
|
||||
;; Set of test names that cover this function
|
||||
#{'<test-ns>/<test-name-1> '<test-ns>/<test-name-2>})
|
||||
```
|
||||
|
||||
Check the `:survived` key in the result. If mutations still survive:
|
||||
- Try writing additional tests
|
||||
- If a mutation is truly unkillable (semantically equivalent), note it for the PR description
|
||||
- If a mutation reveals dead/unreachable code, consider suggesting removal as a code improvement
|
||||
|
||||
#### 5e. Handle unkillable mutations and improvements
|
||||
|
||||
**Do NOT edit the source namespace just for documentation.** Instead:
|
||||
|
||||
- **Unkillable mutations**: Note them in the PR description with rationale
|
||||
- **Code improvements** (dead code removal, expression simplification) that directly relate to surviving mutants:
|
||||
1. Make the change in the source file
|
||||
2. Verify all tests still pass with the change
|
||||
3. Note the file path, line range, and the improved code
|
||||
4. **Reset the change**: `git checkout -- <source-file>` — do NOT commit it
|
||||
5. After creating the PR, use `add-suggested-change!` to post the improvement as a GitHub suggested change (Step 5h)
|
||||
6. The reviewer decides whether to accept it
|
||||
|
||||
#### 5f. Commit and push
|
||||
|
||||
```clojure
|
||||
;; Only commit the test file — never commit source changes for improvements
|
||||
(mut-test/commit-and-push! "<target-ns>" "<primary-fn-name>"
|
||||
["test/metabase/lib/<short_name>_test.cljc"])
|
||||
```
|
||||
|
||||
#### 5g. Create Linear issue and draft PR
|
||||
|
||||
```clojure
|
||||
;; Create a Linear issue (use the primary function name)
|
||||
(def issue (mut-test/create-issue-for-function! "<target-ns>" "<primary-fn-name>"))
|
||||
;; => {:identifier "QUE-1234", :url "https://linear.app/...", ...}
|
||||
|
||||
;; Create draft PR linked to the Linear issue
|
||||
;; :fn-names lists ALL functions covered by this PR
|
||||
;; :mutations-before counts only mutations for functions in this PR
|
||||
;; :not-killed lists only unkillable mutations for functions in this PR
|
||||
(def pr-url
|
||||
(mut-test/create-draft-pr!
|
||||
{:target-ns "<target-ns>"
|
||||
:fn-name "<primary-fn-name>"
|
||||
:linear-identifier (:identifier issue)
|
||||
:mutations-before 9 ;; surviving mutations for these functions only
|
||||
:tests-added 2 ;; number of new test functions
|
||||
:killed ["Replace :segment-id with :segment-id__"
|
||||
"Replace cycle-path with nil"]
|
||||
:not-killed [{:description "Replace acc with nil"
|
||||
:rationale "reduce always has single iteration"}]
|
||||
:suggested-changes []}))
|
||||
;; => "https://github.com/metabase/metabase/pull/12345"
|
||||
```
|
||||
|
||||
#### 5h. Add code improvement suggestions (if any)
|
||||
|
||||
If you identified code improvements in step 5e (and reset them), post each as a GitHub suggested change:
|
||||
|
||||
```clojure
|
||||
(mut-test/add-suggested-change!
|
||||
{:pr-url pr-url
|
||||
:path "src/metabase/lib/<short_name>.cljc"
|
||||
:start-line 42 ;; first line of the code to replace
|
||||
:end-line 45 ;; last line of the code to replace
|
||||
:suggestion "(improved-code-here)" ;; the replacement code
|
||||
:comment "**Suggested improvement:** <description of the change and why it's related to the surviving mutant>"})
|
||||
```
|
||||
|
||||
### Step 6: Return to master and repeat
|
||||
|
||||
```clojure
|
||||
(mut-test/return-to-master!)
|
||||
```
|
||||
|
||||
Repeat Step 5 for the next group.
|
||||
|
||||
### Step 7: Summary
|
||||
|
||||
Print a summary of what was done:
|
||||
- Link to the Linear project for this namespace
|
||||
- Number of functions processed
|
||||
- Number of functions skipped (all mutations unkillable)
|
||||
- Number of draft PRs created
|
||||
- Number of mutations killed vs. unkillable
|
||||
- List of test PRs with links
|
||||
- List of mutation rule PRs with links (if any were created)
|
||||
|
||||
## Suggesting New Mutation Rules
|
||||
|
||||
If you notice patterns in surviving mutations that suggest the mutation testing tool should have additional rules (e.g., a common Clojure form that isn't being mutated), open a separate PR proposing additions to `dev.coverage/mutation-rules` with an explanation.
|
||||
|
||||
## Configuration
|
||||
|
||||
The `dev.mutation-testing` namespace reads `LINEAR_API_KEY` from the environment and stores team/project IDs in an atom.
|
||||
|
||||
- **Team ID**: Set once per REPL session via `(mut-test/list-teams!)` and `(mut-test/set-config! {:team-id "..."})` (Step 2)
|
||||
- **Project ID**: Set automatically when `create-project-for-namespace!` is called (Step 3) — one project per namespace
|
||||
- **Team ID**: Set once per REPL session via `(mut-test/list-teams!)` and `(mut-test/set-config! {:team-id "..."})`
|
||||
- **Project ID**: Set automatically when `run!` calls `create-project-for-namespace!`
|
||||
- **Base Branch**: Defaults to `"master"`. Override via `(mut-test/set-config! {:base-branch "release-x.52.x"})` or pass `{:base-branch "..."}` as the second arg to `run!`
|
||||
- **Project ID**: If set (via `set-config!` or `run!` opts), `run!` reuses the existing Linear project instead of creating a new one
|
||||
|
||||
@@ -12,8 +12,9 @@ allowed-tools: Read, Grep, Bash, Glob
|
||||
|
||||
Review pull requests with a focus on:
|
||||
|
||||
- Readability and maintainability
|
||||
- Appropriate test coverage
|
||||
- Compliance with project coding standards and conventions
|
||||
- Code quality and best practices
|
||||
- Clear and correct JSDoc comments
|
||||
- Type safety and proper TypeScript usage
|
||||
- React best practices (when applicable)
|
||||
- React best practices (when applicable)
|
||||
|
||||
@@ -228,7 +228,8 @@
|
||||
|
||||
:invalid-arity {:skip-args [metabase.lib.util.match/match
|
||||
metabase.lib.util.match/match-lite
|
||||
metabase.lib.util.match/match-many]} ; TODO (cam): can we fix these?
|
||||
metabase.lib.util.match/match-many
|
||||
metabase.lib.util.match/replace-lite]} ; TODO (cam): can we fix these?
|
||||
:refer-all {:level :warning, :exclude [clojure.test]}
|
||||
:unused-referred-var {:exclude {compojure.core [GET DELETE POST PUT]}}
|
||||
|
||||
@@ -250,12 +251,14 @@
|
||||
(metabase.lib.util.match/match-many)
|
||||
(metabase.lib.util.match/replace)
|
||||
(metabase.lib.util.match/replace-in)
|
||||
(metabase.lib.util.match/replace-lite)
|
||||
(metabase.driver-api.core/match)
|
||||
(metabase.driver-api.core/match-one)
|
||||
(metabase.driver-api.core/match-lite)
|
||||
(metabase.driver-api.core/match-many)
|
||||
(metabase.driver-api.core/replace)
|
||||
(metabase.driver-api.core/replace-in)
|
||||
(metabase.driver-api.core/replace-lite)
|
||||
(metabase.query-processor.middleware.cache-backend.interface/with-cached-results)
|
||||
(metabase.util.regex/rx [opt])
|
||||
(metabase.util/prog1 [<>])
|
||||
@@ -309,6 +312,7 @@
|
||||
metabase.lib.equality/find-column-indexes-for-refs {:message "Use lib.equality/closest-matches-in-metadata or lib.equality/find-closest-matches-for-refs instead of lib.equality/find-column-indexes-for-refs"}
|
||||
metabase.lib.util.match/match {:message "Use lib.util.match/match-many instead of lib.util.match/match."}
|
||||
metabase.lib.util.match/match-one {:message "Use lib.util.match/match-lite instead of lib.util.match/match-one."}
|
||||
metabase.lib.util.match/replace {:message "Use lib.util.match/replace-lite instead of lib.util.match/replace."}
|
||||
metabase-enterprise.transforms.interface/execute! {:message "Use metabase-enterprise.transforms.execute/execute! instead of metabase-enterprise.transforms.interface/execute!"}
|
||||
next.jdbc/active-tx? {:message "Use metabase.app-db.core/in-transaction? to see if we are in a transaction"}
|
||||
toucan2.core/debug {:message "Don't commit code using t2/debug"}
|
||||
@@ -418,6 +422,8 @@
|
||||
ring.util.codec codec
|
||||
ring.util.response response
|
||||
saml20-clj.core saml
|
||||
metabase.transforms-base.util transforms-base.u
|
||||
metabase.transforms.util transforms.u
|
||||
toucan2.core t2}}}
|
||||
|
||||
:lint-as
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
version}}
|
||||
|
||||
analyze
|
||||
{:team "Querying"
|
||||
{:team "Graphy"
|
||||
:api #{metabase.analyze.core
|
||||
metabase.analyze.query-results}
|
||||
:uses #{config
|
||||
@@ -152,6 +152,7 @@
|
||||
metabase.api.docs
|
||||
metabase.api.init
|
||||
metabase.api.macros
|
||||
metabase.api.macros.scope
|
||||
metabase.api.open-api
|
||||
metabase.api.response
|
||||
metabase.api.routes.common
|
||||
@@ -214,6 +215,7 @@
|
||||
settings
|
||||
task
|
||||
task-history
|
||||
tracing
|
||||
util}}
|
||||
|
||||
auth-identity
|
||||
@@ -275,6 +277,7 @@
|
||||
config
|
||||
events
|
||||
models
|
||||
permissions
|
||||
premium-features
|
||||
request
|
||||
settings
|
||||
@@ -395,6 +398,7 @@
|
||||
request
|
||||
revisions
|
||||
settings
|
||||
tracing
|
||||
transforms
|
||||
upload
|
||||
util}}
|
||||
@@ -547,6 +551,8 @@
|
||||
metabase.driver.common
|
||||
metabase.driver.common.parameters
|
||||
metabase.driver.common.parameters.parse
|
||||
metabase.driver.connection
|
||||
metabase.driver.connection.workspaces
|
||||
metabase.driver.ddl.interface
|
||||
metabase.driver.h2
|
||||
metabase.driver.impl
|
||||
@@ -569,7 +575,8 @@
|
||||
metabase.driver.sql.util
|
||||
metabase.driver.sync
|
||||
metabase.driver.util}
|
||||
:uses #{app-db
|
||||
:uses #{analytics
|
||||
app-db
|
||||
appearance
|
||||
auth-provider
|
||||
classloader
|
||||
@@ -584,6 +591,7 @@
|
||||
settings
|
||||
sql-tools
|
||||
system
|
||||
tracing
|
||||
util
|
||||
warehouse-schema
|
||||
warehouses}}
|
||||
@@ -662,7 +670,8 @@
|
||||
:api #{metabase.events.core
|
||||
metabase.events.init}
|
||||
:uses #{models
|
||||
util}}
|
||||
util}
|
||||
:friends #{tracing}}
|
||||
|
||||
formatter
|
||||
{:team "UX West"
|
||||
@@ -691,7 +700,7 @@
|
||||
util}}
|
||||
|
||||
graph
|
||||
{:team "Querying"
|
||||
{:team "Graphy"
|
||||
:api #{metabase.graph.core}
|
||||
:uses #{util}}
|
||||
|
||||
@@ -733,26 +742,18 @@
|
||||
|
||||
lib
|
||||
{:team "Querying"
|
||||
:api #{metabase.lib.binning
|
||||
metabase.lib.core
|
||||
:api #{metabase.lib.core
|
||||
metabase.lib.equality
|
||||
metabase.lib.field
|
||||
metabase.lib.init
|
||||
metabase.lib.metadata
|
||||
metabase.lib.metadata.cached-provider
|
||||
metabase.lib.metadata.column
|
||||
metabase.lib.metadata.invocation-tracker
|
||||
metabase.lib.metadata.protocols
|
||||
metabase.lib.metadata.result-metadata
|
||||
metabase.lib.normalize
|
||||
metabase.lib.options
|
||||
metabase.lib.query
|
||||
metabase.lib.schema
|
||||
metabase.lib.schema.actions
|
||||
metabase.lib.schema.aggregation
|
||||
metabase.lib.schema.common
|
||||
metabase.lib.schema.expression
|
||||
metabase.lib.schema.expression.temporal
|
||||
metabase.lib.schema.filter
|
||||
metabase.lib.schema.id
|
||||
metabase.lib.schema.info
|
||||
metabase.lib.schema.join
|
||||
@@ -768,17 +769,20 @@
|
||||
metabase.lib.schema.test-spec
|
||||
metabase.lib.schema.util
|
||||
metabase.lib.schema.validate
|
||||
metabase.lib.temporal-bucket
|
||||
metabase.lib.types.isa
|
||||
metabase.lib.util
|
||||
metabase.lib.util.match
|
||||
metabase.lib.walk}
|
||||
metabase.lib.util.match}
|
||||
:uses #{config
|
||||
graph
|
||||
legacy-mbql
|
||||
types
|
||||
util}
|
||||
:friends #{query-processor legacy-mbql}}
|
||||
:friends #{legacy-mbql ;; Reuses internal schemas and a few helper functions. Deeply coupled to MBQL.
|
||||
lib-be ;; BE-specific extension of lib; tight coupling is acceptable.
|
||||
query-processor ;; Necessarily manipulates MBQL; better to pull most logic into a new module.
|
||||
source-swap ;; Manipulates MBQL queries; tight coupling with lib is expected.
|
||||
enterprise/sandbox ;; Really part of the QP, though its lives in the enterprise/ tree.
|
||||
}}
|
||||
|
||||
lib-be
|
||||
{:team "Querying"
|
||||
@@ -789,6 +793,16 @@
|
||||
settings
|
||||
util}}
|
||||
|
||||
lib-metric
|
||||
{:team "UX West"
|
||||
:api #{metabase.lib-metric.core
|
||||
metabase.lib-metric.schema}
|
||||
:uses #{lib
|
||||
lib-be
|
||||
settings
|
||||
types
|
||||
util}}
|
||||
|
||||
llm
|
||||
{:team "Metabot"
|
||||
:api #{metabase.llm.api
|
||||
@@ -838,12 +852,29 @@
|
||||
events
|
||||
lib
|
||||
lib-be
|
||||
metrics
|
||||
models
|
||||
permissions
|
||||
remote-sync
|
||||
search
|
||||
util}}
|
||||
|
||||
metrics
|
||||
{:team "UX West"
|
||||
:api #{metabase.metrics.api
|
||||
metabase.metrics.core}
|
||||
:uses #{api
|
||||
collections
|
||||
lib
|
||||
lib-be
|
||||
lib-metric
|
||||
models
|
||||
parameters
|
||||
query-processor
|
||||
request
|
||||
server
|
||||
util}}
|
||||
|
||||
model-persistence
|
||||
{:team "Querying"
|
||||
:api #{metabase.model-persistence.api
|
||||
@@ -864,6 +895,7 @@
|
||||
system
|
||||
task
|
||||
task-history
|
||||
tracing
|
||||
util}}
|
||||
|
||||
models
|
||||
@@ -873,6 +905,7 @@
|
||||
metabase.models.interface
|
||||
metabase.models.resolution
|
||||
metabase.models.serialization
|
||||
metabase.models.transforms.transform
|
||||
metabase.models.util.spec-update
|
||||
metabase.models.visualization-settings}
|
||||
:uses #{api
|
||||
@@ -889,6 +922,7 @@
|
||||
search
|
||||
settings
|
||||
transforms
|
||||
transforms-base
|
||||
util}
|
||||
:friends #{transforms}}
|
||||
|
||||
@@ -935,6 +969,7 @@
|
||||
system
|
||||
task
|
||||
task-history
|
||||
tracing
|
||||
users
|
||||
util}}
|
||||
|
||||
@@ -978,6 +1013,7 @@
|
||||
premium-features
|
||||
remote-sync
|
||||
settings
|
||||
tracing
|
||||
util
|
||||
enterprise/advanced-permissions}}
|
||||
|
||||
@@ -1023,6 +1059,7 @@
|
||||
settings
|
||||
startup
|
||||
task
|
||||
tracing
|
||||
util}}
|
||||
|
||||
product-feedback
|
||||
@@ -1090,6 +1127,7 @@
|
||||
request
|
||||
task
|
||||
task-history
|
||||
tracing
|
||||
util
|
||||
enterprise/advanced-permissions
|
||||
enterprise/sandbox}}
|
||||
@@ -1115,6 +1153,7 @@
|
||||
events
|
||||
lib
|
||||
lib-be
|
||||
metrics
|
||||
models
|
||||
parameters
|
||||
permissions
|
||||
@@ -1180,6 +1219,7 @@
|
||||
metabase.query-processor.middleware.add-remaps
|
||||
metabase.query-processor.middleware.annotate
|
||||
metabase.query-processor.middleware.cache-backend.db
|
||||
metabase.query-processor.middleware.catch-exceptions
|
||||
metabase.query-processor.middleware.constraints
|
||||
metabase.query-processor.middleware.limit
|
||||
metabase.query-processor.middleware.permissions
|
||||
@@ -1231,6 +1271,7 @@
|
||||
server
|
||||
settings
|
||||
system
|
||||
tracing
|
||||
users
|
||||
util}}
|
||||
|
||||
@@ -1312,6 +1353,7 @@
|
||||
settings
|
||||
startup
|
||||
task
|
||||
tracing
|
||||
transforms
|
||||
util}}
|
||||
|
||||
@@ -1358,6 +1400,7 @@
|
||||
session
|
||||
settings
|
||||
system
|
||||
tracing
|
||||
users
|
||||
util
|
||||
enterprise/sso}}
|
||||
@@ -1379,6 +1422,7 @@
|
||||
sso
|
||||
system
|
||||
task
|
||||
tracing
|
||||
util}}
|
||||
|
||||
settings
|
||||
@@ -1425,6 +1469,15 @@
|
||||
system
|
||||
util}}
|
||||
|
||||
source-swap
|
||||
{:team "Graphy"
|
||||
:api #{metabase.source-swap.core
|
||||
metabase.source-swap.schema}
|
||||
:uses #{driver
|
||||
lib
|
||||
sql-tools
|
||||
util}}
|
||||
|
||||
sql-parsing
|
||||
{:team "DevEx"
|
||||
:api #{metabase.sql-parsing.core}
|
||||
@@ -1470,7 +1523,7 @@
|
||||
util}}
|
||||
|
||||
sync
|
||||
{:team "Querying"
|
||||
{:team "Graphy"
|
||||
:api #{metabase.sync.api
|
||||
metabase.sync.core
|
||||
metabase.sync.init
|
||||
@@ -1492,6 +1545,7 @@
|
||||
settings
|
||||
task
|
||||
task-history
|
||||
tracing
|
||||
util
|
||||
warehouse-schema
|
||||
warehouses}}
|
||||
@@ -1508,6 +1562,7 @@
|
||||
metabase.task.core}
|
||||
:uses #{app-db
|
||||
classloader
|
||||
tracing
|
||||
util}}
|
||||
|
||||
task-history
|
||||
@@ -1524,6 +1579,7 @@
|
||||
query-processor
|
||||
request
|
||||
task
|
||||
tracing
|
||||
util}}
|
||||
|
||||
tenants
|
||||
@@ -1571,12 +1627,18 @@
|
||||
models
|
||||
util}}
|
||||
|
||||
tracing
|
||||
{:team "DevEx"
|
||||
:api #{metabase.tracing.core
|
||||
metabase.tracing.init}
|
||||
:uses #{events
|
||||
settings
|
||||
task
|
||||
util}}
|
||||
|
||||
transforms
|
||||
{:team "Querying"
|
||||
:api #{metabase.transforms.api.transform
|
||||
metabase.transforms.api.transform-job
|
||||
metabase.transforms.api.transform-tag
|
||||
metabase.transforms.core
|
||||
:api #{metabase.transforms.core
|
||||
metabase.transforms.feature-gating
|
||||
metabase.transforms.init
|
||||
metabase.transforms.schema
|
||||
@@ -1588,23 +1650,67 @@
|
||||
driver
|
||||
events
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
premium-features
|
||||
queries
|
||||
query-processor
|
||||
request
|
||||
revisions
|
||||
settings
|
||||
sql-tools
|
||||
sync
|
||||
task
|
||||
task-history
|
||||
tracing
|
||||
transforms-base
|
||||
util}
|
||||
:friends #{models
|
||||
enterprise/transforms-python
|
||||
enterprise/workspaces}}
|
||||
|
||||
transforms-base
|
||||
{:team "Querying"
|
||||
:api #{metabase.transforms-base.init
|
||||
metabase.transforms-base.interface
|
||||
metabase.transforms-base.util}
|
||||
:uses #{driver
|
||||
events
|
||||
lib
|
||||
lib-be
|
||||
query-processor
|
||||
sync
|
||||
util}
|
||||
:friends #{transforms
|
||||
enterprise/transforms-python
|
||||
enterprise/workspaces}}
|
||||
|
||||
transforms-inspector
|
||||
{:team "Gadget"
|
||||
:api #{metabase.transforms-inspector.core
|
||||
metabase.transforms-inspector.schema}
|
||||
:uses #{driver
|
||||
lib
|
||||
lib-be
|
||||
query-processor
|
||||
sql-tools
|
||||
transforms-base
|
||||
util}}
|
||||
|
||||
transforms-rest
|
||||
{:team "Gadget"
|
||||
:api #{metabase.transforms-rest.api.transform
|
||||
metabase.transforms-rest.api.transform-job
|
||||
metabase.transforms-rest.api.transform-tag}
|
||||
:uses #{api
|
||||
driver
|
||||
events
|
||||
models
|
||||
query-processor
|
||||
request
|
||||
server
|
||||
sql-tools
|
||||
transforms
|
||||
transforms-base
|
||||
transforms-inspector
|
||||
util}}
|
||||
|
||||
types
|
||||
{:team "Querying"
|
||||
:api #{metabase.types.core
|
||||
@@ -1898,7 +2004,9 @@
|
||||
request
|
||||
server
|
||||
util
|
||||
enterprise/metabot-v3}}
|
||||
enterprise/api
|
||||
enterprise/metabot-v3
|
||||
enterprise/workspaces}}
|
||||
|
||||
enterprise/ai-entity-analysis
|
||||
{:team "Metabot"
|
||||
@@ -2087,10 +2195,11 @@
|
||||
warehouses}}
|
||||
|
||||
enterprise/dependencies
|
||||
{:team "Querying"
|
||||
{:team "Graphy"
|
||||
:api #{metabase-enterprise.dependencies.api
|
||||
metabase-enterprise.dependencies.core
|
||||
metabase-enterprise.dependencies.init}
|
||||
metabase-enterprise.dependencies.init
|
||||
metabase-enterprise.dependencies.models.dependency}
|
||||
:uses #{analyze
|
||||
api
|
||||
app-db
|
||||
@@ -2111,8 +2220,10 @@
|
||||
request
|
||||
revisions
|
||||
settings
|
||||
sql-tools
|
||||
task
|
||||
transforms
|
||||
transforms-base
|
||||
util}}
|
||||
|
||||
enterprise/email
|
||||
@@ -2230,6 +2341,7 @@
|
||||
system
|
||||
task
|
||||
transforms
|
||||
transforms-base
|
||||
users
|
||||
util
|
||||
warehouses
|
||||
@@ -2271,6 +2383,21 @@
|
||||
enterprise/serialization
|
||||
enterprise/transforms-python}}
|
||||
|
||||
enterprise/replacement
|
||||
{:team "Graphy"
|
||||
:api #{metabase-enterprise.replacement.api}
|
||||
:uses #{api
|
||||
app-db
|
||||
events
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
queries
|
||||
source-swap
|
||||
task
|
||||
util
|
||||
enterprise/dependencies}}
|
||||
|
||||
enterprise/sandbox
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.sandbox.api.routes
|
||||
@@ -2334,6 +2461,7 @@
|
||||
search
|
||||
settings
|
||||
task
|
||||
tracing
|
||||
util
|
||||
enterprise/llm
|
||||
enterprise/metabot-v3}}
|
||||
@@ -2432,7 +2560,7 @@
|
||||
enterprise/transforms
|
||||
{:team "Querying"
|
||||
:api #{}
|
||||
:uses #{}}
|
||||
:uses #{premium-features}}
|
||||
|
||||
enterprise/transforms-python
|
||||
{:team "Querying"
|
||||
@@ -2453,6 +2581,7 @@
|
||||
query-processor
|
||||
settings
|
||||
transforms
|
||||
transforms-base
|
||||
util}}
|
||||
|
||||
enterprise/upload-management
|
||||
@@ -2468,8 +2597,8 @@
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.workspaces.api
|
||||
metabase-enterprise.workspaces.init}
|
||||
:friends #{enterprise/agent-api}
|
||||
:uses #{api
|
||||
api-keys
|
||||
app-db
|
||||
config
|
||||
database-routing
|
||||
@@ -2478,6 +2607,7 @@
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
premium-features
|
||||
queries
|
||||
;; NOTE: we will remove this once we get "pure transforms" module.
|
||||
query-processor
|
||||
@@ -2486,8 +2616,14 @@
|
||||
sql-tools
|
||||
system
|
||||
transforms
|
||||
transforms-base
|
||||
util
|
||||
enterprise/transforms-python}}}
|
||||
enterprise/transforms-python}}
|
||||
|
||||
enterprise/writable-connection
|
||||
{:team "Querying"
|
||||
:api #{}
|
||||
:uses #{premium-features}}}
|
||||
|
||||
;; namespaces matching these patterns (with `re-find`) are excluded from the module linter. Since regex literals
|
||||
;; aren't allowed in EDN just used the `(str <regex>)` version i.e. two slashes instead of one.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
;; TODO -- seems silly to maintain different blacklists and whitelists here than we use for the deftest `^:parallel`
|
||||
;; checker...
|
||||
{:explicitly-safe
|
||||
#{ ;; these toucan methods might actually set global values if it's used outside of a transaction,
|
||||
#{;; these toucan methods might actually set global values if it's used outside of a transaction,
|
||||
;; but since mt/with-temp runs in a transaction, so we'll ignore them in this case.
|
||||
toucan2.core/delete!
|
||||
toucan2.core/update!
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
help-link-custom-destination
|
||||
instance-creation
|
||||
is-hosted?
|
||||
is-metabot-enabled
|
||||
jdbc-data-warehouse-max-connection-pool-size
|
||||
jdbc-data-warehouse-unreturned-connection-timeout-seconds
|
||||
jwt-attribute-email
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
metabase.driver-api.core/match-lite [[:inner 0]]
|
||||
metabase.driver-api.core/match-many [[:inner 0]]
|
||||
metabase.driver-api.core/replace [[:inner 0]]
|
||||
metabase.driver-api.core/replace-lite [[:inner 0]]
|
||||
metabase.driver-api.core/replace-in [[:inner 0]]
|
||||
metabase.driver.bigquery-cloud-sdk.query-processor/with-temporal-type [[:default]]
|
||||
metabase.driver.druid.client/DELETE [[:default]]
|
||||
@@ -160,6 +161,7 @@
|
||||
metabase.lib.util.match/match-lite [[:inner 0]]
|
||||
metabase.lib.util.match/match-many [[:inner 0]]
|
||||
metabase.lib.util.match/replace [[:inner 0]]
|
||||
metabase.lib.util.match/replace-lite [[:inner 0]]
|
||||
metabase.lib.util.match/replace-in [[:inner 0]]
|
||||
metabase.models.interface/define-batched-hydration-method [[:inner 0]]
|
||||
metabase.models.interface/define-simple-hydration-method [[:inner 0]]
|
||||
|
||||
8
.gitattributes
vendored
@@ -1,7 +1,9 @@
|
||||
# Custom merge driver for YAML migration files
|
||||
# Treats each changeset as an atomic unit to avoid conflicts within changesets
|
||||
# Custom merge driver for per-release YAML migration files.
|
||||
# Treats each changeset as an atomic unit to avoid conflicts within changesets.
|
||||
# Only applies to per-release *_update_migrations.yaml files — directory-based
|
||||
# migrations (e.g. 060/*.yaml) have one file per migration and don't need this.
|
||||
# Execute this in your shell to enable custom merge driver:
|
||||
# echo '\n[include]\npath=../.gitconfig' >> .git/config
|
||||
#
|
||||
# TODO (Bryan 12/12/25) -- reenable this when we can completely verify correctness
|
||||
# resources/migrations/*.yaml merge=yaml-migrations
|
||||
# resources/migrations/*_update_migrations.yaml merge=yaml-migrations
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# execute this in your shell to enable custom merge driver:
|
||||
# echo '\n[include]\npath=../.gitconfig' >> .git/config
|
||||
[merge "yaml-migrations"]
|
||||
name = YAML migration changeset-aware merge
|
||||
name = Per-release YAML migration changeset-aware merge
|
||||
driver = bin/mage -merge-yaml-migrations %O %A %B %L %P
|
||||
|
||||
268
.github/CODEOWNERS
vendored
@@ -1,266 +1,30 @@
|
||||
# Before changing this file, make sure you have read how we use it:
|
||||
# https://www.notion.so/metabase/Pull-Request-Code-Review-Guide-374f05df889748b69d67af753f9bc9b8?pvs=4#36550c04ceab40b5a8b8bee6264b090c
|
||||
|
||||
# @metabase/core-backend-querying-platform
|
||||
src/metabase/lib @metabase/core-backend-querying-platform
|
||||
src/metabase/lib_be @metabase/core-backend-querying-platform
|
||||
src/metabase/query_processor @metabase/core-backend-querying-platform
|
||||
|
||||
# @metabase/graphy
|
||||
src/metabase/graph @metabase/graphy
|
||||
enterprise/backend/src/metabase_enterprise/dependencies @metabase/graphy
|
||||
enterprise/frontend/src/metabase-enterprise/dependencies @metabase/graphy
|
||||
|
||||
# @metabase/embedding
|
||||
enterprise/frontend/src/embedding-sdk-package @metabase/embedding
|
||||
enterprise/frontend/src/embedding-sdk-bundle @metabase/embedding
|
||||
enterprise/frontend/src/embedding-sdk-shared @metabase/embedding
|
||||
frontend/src/metabase/embedding-sdk @metabase/embedding
|
||||
frontend/src/metabase/public/containers/PublicOrEmbeddedQuestion @metabase/embedding
|
||||
frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard @metabase/embedding
|
||||
frontend/src/metabase/ui @metabase/core-frontend-ux-west
|
||||
# snowplow/* @metabase/data
|
||||
src/metabase/analytics/prometheus.clj @metabase/cloud-ops
|
||||
.github @metabase/devexp
|
||||
.clj-kondo @metabase/devexp
|
||||
deps.edn @metabase/backend-developers
|
||||
|
||||
# @metabase/tech-writers
|
||||
docs @metabase/tech-writers
|
||||
docs/developers-guide # exclude from tech-writer ownership
|
||||
|
||||
#
|
||||
# Backend modules <=> teams that own them
|
||||
#
|
||||
# @metabase/devexp
|
||||
.github @metabase/devexp
|
||||
|
||||
# modules/drivers @metabase/drivers
|
||||
src/metabase/driver/sql_jdbc/actions.clj @metabase/core-ux-east
|
||||
test/metabase/driver/sql_jdbc/actions_test.clj @metabase/core-ux-east
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/advanced_config @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/advanced_config @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/advanced_permissions @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/advanced_permissions @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/airgap @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/airgap @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/api @metabase/devexp
|
||||
# enterprise/backend/test/metabase_enterprise/api @metabase/devexp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/audit_app @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/audit_app @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/auth_provider @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/auth_provider @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/billing @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/billing @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/cache @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/cache @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/content_verification @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/content_verification @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/core @metabase/devexp
|
||||
# enterprise/backend/test/metabase_enterprise/core @metabase/devexp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/dashboard_subscription_filters @metabase/core-ux-east
|
||||
# enterprise/backend/test/metabase_enterprise/dashboard_subscription_filters @metabase/core-ux-east
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/enhancements @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/enhancements @metabase/core-backend-admin-webapp
|
||||
|
||||
# # enterprise/backend/src/metabase_enterprise/llm
|
||||
# # enterprise/backend/test/metabase_enterprise/llm
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/query_reference_validation @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/query_reference_validation @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/sandbox @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/sandbox @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/scim @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/scim @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/search ...
|
||||
# enterprise/backend/test/metabase_enterprise/search ...
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/serialization @metabase/core-ux-east
|
||||
# enterprise/backend/test/metabase_enterprise/serialization @metabase/core-ux-east
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/snippet_collections @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/snippet_collections @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/sso @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/sso @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/stale @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/stale @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/stats @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/stats @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/task @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/task @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/upload_management @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/upload_management @metabase/core-backend-admin-webapp
|
||||
|
||||
src/metabase/actions @metabase/core-ux-east
|
||||
test/metabase/actions @metabase/core-ux-east
|
||||
|
||||
# src/metabase/activity_feed @metabase/core-backend-admin-webapp
|
||||
# test/metabase/activity_feed @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/analytics @metabase/core-backend-admin-webapp
|
||||
# test/metabase/analytics @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/analyze @metabase/drivers
|
||||
# test/metabase/analyze @metabase/drivers
|
||||
|
||||
# src/metabase/api @metabase/devexp
|
||||
# test/metabase/api @metabase/devexp
|
||||
|
||||
# src/metabase/audit @metabase/core-backend-admin-webapp
|
||||
# test/metabase/audit @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/auth_provider @metabase/core-backend-admin-webapp
|
||||
# test/metabase/auth_provider @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/bookmarks @metabase/core-backend-admin-webapp
|
||||
# test/metabase/bookmarks @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/cmd @metabase/devexp
|
||||
# test/metabase/cmd @metabase/devexp
|
||||
|
||||
# src/metabase/channel @metabase/core-ux-east
|
||||
# test/metabase/channel @metabase/core-ux-east
|
||||
|
||||
# src/metabase/cloud_migration ...
|
||||
# test/metabase/cloud_migration ...
|
||||
|
||||
# src/metabase/cmd @metabase/devexp
|
||||
# test/metabase/cmd @metabase/devexp
|
||||
|
||||
# src/metabase/config @metabase/devexp
|
||||
# test/metabase/config @metabase/devexp
|
||||
|
||||
# src/metabase/core @metabase/devexp
|
||||
# test/metabase/core @metabase/devexp
|
||||
|
||||
# src/metabase/db @metabase/core-backend-admin-webapp
|
||||
# test/metabase/db @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/driver @metabase/drivers
|
||||
# test/metabase/driver @metabase/drivers
|
||||
|
||||
# src/metabase/eid_translation @metabase/core-backend-admin-webapp
|
||||
# test/metabase/eid_translation @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/embed @metabase/embedding
|
||||
# test/metabase/embed @metabase/embedding
|
||||
|
||||
# src/metabase/events @metabase/devexp
|
||||
# test/metabase/events @metabase/devexp
|
||||
|
||||
# src/metabase/formatter @metabase/core-backend-querying
|
||||
# test/metabase/formatter @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/indexed_entities @metabase/core-backend-querying
|
||||
# test/metabase/indexed_entities @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/integrations ...
|
||||
# test/metabase/integrations ...
|
||||
|
||||
# src/metabase/legacy_mbql @metabase/core-backend-querying
|
||||
# test/metabase/legacy_mbql @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/lib @metabase/core-backend-querying
|
||||
# test/metabase/lib @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/logger @metabase/devexp
|
||||
# test/metabase/logger @metabase/devexp
|
||||
|
||||
# src/metabase/login_history @metabase/core-backend-admin-webapp
|
||||
# test/metabase/login_history @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/model_persistence @metabase/core-backend-querying
|
||||
# test/metabase/model_persistence @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/models @metabase/devexp
|
||||
# test/metabase/models @metabase/devexp
|
||||
|
||||
# src/metabase/moderation @metabase/core-backend-admin-webapp
|
||||
# test/metabase/moderation @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/notification @metabase/core-ux-east
|
||||
# test/metabase/notification @metabase/core-ux-east
|
||||
|
||||
# src/metabase/permissions @metabase/core-backend-admin-webapp
|
||||
# test/metabase/permissions @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/plugins @metabase/devexp
|
||||
# test/metabase/plugins @metabase/devexp
|
||||
|
||||
# src/metabase/premium_features @metabase/core-backend-admin-webapp
|
||||
# test/metabase/premium_features @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/public_settings @metabase/core-backend-admin-webapp
|
||||
# test/metabase/public_settings @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/public_sharing @metabase/core-backend-admin-webapp
|
||||
# test/metabase/public_sharing @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/pulse @metabase/core-ux-east
|
||||
# test/metabase/pulse @metabase/core-ux-east
|
||||
|
||||
# src/metabase/query_analysis @metabase/core-backend-querying
|
||||
# test/metabase/query_analysis @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/query_processor @metabase/core-backend-querying
|
||||
# test/metabase/query_processor @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/request @metabase/devexp
|
||||
# test/metabase/request @metabase/devexp
|
||||
|
||||
# src/metabase/revisions @metabase/core-backend-admin-webapp
|
||||
# test/metabase/revisions @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/sample_data @metabase/core-backend-admin-webapp
|
||||
# test/metabase/sample_data @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/search ...
|
||||
# test/metabase/search ...
|
||||
|
||||
# src/metabase/segments @metabase/core-backend-querying
|
||||
# test/metabase/segments @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/server @metabase/devexp
|
||||
# test/metabase/server @metabase/devexp
|
||||
|
||||
# src/metabase/session @metabase/core-backend-admin-webapp
|
||||
# test/metabase/session @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/setup @metabase/core-backend-admin-webapp
|
||||
# test/metabase/setup @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/sso @metabase/core-backend-admin-webapp
|
||||
# test/metabase/sso @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/sync @metabase/drivers
|
||||
# test/metabase/sync @metabase/drivers
|
||||
|
||||
# src/metabase/task @metabase/devexp
|
||||
# test/metabase/task @metabase/devexp
|
||||
|
||||
# src/metabase/tiles @metabase/core-backend-dashviz
|
||||
# test/metabase/tiles @metabase/core-backend-dashviz
|
||||
|
||||
# src/metabase/timeline @metabase/core-backend-dashviz
|
||||
# test/metabase/timeline @metabase/core-backend-dashviz
|
||||
|
||||
# src/metabase/types @metabase/core-backend-querying
|
||||
# test/metabase/types @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/upload @metabase/core-backend-admin-webapp
|
||||
# test/metabase/upload @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/user_key_value @metabase/core-backend-admin-webapp
|
||||
# test/metabase/user_key_value @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/util @metabase/devexp
|
||||
# test/metabase/util @metabase/devexp
|
||||
|
||||
# src/metabase/xrays @metabase/core-backend-querying
|
||||
# test/metabase/xrays @metabase/core-backend-querying
|
||||
# @metabase/cloud-ops
|
||||
src/metabase/analytics/prometheus.clj @metabase/cloud-ops
|
||||
|
||||
28
.github/actions/fetch-artifact/action.yml
vendored
@@ -45,17 +45,31 @@ runs:
|
||||
});
|
||||
|
||||
if (!artifacts.data?.artifacts?.[0]?.id) {
|
||||
throw new Error(`No artifacts found for ${artifactName}`);
|
||||
core.setFailed(`No artifacts found for ${artifactName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact_id = artifacts.data.artifacts[0].id;
|
||||
|
||||
const download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifact_id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
let download;
|
||||
const maxAttempts = 3;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifact_id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
break;
|
||||
} catch (err) {
|
||||
console.log(`Attempt ${attempt}/${maxAttempts} failed: ${err.message}`);
|
||||
if (attempt === maxAttempts) throw err;
|
||||
const delay = attempt * 15000; // 15s, 30s
|
||||
console.log(`Retrying in ${delay / 1000}s...`);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/mb.zip`, Buffer.from(download.data));
|
||||
|
||||
|
||||
8
.github/actions/get-cache-keys/action.yml
vendored
@@ -7,9 +7,15 @@ outputs:
|
||||
m2-cache-key:
|
||||
description: "The cache key for the Maven cache."
|
||||
value: ${{ steps.keys-factory.outputs.m2-cache-key }}
|
||||
m2-restore-key:
|
||||
description: "The fallback restore key prefix for the Maven cache."
|
||||
value: ${{ steps.keys-factory.outputs.m2-restore-key }}
|
||||
node-cache-key:
|
||||
description: "The cache key for the Node.js cache."
|
||||
value: ${{ steps.keys-factory.outputs.node-cache-key }}
|
||||
node-restore-key:
|
||||
description: "The fallback restore key prefix for the Node.js cache."
|
||||
value: ${{ steps.keys-factory.outputs.node-restore-key }}
|
||||
eslint-cache-key:
|
||||
description: "The cache key for Eslint cache"
|
||||
value: ${{ steps.keys-factory.outputs.eslint-cache-key }}
|
||||
@@ -31,7 +37,9 @@ runs:
|
||||
CACHE_SUFFIX="${{ runner.os }}-$WEEK_OF_YEAR"
|
||||
|
||||
echo "m2-cache-key=$M2_CACHE_PREFIX-$CACHE_SUFFIX" >> $GITHUB_OUTPUT
|
||||
echo "m2-restore-key=$M2_CACHE_PREFIX-" >> $GITHUB_OUTPUT
|
||||
echo "node-cache-key=$NODE_CACHE_PREFIX-$CACHE_SUFFIX" >> $GITHUB_OUTPUT
|
||||
echo "node-restore-key=$NODE_CACHE_PREFIX-" >> $GITHUB_OUTPUT
|
||||
echo "eslint-cache-key=$ESLINT_CACHE_PREFIX-$CACHE_SUFFIX" >> $GITHUB_OUTPUT
|
||||
echo "cypress-cache-key=$CYPRESS_CACHE_PREFIX-$CACHE_SUFFIX" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
|
||||
27
.github/actions/prepare-backend/action.yml
vendored
@@ -1,14 +1,13 @@
|
||||
name: Prepare back-end environment
|
||||
description: |
|
||||
Prepare the backend. Installs jdk, Clojure, and fetches the cache for m2.
|
||||
The CI config fetches `ci-test-config.json` from master and lets us ignore tests at the var level if they are flaking.
|
||||
inputs:
|
||||
java-version:
|
||||
required: true
|
||||
default: '21'
|
||||
default: "21"
|
||||
clojure-version:
|
||||
required: true
|
||||
default: '1.12.0.1488'
|
||||
default: "1.12.0.1488"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -17,13 +16,21 @@ runs:
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: ${{ inputs.java-version }}
|
||||
distribution: 'temurin'
|
||||
distribution: "temurin"
|
||||
- name: Install Clojure CLI
|
||||
shell: bash
|
||||
continue-on-error: true
|
||||
run: |
|
||||
curl -O https://download.clojure.org/install/linux-install-${{ inputs.clojure-version }}.sh &&
|
||||
sudo bash ./linux-install-${{ inputs.clojure-version }}.sh
|
||||
for attempt in 1 2 3; do
|
||||
curl --retry 3 --retry-delay 5 --fail -O https://download.clojure.org/install/linux-install-${{ inputs.clojure-version }}.sh \
|
||||
&& sudo bash ./linux-install-${{ inputs.clojure-version }}.sh \
|
||||
&& break
|
||||
echo "::warning::Clojure install attempt $attempt failed, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
if ! clojure --version; then
|
||||
echo "::error::Clojure CLI installation failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Get M2 cache key
|
||||
uses: ./.github/actions/get-cache-keys
|
||||
@@ -31,6 +38,10 @@ runs:
|
||||
|
||||
- name: Install uv (for Python package installation)
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: false
|
||||
version: "0.10.2" # keep in sync with mise.toml
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Restore M2 cache
|
||||
uses: actions/cache/restore@v4
|
||||
@@ -39,3 +50,5 @@ runs:
|
||||
~/.m2
|
||||
~/.gitlibs
|
||||
key: ${{ steps.get-cache-keys.outputs.m2-cache-key }}
|
||||
restore-keys: |
|
||||
${{ steps.get-cache-keys.outputs.m2-restore-key }}
|
||||
|
||||
5
.github/actions/prepare-cypress/action.yml
vendored
@@ -15,11 +15,10 @@ outputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Chrome v111
|
||||
- name: Install Chrome v144
|
||||
uses: browser-actions/setup-chrome@v2
|
||||
with:
|
||||
# https://chromium.cypress.io/linux/stable/111.0.5563.146
|
||||
chrome-version: 1097615
|
||||
chrome-version: 144
|
||||
id: setup-chrome
|
||||
|
||||
- name: Get cache keys
|
||||
|
||||
17
.github/actions/prepare-frontend/action.yml
vendored
@@ -11,17 +11,10 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Prepare Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Prepare Node + Bun environment
|
||||
uses: ./.github/actions/prepare-node-bun
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }} # this overrides, if present
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.7"
|
||||
token: ${{ github.token }}
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Get cache keys
|
||||
uses: ./.github/actions/get-cache-keys
|
||||
@@ -34,6 +27,8 @@ runs:
|
||||
~/.m2
|
||||
~/.gitlibs
|
||||
key: ${{ steps.get-cache-keys.outputs.m2-cache-key }}
|
||||
restore-keys: |
|
||||
${{ steps.get-cache-keys.outputs.m2-restore-key }}
|
||||
|
||||
- name: Get bun cache directory
|
||||
id: bun-cache
|
||||
@@ -51,6 +46,8 @@ runs:
|
||||
${{ steps.bun-cache.outputs.dir }}
|
||||
node_modules
|
||||
key: ${{ steps.get-cache-keys.outputs.node-cache-key }}
|
||||
restore-keys: |
|
||||
${{ steps.get-cache-keys.outputs.node-restore-key }}
|
||||
|
||||
# postinstall lifecycle hook can be dangerous in CI so we only run it locally (see package.json)
|
||||
# If a job wants to patch packages, it does so explicitly in the CI. This setting is opt-out.
|
||||
|
||||
20
.github/actions/prepare-node-bun/action.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Prepare Node + Bun environment
|
||||
description: "Set up Node.js and Bun for CI jobs"
|
||||
inputs:
|
||||
node-version:
|
||||
description: "The version of Node.js to use, overriding .nvmrc"
|
||||
required: false
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Prepare Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.7"
|
||||
token: ${{ github.token }}
|
||||
110
.github/actions/sdk-bundle-size-check/action.yml
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
name: Embedding SDK Bundle Size Check
|
||||
description: |
|
||||
Downloads the current branch's uberjar and builds the base ref's
|
||||
embedding-sdk.js from source, compares their sizes,
|
||||
and sets output if the size change exceeds the threshold.
|
||||
|
||||
inputs:
|
||||
edition:
|
||||
description: 'Metabase edition (ee or oss)'
|
||||
required: true
|
||||
base-ref-commit:
|
||||
description: 'Commit SHA of the base ref (e.g. merge base).'
|
||||
required: true
|
||||
threshold-percent:
|
||||
description: 'Percent threshold for bundle size change'
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
status:
|
||||
description: "Bundle size status: increased, decreased, or stable"
|
||||
value: ${{ steps.compare.outputs.status }}
|
||||
size_change_percent:
|
||||
description: "size change percent (positive = increase, negative = decrease)"
|
||||
value: ${{ steps.compare.outputs.size_change_percent }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Prepare backend
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Prepare frontend
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Download current uberjar
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: metabase-${{ inputs.edition }}-${{ github.event.pull_request.head.sha || github.sha }}-uberjar
|
||||
path: temp/current-uberjar
|
||||
- name: Extract embedding-sdk bundles from current uberjar
|
||||
shell: bash
|
||||
run: |
|
||||
# Try new layout first (app/embedding-sdk/legacy/), fall back to old layout (app/embedding-sdk.js)
|
||||
unzip -j temp/current-uberjar/target/uberjar/metabase.jar \
|
||||
"frontend_client/app/embedding-sdk/legacy/embedding-sdk.js" \
|
||||
-d temp/current-bundle 2>/dev/null || \
|
||||
unzip -j temp/current-uberjar/target/uberjar/metabase.jar \
|
||||
"frontend_client/app/embedding-sdk.js" \
|
||||
-d temp/current-bundle
|
||||
|
||||
# Extract chunks directory if it exists (new layout only)
|
||||
mkdir -p temp/current-chunks
|
||||
unzip -j temp/current-uberjar/target/uberjar/metabase.jar \
|
||||
"frontend_client/app/embedding-sdk/chunks/*" \
|
||||
-d temp/current-chunks 2>/dev/null || true
|
||||
- name: Checkout base ref
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.base-ref-commit }}
|
||||
path: temp/base-ref-src
|
||||
- name: Build base-ref embedding-sdk.js
|
||||
shell: bash
|
||||
run: |
|
||||
cd temp/base-ref-src
|
||||
bun install
|
||||
MB_EDITION=${{ inputs.edition }} bun run build-release
|
||||
- name: Compare sizes and set output
|
||||
id: compare
|
||||
shell: bash
|
||||
run: |
|
||||
# --- Legacy (monolithic) bundle comparison ---
|
||||
current_size=$(wc -c < temp/current-bundle/embedding-sdk.js) || { echo "::error::Failed to read current bundle"; exit 1; }
|
||||
|
||||
# Base ref: try new layout first, fall back to old
|
||||
if [ -f "temp/base-ref-src/resources/frontend_client/app/embedding-sdk/legacy/embedding-sdk.js" ]; then
|
||||
base_ref_size=$(wc -c < "temp/base-ref-src/resources/frontend_client/app/embedding-sdk/legacy/embedding-sdk.js")
|
||||
else
|
||||
base_ref_size=$(wc -c < "temp/base-ref-src/resources/frontend_client/app/embedding-sdk.js")
|
||||
fi
|
||||
[ "$current_size" -gt 0 ] && [ "$base_ref_size" -gt 0 ] || { echo "::error::Bundle file is empty"; exit 1; }
|
||||
|
||||
diff=$((current_size - base_ref_size))
|
||||
percent=$(( diff * 100 / base_ref_size ))
|
||||
|
||||
echo "=== Legacy (monolithic) bundle ==="
|
||||
echo "Current size: $current_size bytes"
|
||||
echo "Base ref size: $base_ref_size bytes"
|
||||
echo "Difference: $diff bytes ($percent%)"
|
||||
|
||||
threshold="${{ inputs.threshold-percent }}"
|
||||
if [ "$percent" -gt "$threshold" ]; then
|
||||
status="increased"
|
||||
elif [ "$percent" -lt "$(( -threshold ))" ]; then
|
||||
status="decreased"
|
||||
else
|
||||
status="stable"
|
||||
fi
|
||||
|
||||
echo "status=$status" >> $GITHUB_OUTPUT
|
||||
echo "size_change_percent=$percent" >> $GITHUB_OUTPUT
|
||||
|
||||
# --- Chunked bundle (informational) ---
|
||||
if ls temp/current-chunks/*.js 1>/dev/null 2>&1; then
|
||||
chunks_size=$(cat temp/current-chunks/*.js | wc -c)
|
||||
echo ""
|
||||
echo "=== Chunked bundle (bootstrap + chunks) ==="
|
||||
echo "Total chunked size: $chunks_size bytes"
|
||||
echo "chunks_size=$chunks_size" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo ""
|
||||
echo "No chunked bundle found (expected on master)"
|
||||
fi
|
||||
75
.github/copilot-instructions.md
vendored
@@ -1,52 +1,55 @@
|
||||
# Metabase Developer Assistant Instructions
|
||||
|
||||
## Frontend Development
|
||||
For detailed coding standards and conventions, see `CLAUDE.md` and the skills in `.claude/skills/`.
|
||||
|
||||
### Repository Structure
|
||||
## Code Review Standards
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── metabase/ # Main application components and pages
|
||||
├── metabase-lib/ # Query building and data modeling utilities
|
||||
├── metabase-types/ # TypeScript type definitions
|
||||
└── metabase/ui/ # Design system components
|
||||
Review focus areas: Security → Performance → Testing → Documentation (in priority order)
|
||||
|
||||
e2e/ # Cypress end-to-end tests
|
||||
frontend/test/ # Jest unit tests
|
||||
```
|
||||
When reviewing code, use these emoji prefixes to categorize feedback:
|
||||
- 🔒 Security concerns
|
||||
- ⚡ Performance opportunities
|
||||
- 🧹 Cleanup needs
|
||||
- 📚 Documentation gaps
|
||||
- 🚨 Blocking issues
|
||||
- 💭 Clarification questions
|
||||
|
||||
### Technology Stack
|
||||
### Security Critical Issues
|
||||
|
||||
- **Framework**: React 18 with TypeScript
|
||||
- **State**: Redux Toolkit
|
||||
- **Styling**: CSS Modules (preferred) > Emotion styled-components
|
||||
- **UI**: `metabase/ui` components built with Mantine v8
|
||||
- **Build**: Rspack (primary), Webpack (legacy)
|
||||
- **Testing**: Jest + React Testing Library, Cypress
|
||||
- Check for hardcoded secrets, API keys, or credentials
|
||||
- Look for SQL injection and XSS vulnerabilities
|
||||
- Verify proper input validation and sanitization
|
||||
- Review authentication and authorization logic
|
||||
- Ensure enterprise features use the plugin system (no enterprise code in OSS)
|
||||
|
||||
### Coding Standards
|
||||
### Performance Red Flags
|
||||
|
||||
#### Component Preferences
|
||||
- Identify N+1 database query problems
|
||||
- Spot inefficient loops and algorithmic issues
|
||||
- Check for memory leaks and resource cleanup
|
||||
- Review caching opportunities for expensive operations
|
||||
|
||||
- Prefer `metabase/ui`components over `metabase/common/components`
|
||||
- Use `.tsx` for components, `.ts` for utilities
|
||||
### Code Quality Essentials
|
||||
|
||||
#### Styling
|
||||
- Functions should be focused and appropriately sized
|
||||
- Use clear, descriptive naming conventions
|
||||
- Ensure proper error handling throughout
|
||||
- Remove dead code and unused imports
|
||||
|
||||
ALWAYS prefer Mantine style props,then CSS modules. DO NOT suggest styled components, they are deprecated.
|
||||
### Review Style
|
||||
|
||||
#### TypeScript Migration
|
||||
- Only flag actual issues worth mentioning
|
||||
- Do not post "looks good" comments or congratulate following conventions
|
||||
- Be specific and actionable in feedback
|
||||
- Explain the "why" behind recommendations
|
||||
- Ask clarifying questions when code intent is unclear
|
||||
|
||||
When heavily editing `.js`/`.jsx` files, create a separate PR to convert to TypeScript first, then implement changes.
|
||||
### Review Format
|
||||
|
||||
#### Enterprise Features
|
||||
- Condense feedback into as few comments as possible
|
||||
- Prefer a single summary comment over dozens of inline comments
|
||||
- Only use inline comments for critical issues that require immediate attention
|
||||
- For non-critical issues, link to the specific line from the summary instead
|
||||
- Delete all stale comments and summaries on each new push or CI run
|
||||
|
||||
Enterprise functionality MUST use the plugin system. It is very important to not expose enterprise code in the OSS version.
|
||||
|
||||
#### Testing Requirements
|
||||
|
||||
All PRs should include tests. Prefer Unit tests over E2E tests.
|
||||
|
||||
#### Localization
|
||||
|
||||
All user-facing strings MUST be localized using the ttag library. Localized strings should be complete phrases, do not concatenate a few separately localized strings. You should add context to strings where the meaning of the string might not be obvious in isolation: e.g. "Home" might have different words in some languages depending on whether you're talking about a dwelling or the landing page for a website.
|
||||
Language-specific rules are in `.github/instructions/*.instructions.md`.
|
||||
|
||||
14
.github/file-paths.yaml
vendored
@@ -30,7 +30,7 @@ frontend_configs: &frontend_configs
|
||||
- "**/tsconfig*.json"
|
||||
- "package.json"
|
||||
- "babel.config.json"
|
||||
- ".eslintrc.js"
|
||||
- "eslint.config.mjs"
|
||||
- "postcss.config.js"
|
||||
- "rspack.config.js"
|
||||
- "rspack.main.config.js"
|
||||
@@ -199,3 +199,15 @@ cljs:
|
||||
- *shared_specs
|
||||
- ".github/workflows/cljs.yml"
|
||||
- "shadow-cljs.edn"
|
||||
|
||||
openapi:
|
||||
- ".github/workflows/openapi.yml"
|
||||
- "src/metabase/api/**"
|
||||
- "src/metabase/api_routes/**"
|
||||
- "src/metabase/*_rest/api.clj"
|
||||
- "src/metabase/*/api.clj"
|
||||
- "src/metabase/*/api/*.clj"
|
||||
- "enterprise/backend/src/metabase_enterprise/**/api.clj"
|
||||
- "enterprise/backend/src/metabase_enterprise/**/api/*.clj"
|
||||
- "resources/openapi/**"
|
||||
- "package.json"
|
||||
|
||||
9
.github/instructions/clojure.instructions.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
applyTo: "**/*.{clj,cljs,cljc}"
|
||||
---
|
||||
|
||||
# Clojure/ClojureScript Code Review
|
||||
|
||||
When reviewing Clojure or ClojureScript code, apply the guidelines in these files:
|
||||
- `.claude/skills/_shared/clojure-style-guide.md`
|
||||
- `.claude/skills/clojure-review/SKILL.md`
|
||||
9
.github/instructions/docs.instructions.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
applyTo: "docs/**/*.md"
|
||||
---
|
||||
|
||||
# Documentation Review
|
||||
|
||||
When reviewing documentation, apply the guidelines in these files:
|
||||
- `.claude/skills/_shared/metabase-style-guide.md`
|
||||
- `.claude/skills/docs-review/SKILL.md`
|
||||
11
.github/instructions/typescript.instructions.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
applyTo: "**/*.{ts,tsx,js,jsx}"
|
||||
---
|
||||
|
||||
# TypeScript/JavaScript Code Review
|
||||
|
||||
When reviewing TypeScript or JavaScript code, apply the guidelines in these files:
|
||||
|
||||
- `.claude/skills/typescript-review/SKILL.md`
|
||||
- `docs/developers-guide/frontend.md`
|
||||
- `frontend/CLAUDE.md`
|
||||
52
.github/scripts/build-cross-version-matrix.js
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
// This script is used in .github/workflows/cross-version.yml
|
||||
// to generate a matrix for cross-version migration testing
|
||||
|
||||
/**
|
||||
* Generate a CI matrix for cross-version migration testing (EE only).
|
||||
*
|
||||
* Creates pairs of [source, target] for testing HEAD against the last N
|
||||
* minor versions in both upgrade and downgrade directions.
|
||||
*
|
||||
* @param {number} currentVersion - The current major version number (e.g., 59)
|
||||
* @param {number} count - Number of previous minor versions to test against (default: 3)
|
||||
* @returns {{ config: Array<{ source: string, target: string }> }}
|
||||
*
|
||||
* @example
|
||||
* buildMatrix(59)
|
||||
* // Returns:
|
||||
* // {
|
||||
* // config: [
|
||||
* // { source: "HEAD", target: "v1.59.x" },
|
||||
* // { source: "v1.59.x", target: "HEAD" },
|
||||
* // { source: "HEAD", target: "v1.58.x" },
|
||||
* // { source: "v1.58.x", target: "HEAD" },
|
||||
* // { source: "HEAD", target: "v1.57.x" },
|
||||
* // { source: "v1.57.x", target: "HEAD" },
|
||||
* // ]
|
||||
* // }
|
||||
*/
|
||||
function buildCrossVersionMatrix(currentVersion, count = 3) {
|
||||
if (!Number.isInteger(currentVersion) || currentVersion < 1) {
|
||||
throw new Error(
|
||||
`Invalid currentVersion: ${currentVersion}. Must be a positive integer.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(count) || count < 1) {
|
||||
throw new Error(`Invalid count: ${count}. Must be a positive integer.`);
|
||||
}
|
||||
|
||||
const config = Array.from({ length: count }, (_, i) => currentVersion - i)
|
||||
.filter((majorVersion) => majorVersion >= 1)
|
||||
.flatMap((majorVersion) => {
|
||||
const version = `v1.${majorVersion}.x`;
|
||||
return [
|
||||
{ source: "HEAD", target: version },
|
||||
{ source: version, target: "HEAD" },
|
||||
];
|
||||
});
|
||||
|
||||
return { config };
|
||||
}
|
||||
|
||||
module.exports = { buildCrossVersionMatrix };
|
||||
242
.github/scripts/build-cross-version-matrix.unit.spec.js
vendored
Normal file
@@ -0,0 +1,242 @@
|
||||
const { buildCrossVersionMatrix } = require("./build-cross-version-matrix");
|
||||
|
||||
describe("buildCrossVersionMatrix", () => {
|
||||
describe("default behavior", () => {
|
||||
it("should generate matrix with 3 versions by default", () => {
|
||||
const result = buildCrossVersionMatrix(59);
|
||||
expect(result).toEqual({
|
||||
config: [
|
||||
{ source: "HEAD", target: "v1.59.x" },
|
||||
{ source: "v1.59.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.58.x" },
|
||||
{ source: "v1.58.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.57.x" },
|
||||
{ source: "v1.57.x", target: "HEAD" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should use EE prefix (v1)", () => {
|
||||
const result = buildCrossVersionMatrix(50);
|
||||
expect(result.config[0].target).toBe("v1.50.x");
|
||||
expect(result.config[1].source).toBe("v1.50.x");
|
||||
});
|
||||
|
||||
it("should create pairs in both directions (upgrade and downgrade)", () => {
|
||||
const result = buildCrossVersionMatrix(59);
|
||||
const entries = result.config;
|
||||
|
||||
// Check HEAD -> version (upgrade test)
|
||||
expect(entries.filter((e) => e.source === "HEAD")).toHaveLength(3);
|
||||
// Check version -> HEAD (downgrade test)
|
||||
expect(entries.filter((e) => e.target === "HEAD")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom count", () => {
|
||||
it("should generate matrix with custom count", () => {
|
||||
const result = buildCrossVersionMatrix(59, 5);
|
||||
expect(result.config).toHaveLength(10); // 5 versions × 2 directions
|
||||
expect(result.config).toEqual([
|
||||
{ source: "HEAD", target: "v1.59.x" },
|
||||
{ source: "v1.59.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.58.x" },
|
||||
{ source: "v1.58.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.57.x" },
|
||||
{ source: "v1.57.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.56.x" },
|
||||
{ source: "v1.56.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.55.x" },
|
||||
{ source: "v1.55.x", target: "HEAD" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should generate matrix with count=1", () => {
|
||||
const result = buildCrossVersionMatrix(59, 1);
|
||||
expect(result.config).toHaveLength(2);
|
||||
expect(result.config).toEqual([
|
||||
{ source: "HEAD", target: "v1.59.x" },
|
||||
{ source: "v1.59.x", target: "HEAD" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should generate matrix with large count", () => {
|
||||
const result = buildCrossVersionMatrix(59, 10);
|
||||
expect(result.config).toHaveLength(20);
|
||||
|
||||
// Check first and last entries
|
||||
expect(result.config[0]).toEqual({
|
||||
source: "HEAD",
|
||||
target: "v1.59.x",
|
||||
});
|
||||
expect(result.config[18]).toEqual({
|
||||
source: "HEAD",
|
||||
target: "v1.50.x",
|
||||
});
|
||||
expect(result.config[19]).toEqual({
|
||||
source: "v1.50.x",
|
||||
target: "HEAD",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle version 1", () => {
|
||||
const result = buildCrossVersionMatrix(1);
|
||||
expect(result).toEqual({
|
||||
config: [
|
||||
{ source: "HEAD", target: "v1.1.x" },
|
||||
{ source: "v1.1.x", target: "HEAD" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should stop when version would go below 1", () => {
|
||||
const result = buildCrossVersionMatrix(2, 5);
|
||||
// Can only generate v1.2.x and v1.1.x (2 versions, not 5)
|
||||
expect(result.config).toHaveLength(4);
|
||||
expect(result).toEqual({
|
||||
config: [
|
||||
{ source: "HEAD", target: "v1.2.x" },
|
||||
{ source: "v1.2.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.1.x" },
|
||||
{ source: "v1.1.x", target: "HEAD" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle high version numbers", () => {
|
||||
const result = buildCrossVersionMatrix(100, 2);
|
||||
expect(result).toEqual({
|
||||
config: [
|
||||
{ source: "HEAD", target: "v1.100.x" },
|
||||
{ source: "v1.100.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.99.x" },
|
||||
{ source: "v1.99.x", target: "HEAD" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should throw for undefined currentVersion", () => {
|
||||
expect(() => buildCrossVersionMatrix()).toThrow(
|
||||
"Invalid currentVersion: undefined. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for non-integer currentVersion", () => {
|
||||
expect(() => buildCrossVersionMatrix(59.5)).toThrow(
|
||||
"Invalid currentVersion: 59.5. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for zero currentVersion", () => {
|
||||
expect(() => buildCrossVersionMatrix(0)).toThrow(
|
||||
"Invalid currentVersion: 0. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for negative currentVersion", () => {
|
||||
expect(() => buildCrossVersionMatrix(-1)).toThrow(
|
||||
"Invalid currentVersion: -1. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for NaN currentVersion", () => {
|
||||
expect(() => buildCrossVersionMatrix(NaN)).toThrow(
|
||||
"Invalid currentVersion: NaN. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for non-integer count", () => {
|
||||
expect(() => buildCrossVersionMatrix(59, 2.5)).toThrow(
|
||||
"Invalid count: 2.5. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for zero count", () => {
|
||||
expect(() => buildCrossVersionMatrix(59, 0)).toThrow(
|
||||
"Invalid count: 0. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for negative count", () => {
|
||||
expect(() => buildCrossVersionMatrix(59, -1)).toThrow(
|
||||
"Invalid count: -1. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix structure for GitHub Actions", () => {
|
||||
it("should return object with config array", () => {
|
||||
const result = buildCrossVersionMatrix(59);
|
||||
expect(result).toHaveProperty("config");
|
||||
expect(Array.isArray(result.config)).toBe(true);
|
||||
});
|
||||
|
||||
it("should produce valid JSON for GitHub Actions", () => {
|
||||
const result = buildCrossVersionMatrix(59);
|
||||
const json = JSON.stringify(result);
|
||||
expect(() => JSON.parse(json)).not.toThrow();
|
||||
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.config).toHaveLength(6);
|
||||
expect(parsed.config[0]).toEqual({
|
||||
source: "HEAD",
|
||||
target: "v1.59.x",
|
||||
});
|
||||
});
|
||||
|
||||
it("should have consistent entry structure", () => {
|
||||
const result = buildCrossVersionMatrix(59, 5);
|
||||
|
||||
result.config.forEach((entry) => {
|
||||
expect(entry).toHaveProperty("source");
|
||||
expect(entry).toHaveProperty("target");
|
||||
expect(typeof entry.source).toBe("string");
|
||||
expect(typeof entry.target).toBe("string");
|
||||
// Either source or target should be HEAD
|
||||
expect(entry.source === "HEAD" || entry.target === "HEAD").toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("version ordering", () => {
|
||||
it("should generate versions in descending order", () => {
|
||||
const result = buildCrossVersionMatrix(59, 5);
|
||||
const versions = result.config
|
||||
.filter((e) => e.source === "HEAD")
|
||||
.map((e) => e.target);
|
||||
|
||||
expect(versions).toEqual([
|
||||
"v1.59.x",
|
||||
"v1.58.x",
|
||||
"v1.57.x",
|
||||
"v1.56.x",
|
||||
"v1.55.x",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should interleave upgrade and downgrade tests", () => {
|
||||
const result = buildCrossVersionMatrix(59, 2);
|
||||
|
||||
expect(result.config[0]).toEqual({
|
||||
source: "HEAD",
|
||||
target: "v1.59.x",
|
||||
});
|
||||
expect(result.config[1]).toEqual({
|
||||
source: "v1.59.x",
|
||||
target: "HEAD",
|
||||
});
|
||||
expect(result.config[2]).toEqual({
|
||||
source: "HEAD",
|
||||
target: "v1.58.x",
|
||||
});
|
||||
expect(result.config[3]).toEqual({
|
||||
source: "v1.58.x",
|
||||
target: "HEAD",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
2
.github/scripts/setup-python-runner.sh
vendored
@@ -19,7 +19,7 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
echo "Cloning python-runner repository..."
|
||||
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
|
||||
git clone --depth 1 -b extensible-resource-schema https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
|
||||
|
||||
if [ "$SKIP_START" = false ]; then
|
||||
echo "Starting python-runner..."
|
||||
|
||||
20
.github/team.json
vendored
@@ -4,14 +4,23 @@
|
||||
"name": "Querying",
|
||||
"label": ".Team/Querying",
|
||||
"members": [
|
||||
"ranquild",
|
||||
"romeovs",
|
||||
"bshepherdson",
|
||||
"metamben",
|
||||
"wzimrin",
|
||||
"rileythomp",
|
||||
"camsual"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Graphy",
|
||||
"label": ".Team/Graphy",
|
||||
"members": [
|
||||
"ranquild",
|
||||
"romeovs",
|
||||
"ericnormand",
|
||||
"galdre",
|
||||
"rileythomp"
|
||||
"dpsutton",
|
||||
"escherize",
|
||||
"bgrabow"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -36,9 +45,6 @@
|
||||
"name": "DevEx",
|
||||
"label": ".Team/DevEx",
|
||||
"members": [
|
||||
"camsaul",
|
||||
"dpsutton",
|
||||
"escherize",
|
||||
"fraserdrops",
|
||||
"nemanjaglumac"
|
||||
]
|
||||
|
||||
20
.github/workflows/app-db.yml
vendored
@@ -20,9 +20,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: MariaDB 10.2
|
||||
junit-name: be-tests-mariadb-10-2-ee
|
||||
image: circleci/mariadb:10.2.23
|
||||
- name: MariaDB 10.6
|
||||
junit-name: be-tests-mariadb-10-6-ee
|
||||
image: circleci/mariadb:10.6
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
- name: MariaDB Latest
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
MB_MYSQL_SSL_TEST_USER: metabase
|
||||
MB_MYSQL_SSL_TEST_PASSWORD: ${{ secrets.MYSQL_RDS_SSL_INSTANCE_PASSWORD }}
|
||||
# for historic reasons (I don't want to mess around with required jobs) the job name should be something like
|
||||
# "be-tests-mariadb-10-2-ee (0)"
|
||||
# "be-tests-mariadb-10-6-ee (0)"
|
||||
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -134,9 +134,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: MySQL 8.0
|
||||
junit-name: be-tests-mysql-8-0-ee
|
||||
image: cimg/mysql:8.0
|
||||
- name: MySQL 8.4
|
||||
junit-name: be-tests-mysql-8-4-ee
|
||||
image: cimg/mysql:8.4
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_AURORA_IAM_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_AURORA_IAM_SECRET_ACCESS_KEY }}
|
||||
# for historic reasons (I don't want to mess around with required jobs) the job name should be something like
|
||||
# "be-tests-mariadb-10-2-ee (0)"
|
||||
# "be-tests-mariadb-10-6-ee (0)"
|
||||
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -258,9 +258,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: Postgres 12.x
|
||||
- name: Postgres 14.x
|
||||
junit-name: postgres-ee
|
||||
docker-image: postgres:12-alpine
|
||||
docker-image: postgres:14-alpine
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
|
||||
5
.github/workflows/backend-cloverage.yml
vendored
@@ -11,8 +11,9 @@ name: Backend-Cloverage
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 10 * * 1-5' # every weekday at 5am US eastern time
|
||||
# re-enable once we figure out the correctness of the reports in DEV-1104
|
||||
# schedule:
|
||||
# - cron: '0 10 * * 1-5' # every weekday at 5am US eastern time
|
||||
|
||||
jobs:
|
||||
setup-oss:
|
||||
|
||||
13
.github/workflows/backend.yml
vendored
@@ -36,6 +36,18 @@ jobs:
|
||||
- run: clojure -X:dev:ee:ee-dev:drivers:drivers-dev:test:eastwood
|
||||
name: Run Eastwood linter
|
||||
|
||||
be-linter-eastwood-test:
|
||||
if: ${{ !inputs.skip }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
name: Eastwood (test)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- run: clojure -X:dev:ee:ee-dev:drivers:drivers-dev:test:eastwood/test
|
||||
name: Run Eastwood linter on tests
|
||||
|
||||
be-tests:
|
||||
if: ${{ !inputs.skip }}
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -184,6 +196,7 @@ jobs:
|
||||
be-tests-result:
|
||||
needs:
|
||||
- be-linter-eastwood
|
||||
- be-linter-eastwood-test
|
||||
- be-linter-clj-kondo
|
||||
- be-cljfmt
|
||||
- be-check
|
||||
|
||||
38
.github/workflows/codespell.yml
vendored
@@ -43,27 +43,35 @@ jobs:
|
||||
app-id: ${{ secrets.METABASE_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.METABASE_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Build Claude prompt
|
||||
if: steps.codespell_check.outcome == 'failure'
|
||||
run: |
|
||||
cat > /tmp/claude_prompt.txt << 'PROMPT_HEADER'
|
||||
You are tasked with fixing spelling errors detected by codespell in this repository.
|
||||
|
||||
## Codespell Output
|
||||
The following spelling errors were detected:
|
||||
```
|
||||
PROMPT_HEADER
|
||||
cat codespell_output.txt >> /tmp/claude_prompt.txt
|
||||
cat >> /tmp/claude_prompt.txt << 'PROMPT_FOOTER'
|
||||
```
|
||||
|
||||
## Instructions
|
||||
1. **Review the codespell output and identify reported spelling errors** - The usual format codespell uses is `typo ==> correction`
|
||||
2. **Fix every spelling error** - Use the Edit tool to fix each misspelling with the suggested correction
|
||||
- If codespell suggests multiple corrections, choose the most appropriate one based on context
|
||||
|
||||
Human reviewers will verify the changes before merging, so fix all errors reported by codespell.
|
||||
PROMPT_FOOTER
|
||||
|
||||
- name: Fix spelling errors with Claude Code
|
||||
if: steps.codespell_check.outcome == 'failure'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ steps.claude-app-token.outputs.token }}
|
||||
prompt: |
|
||||
You are tasked with fixing spelling errors detected by codespell in this repository.
|
||||
|
||||
## Codespell Output
|
||||
The following spelling errors were detected:
|
||||
```
|
||||
$(cat codespell_output.txt)
|
||||
```
|
||||
|
||||
## Instructions
|
||||
1. **Review the codespell output and identify reported spelling errors** - The usual format codespell uses is `typo ==> correction`
|
||||
2. **Fix every spelling error** - Use the Edit tool to fix each misspelling with the suggested correction
|
||||
- If codespell suggests multiple corrections, choose the most appropriate one based on context
|
||||
|
||||
Human reviewers will verify the changes before merging, so fix all errors reported by codespell.
|
||||
prompt_file: /tmp/claude_prompt.txt
|
||||
claude_args: |
|
||||
--allowedTools "Write,Read,Glob,Grep,Edit,MultiEdit,Bash(cat*),Bash(ls*)"
|
||||
--disallowedTools "Bash(git commit*),Bash(git push*),Bash(gh*)"
|
||||
|
||||
12
.github/workflows/cross-branch-migrations.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
base-ref: ${{ env.BASE_REF }}
|
||||
head-ref: ${{ env.HEAD_REF }}
|
||||
|
||||
migrations-postgres-12-ee:
|
||||
migrations-postgres-14-ee:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
MB_DB_USER: circle_test
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12-alpine
|
||||
image: postgres:14-alpine
|
||||
ports:
|
||||
- "5432:5432"
|
||||
env:
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
base-ref: ${{ env.BASE_REF }}
|
||||
head-ref: ${{ env.HEAD_REF }}
|
||||
|
||||
migrations-mysql-8-0-ee:
|
||||
migrations-mysql-8-4-ee:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
MB_DB_USER: root
|
||||
services:
|
||||
mysql:
|
||||
image: cimg/mysql:8.0
|
||||
image: cimg/mysql:8.4
|
||||
ports:
|
||||
- "3306:3306"
|
||||
steps:
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
base-ref: ${{ env.BASE_REF }}
|
||||
head-ref: ${{ env.HEAD_REF }}
|
||||
|
||||
migrations-mariadb-10-2-ee:
|
||||
migrations-mariadb-10-6-ee:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
MB_DB_USER: root
|
||||
services:
|
||||
mariadb:
|
||||
image: circleci/mariadb:10.2.23
|
||||
image: circleci/mariadb:10.6
|
||||
ports:
|
||||
- "3306:3306"
|
||||
steps:
|
||||
|
||||
102
.github/workflows/cross-version.yml
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
name: Cross-Version Migration Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_version:
|
||||
description: "Source version (e.g., v1.58.6 or HEAD)"
|
||||
required: true
|
||||
type: string
|
||||
target_version:
|
||||
description: "Target version (e.g., v1.58.7 or HEAD)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- "cross-version/**"
|
||||
- ".github/workflows/cross-version.yml"
|
||||
- ".github/scripts/build-cross-version-matrix.js"
|
||||
|
||||
schedule:
|
||||
# Run nightly at 3 AM UTC
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
jobs:
|
||||
# Generate matrix for scheduled/PR runs
|
||||
generate-matrix:
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github
|
||||
|
||||
- name: Generate version matrix
|
||||
id: set-matrix
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { buildCrossVersionMatrix } = require('${{ github.workspace }}/.github/scripts/build-cross-version-matrix');
|
||||
const { config } = buildCrossVersionMatrix(${{ vars.CURRENT_VERSION }});
|
||||
const matrix = JSON.stringify({ include: config });
|
||||
core.setOutput("matrix", matrix);
|
||||
console.log("matrix:", matrix);
|
||||
|
||||
# Matrix-based tests for scheduled/PR runs
|
||||
test-matrix:
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
needs: generate-matrix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }}
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
|
||||
- name: Run cross-version test
|
||||
env:
|
||||
CURRENT_VERSION: ${{ vars.CURRENT_VERSION }}
|
||||
run: |
|
||||
cd cross-version
|
||||
./test.sh --source "${{ matrix.source }}" --target "${{ matrix.target }}"
|
||||
|
||||
- name: Show logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
cd cross-version
|
||||
docker compose logs metabase || true
|
||||
docker compose logs postgres || true
|
||||
|
||||
# Manual test for workflow_dispatch
|
||||
test-manual:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
|
||||
- name: Run cross-version test
|
||||
env:
|
||||
CURRENT_VERSION: ${{ vars.CURRENT_VERSION }}
|
||||
run: |
|
||||
cd cross-version
|
||||
./test.sh --source "${{ inputs.source_version }}" --target "${{ inputs.target_version }}"
|
||||
|
||||
- name: Show logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
cd cross-version
|
||||
docker compose logs metabase || true
|
||||
docker compose logs postgres || true
|
||||
79
.github/workflows/disabled-drivers-slack-notification.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
name: Disabled Drivers Slack Notification
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 9 * * 1" # every Monday at 9:00 UTC
|
||||
|
||||
jobs:
|
||||
notify-disabled-drivers:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/Jod
|
||||
|
||||
- name: Install Slack SDK
|
||||
run: npm install @slack/web-api
|
||||
|
||||
- name: Check disabled drivers and notify Slack
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
with:
|
||||
script: |
|
||||
const { WebClient } = require('@slack/web-api');
|
||||
|
||||
// Fetch the config
|
||||
const response = await fetch('https://raw.githubusercontent.com/metabase/ci-test-config/refs/heads/master/ci-test-config.json');
|
||||
const config = await response.json();
|
||||
|
||||
const drivers = config.ignored?.drivers || [];
|
||||
const lastUpdated = config.metadata?.last_updated || 'unknown';
|
||||
const reason = config.metadata?.reason || 'No reason provided';
|
||||
|
||||
if (drivers.length === 0) {
|
||||
console.log('No drivers are currently disabled. Skipping Slack notification.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.env.SLACK_BOT_TOKEN) {
|
||||
throw new Error('SLACK_BOT_TOKEN is required');
|
||||
}
|
||||
|
||||
const slack = new WebClient(process.env.SLACK_BOT_TOKEN);
|
||||
|
||||
await slack.chat.postMessage({
|
||||
channel: 'engineering-ci',
|
||||
text: 'CI Disabled Drivers Report',
|
||||
blocks: [
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `:no_entry: *CI Disabled Drivers*\n${drivers.map(d => `\`${d}\``).join(' ')}`
|
||||
},
|
||||
accessory: {
|
||||
type: 'button',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: 'View Config',
|
||||
emoji: true
|
||||
},
|
||||
url: 'https://github.com/metabase/ci-test-config/blob/master/ci-test-config.json'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'context',
|
||||
elements: [
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `${reason} · :clock1: *${lastUpdated}*`
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
console.log('Slack notification sent successfully');
|
||||
4
.github/workflows/docs-release-generate.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
target_branch:
|
||||
description: "Target branch for documentation update"
|
||||
required: false
|
||||
default: "release-x.58.x"
|
||||
default: "release-x.59.x"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
@@ -16,6 +16,6 @@ jobs:
|
||||
name: Call documentation update for release branch
|
||||
uses: ./.github/workflows/docs-generate-base.yml
|
||||
with:
|
||||
target_branch: ${{ inputs.target_branch || 'release-x.58.x' }}
|
||||
target_branch: ${{ inputs.target_branch || 'release-x.59.x' }}
|
||||
secrets:
|
||||
automation_token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
|
||||
20
.github/workflows/drivers-stress-test.yml
vendored
@@ -109,7 +109,7 @@ jobs:
|
||||
junit-name: 'be-tests-druid-ee'
|
||||
test-args: ":only ${{ inputs.test }} :times ${{ inputs.times }}"
|
||||
|
||||
be-tests-mariadb-10-2-ee:
|
||||
be-tests-mariadb-10-6-ee:
|
||||
if: inputs.driver == 'Maria DB'
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
@@ -124,15 +124,15 @@ jobs:
|
||||
MB_MYSQL_TEST_USER: root
|
||||
services:
|
||||
mariadb:
|
||||
image: circleci/mariadb:10.2.23
|
||||
image: circleci/mariadb:10.6
|
||||
ports:
|
||||
- "3306:3306"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Test MariaDB driver (10.2)
|
||||
- name: Test MariaDB driver (10.6)
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-mariadb-10-2-ee'
|
||||
junit-name: 'be-tests-mariadb-10-6-ee'
|
||||
test-args: ":only ${{ inputs.test }} :times ${{ inputs.times }}"
|
||||
|
||||
be-tests-mariadb-latest-ee:
|
||||
@@ -298,7 +298,7 @@ jobs:
|
||||
junit-name: 'be-tests-mongo-latest-ee'
|
||||
test-args: ":only ${{ inputs.test }} :times ${{ inputs.times }}"
|
||||
|
||||
be-tests-mysql-8-0-ee:
|
||||
be-tests-mysql-8-4-ee:
|
||||
if: inputs.driver == 'MySQL'
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
@@ -313,15 +313,15 @@ jobs:
|
||||
MB_MYSQL_TEST_USER: root
|
||||
services:
|
||||
mysql:
|
||||
image: cimg/mysql:8.0
|
||||
image: cimg/mysql:8.4
|
||||
ports:
|
||||
- "3306:3306"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Test MySQL driver (8.0)
|
||||
- name: Test MySQL driver (8.4)
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-mysql-8-0-ee'
|
||||
junit-name: 'be-tests-mysql-8-4-ee'
|
||||
test-args: ":only ${{ inputs.test }} :times ${{ inputs.times }}"
|
||||
|
||||
be-tests-mysql-latest-ee:
|
||||
@@ -438,7 +438,7 @@ jobs:
|
||||
MB_POSTGRESQL_TEST_USER: mb_test
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12-alpine
|
||||
image: postgres:14-alpine
|
||||
ports:
|
||||
- "5432:5432"
|
||||
env:
|
||||
@@ -447,7 +447,7 @@ jobs:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Test Postgres driver (12)
|
||||
- name: Test Postgres driver (14)
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-postgres-ee'
|
||||
|
||||
38
.github/workflows/drivers.yml
vendored
@@ -77,6 +77,9 @@ jobs:
|
||||
--pr-labels="${{ join(github.event.pull_request.labels.*.name, ',') }}"
|
||||
--skip="${{ inputs.skip }}"
|
||||
)
|
||||
echo "Running:"
|
||||
echo "./bin/mage -driver-decisions ${MAGE_ARGS[@]}"
|
||||
echo ""
|
||||
# First call: show human-readable output for debugging
|
||||
./bin/mage -driver-decisions "${MAGE_ARGS[@]}"
|
||||
# Second call: output only key=value lines for GITHUB_OUTPUT
|
||||
@@ -148,11 +151,10 @@ jobs:
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
# DS 2025-09-26 temporarily disabled while we diagnose flakes
|
||||
# - name: Transforms Python Tests
|
||||
# test-args: >-
|
||||
# :only-tags [:mb/transforms-python-test]
|
||||
# needs-python-runner: true
|
||||
- name: Transforms Python Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
needs-python-runner: true
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: bigquery-cloud-sdk
|
||||
@@ -559,9 +561,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: MariaDB 10.2
|
||||
junit-name: be-tests-mariadb-10-2-ee
|
||||
image: circleci/mariadb:10.2.23
|
||||
- name: MariaDB 10.6
|
||||
junit-name: be-tests-mariadb-10-6-ee
|
||||
image: circleci/mariadb:10.6
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
@@ -571,9 +573,9 @@ jobs:
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
- name: MySQL 8.0
|
||||
junit-name: be-tests-mysql-8-0-ee
|
||||
image: mysql:8.0
|
||||
- name: MySQL 8.4
|
||||
junit-name: be-tests-mysql-8-4-ee
|
||||
image: mysql:8.4
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
@@ -986,9 +988,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: Postgres 12.x
|
||||
- name: Postgres 14.x
|
||||
junit-name: postgres-ee
|
||||
docker-image: postgres:12-alpine
|
||||
docker-image: postgres:14-alpine
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
@@ -1275,12 +1277,10 @@ jobs:
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
|
||||
# DS 2025-09-26 temporarily disabled while we diagnose flakes
|
||||
# - name: Transforms Python Tests
|
||||
# test-args: >-
|
||||
# :only-tags [:mb/transforms-python-test]
|
||||
# needs-python-runner: true
|
||||
- name: Transforms Python Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
needs-python-runner: true
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: snowflake
|
||||
|
||||
5
.github/workflows/e2e-test.yml
vendored
@@ -45,8 +45,9 @@ jobs:
|
||||
name: e2e-tests-${{ inputs.name }}-${{ inputs.edition }}
|
||||
env:
|
||||
MB_EDITION: ${{ inputs.edition }}
|
||||
# Any env starting with `CYPRESS_` will be available to all Cypress tests via `Cypress.env()`
|
||||
# Example: you can get `CYPRESS_FOO` with `Cypress.env("FOO")`
|
||||
# Sensitive env vars (tokens, secrets) starting with `CYPRESS_` are accessed via async `cy.env()`
|
||||
# Example: `cy.env(["FOO"]).then(({ FOO }) => ...)`
|
||||
# For non-sensitive config, use `Cypress.expose()` in cypress config instead (synchronous)
|
||||
CYPRESS_MB_ALL_FEATURES_TOKEN: ${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }}
|
||||
CYPRESS_MB_STARTER_CLOUD_TOKEN: ${{ secrets.STAGING_MB_STARTER_CLOUD_TOKEN }}
|
||||
CYPRESS_MB_PRO_CLOUD_TOKEN: ${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }}
|
||||
|
||||
@@ -19,8 +19,9 @@ jobs:
|
||||
react-version: [18, 19]
|
||||
fail-fast: false
|
||||
env:
|
||||
# Any env starting with `CYPRESS_` will be available to all Cypress tests via `Cypress.env()`
|
||||
# Example: you can get `CYPRESS_FOO` with `Cypress.env("FOO")`
|
||||
# Sensitive env vars (tokens, secrets) starting with `CYPRESS_` are accessed via async `cy.env()`
|
||||
# Example: `cy.env(["FOO"]).then(({ FOO }) => ...)`
|
||||
# For non-sensitive config, use `Cypress.expose()` in cypress config instead (synchronous)
|
||||
CYPRESS_MB_ALL_FEATURES_TOKEN: ${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }}
|
||||
CYPRESS_MB_STARTER_CLOUD_TOKEN: ${{ secrets.STAGING_MB_STARTER_CLOUD_TOKEN }}
|
||||
CYPRESS_MB_PRO_CLOUD_TOKEN: ${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }}
|
||||
@@ -155,8 +156,9 @@ jobs:
|
||||
env:
|
||||
MB_RUN_MODE: "e2e"
|
||||
METASTORE_DEV_SERVER_URL: ${{ secrets.METASTORE_DEV_SERVER_URL }}
|
||||
# Any env starting with `CYPRESS_` will be available to all Cypress tests via `Cypress.env()`
|
||||
# Example: you can get `CYPRESS_FOO` with `Cypress.env("FOO")`
|
||||
# Sensitive env vars (tokens, secrets) starting with `CYPRESS_` are accessed via async `cy.env()`
|
||||
# Example: `cy.env(["FOO"]).then(({ FOO }) => ...)`
|
||||
# For non-sensitive config, use `Cypress.expose()` in cypress config instead (synchronous)
|
||||
CYPRESS_MB_ALL_FEATURES_TOKEN: ${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }}
|
||||
CYPRESS_MB_STARTER_CLOUD_TOKEN: ${{ secrets.STAGING_MB_STARTER_CLOUD_TOKEN }}
|
||||
CYPRESS_MB_PRO_CLOUD_TOKEN: ${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }}
|
||||
|
||||
@@ -88,6 +88,7 @@ jobs:
|
||||
e2e-sample-apps-tests:
|
||||
needs: [get-sample-apps-data]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
test_suite: ${{ fromJSON(needs.get-sample-apps-data.outputs.matrix) }}
|
||||
environment: ["production", "development"]
|
||||
@@ -194,6 +195,7 @@ jobs:
|
||||
e2e-shoppy-tests:
|
||||
needs: [get-sample-apps-data]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
environment: ["production", "development"]
|
||||
if: |
|
||||
@@ -342,6 +344,7 @@ jobs:
|
||||
# For more details see `Embedding SDK integration tests with Host Apps` in `enterprise/frontend/src/embedding-sdk-package/dev.md`
|
||||
e2e-host-apps-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
test_suite:
|
||||
[
|
||||
|
||||
55
.github/workflows/embedding-sdk.yml
vendored
@@ -23,6 +23,10 @@ on:
|
||||
description: "Whether to run host and sample apps compatibility tests."
|
||||
required: true
|
||||
type: boolean
|
||||
bundle-size-check:
|
||||
description: "Whether to run bundle size check."
|
||||
required: true
|
||||
type: boolean
|
||||
skip:
|
||||
description: Whether to skip running tests due to one of the artifacts failing to build.
|
||||
required: true
|
||||
@@ -124,8 +128,59 @@ jobs:
|
||||
uses: ./.github/workflows/embedding-sdk-host-sample-apps-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
embedding-sdk-bundle-size-check:
|
||||
if: inputs.bundle-size-check
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
BUNDLE_SIZE_THRESHOLD_PERCENT: 2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/sdk-bundle-size-check
|
||||
id: compare
|
||||
with:
|
||||
edition: ee
|
||||
base-ref-commit: ${{ github.event.pull_request.base.sha }}
|
||||
threshold-percent: ${{ env.BUNDLE_SIZE_THRESHOLD_PERCENT }}
|
||||
|
||||
- name: Comment on PR if bundle size has increased
|
||||
uses: ./.github/actions/notify-pull-request
|
||||
if: success() && steps.compare.outputs.status == 'increased'
|
||||
with:
|
||||
remove-previous-comment: true
|
||||
add-new-comment: true
|
||||
message: "⚠️ Embedding SDK bundle size increased by ${{ steps.compare.outputs.size_change_percent }}% compared to the ${{ github.base_ref }} branch (threshold: ${{ env.BUNDLE_SIZE_THRESHOLD_PERCENT }}%). Please contact Embedding team."
|
||||
include-log: false
|
||||
|
||||
- name: Send notification to Slack via Zapier
|
||||
if: success() && steps.compare.outputs.status == 'increased'
|
||||
shell: bash
|
||||
env:
|
||||
ZAPIER_WEBHOOK_URL: ${{ secrets.EMBEDDING_ZAPIER_WEBHOOK_URL }}
|
||||
SIZE_CHANGE_PERCENT: ${{ steps.compare.outputs.size_change_percent }}
|
||||
BUNDLE_SIZE_THRESHOLD_PERCENT: ${{ env.BUNDLE_SIZE_THRESHOLD_PERCENT }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
run: |
|
||||
curl -s -X POST "$ZAPIER_WEBHOOK_URL" \
|
||||
-d "⚠️ Embedding SDK bundle size increased by ${SIZE_CHANGE_PERCENT}% (threshold: ${BUNDLE_SIZE_THRESHOLD_PERCENT}%)
|
||||
|
||||
PR #${PR_NUMBER} ${PR_TITLE} (${PR_URL})"
|
||||
|
||||
- name: Comment on PR if bundle size has decreased
|
||||
uses: ./.github/actions/notify-pull-request
|
||||
if: success() && steps.compare.outputs.status == 'decreased'
|
||||
with:
|
||||
remove-previous-comment: true
|
||||
add-new-comment: true
|
||||
message: "🎉 Embedding SDK bundle size decreased by ${{ steps.compare.outputs.size_change_percent }}% compared to the ${{ github.base_ref }} branch. Nice work!"
|
||||
include-log: false
|
||||
|
||||
embedding-sdk-tests-result:
|
||||
needs:
|
||||
- embedding-sdk-bundle-size-check
|
||||
- embedding-sdk-cli-snippets-type-check
|
||||
- embedding-sdk-docs-snippets-type-check
|
||||
- embedding-sdk-api-documentation-generation-validation
|
||||
|
||||
3
.github/workflows/mage.yml
vendored
@@ -2,9 +2,6 @@ name: Mage Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "mage/**"
|
||||
- "bb.edn"
|
||||
|
||||
jobs:
|
||||
# run mage tests even if the rest of be isnt needed
|
||||
|
||||
78
.github/workflows/module-boundaries-stats.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: Module Boundaries Stats
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: 40 1 * * 5 # every Friday at 1:40
|
||||
|
||||
jobs:
|
||||
module-boundaries-stats:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: get module boundary stats
|
||||
id: get-stats
|
||||
run: |
|
||||
NAMED_MODULES=$(node --input-type=module -e "
|
||||
import { elements } from './frontend/lint/module-boundaries.mjs';
|
||||
const named = elements.filter(e => !['shared/other', 'app/misc', 'other'].includes(e.type));
|
||||
")
|
||||
|
||||
NAMED_DIRS=$(node --input-type=module -e "
|
||||
import { elements } from './frontend/lint/module-boundaries.mjs';
|
||||
const dirs = new Set();
|
||||
for (const el of elements) {
|
||||
const m = el.pattern.match(/^frontend\/src\/metabase\/(\w[\w-]*)\//)
|
||||
if (m) dirs.add(m[1]);
|
||||
}
|
||||
")
|
||||
TOTAL_DIRS=$(ls -d frontend/src/metabase/*/ | xargs -I{} basename {} | wc -l | tr -d ' ')
|
||||
NAMED_COUNT=$(echo "$NAMED_DIRS" | grep -c . || true)
|
||||
UNMODULED=$(($TOTAL_DIRS - $NAMED_COUNT))
|
||||
|
||||
VIOLATIONS=$(bun run module-boundaries 2>&1 | grep -o '✖ [0-9]* problems' | grep -o '[0-9]*' || echo "0")
|
||||
|
||||
echo "$NAMED_MODULES modules, $UNMODULED unmoduled folders, $VIOLATIONS violations"
|
||||
|
||||
echo "NAMED_MODULES=$NAMED_MODULES" >> $GITHUB_OUTPUT
|
||||
echo "UNMODULED=$UNMODULED" >> $GITHUB_OUTPUT
|
||||
echo "VIOLATIONS=$VIOLATIONS" >> $GITHUB_OUTPUT
|
||||
- name: Upload stats
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
NAMED_MODULES: ${{ steps.get-stats.outputs.NAMED_MODULES }}
|
||||
UNMODULED: ${{ steps.get-stats.outputs.UNMODULED }}
|
||||
VIOLATIONS: ${{ steps.get-stats.outputs.VIOLATIONS }}
|
||||
API_KEY: ${{ secrets.STATS_API_KEY }}
|
||||
with:
|
||||
script: | # js
|
||||
const { uploadCsvToMb } = require('${{ github.workspace }}/.github/scripts/csv-to-mb.js');
|
||||
|
||||
console.log("NAMED_MODULES", Number(process.env.NAMED_MODULES));
|
||||
console.log("UNMODULED", Number(process.env.UNMODULED));
|
||||
console.log("VIOLATIONS", Number(process.env.VIOLATIONS));
|
||||
|
||||
const data = [
|
||||
{
|
||||
'Date': new Date().toISOString().slice(0, 10), // YYYY-MM-DD
|
||||
'Modules': Number(process.env.NAMED_MODULES),
|
||||
'Unmoduled folders': Number(process.env.UNMODULED),
|
||||
'Violations': Number(process.env.VIOLATIONS),
|
||||
}
|
||||
];
|
||||
|
||||
uploadCsvToMb({
|
||||
baseUrl: 'https://stats.metabase.com',
|
||||
tableId: 75146,
|
||||
jsonData: data,
|
||||
mode: 'append',
|
||||
}).then(() => {
|
||||
console.log('Data uploaded successfully');
|
||||
}).catch((error) => {
|
||||
console.error('Error uploading data:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
6
.github/workflows/openapi-check.yml
vendored
@@ -22,10 +22,8 @@ jobs:
|
||||
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
# fetch full git history to allow fetching the latest base branch commit and comparing changes
|
||||
fetch-depth: 0
|
||||
- name: Setup Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
- name: Prepare Node + Bun environment
|
||||
uses: ./.github/actions/prepare-node-bun
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Generate version.properties
|
||||
|
||||
44
.github/workflows/openapi.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Validate OpenAPI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'release-**'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
name: Check which files changed
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
openapi: ${{ steps.changes.outputs.openapi }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Test which files changed
|
||||
uses: dorny/paths-filter@v3.0.0
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
|
||||
validate-openapi:
|
||||
needs: files-changed
|
||||
if: needs.files-changed.outputs.openapi == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Generate OpenAPI spec
|
||||
run: bun run generate-openapi
|
||||
- name: Validate OpenAPI spec
|
||||
run: bun run lint-openapi
|
||||
4
.github/workflows/pr-env-close-unlabel.yml
vendored
@@ -5,8 +5,8 @@ on:
|
||||
types: [ closed, unlabeled ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
pr-env:
|
||||
|
||||
29
.github/workflows/pr-env-destroy-manual.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: PR Env Destroy (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to destroy environment for'
|
||||
required: true
|
||||
type: string
|
||||
self_hosting:
|
||||
description: 'Self-hosting stack'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pr-env-admin
|
||||
steps:
|
||||
- name: Approved
|
||||
run: echo "Destroying PR environment for PR #${{ inputs.pr_number }}"
|
||||
|
||||
destroy:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/pr-env-destroy.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
self_hosting: ${{ inputs.self_hosting }}
|
||||
pr_number: ${{ inputs.pr_number }}
|
||||
17
.github/workflows/pr-env-destroy.yml
vendored
@@ -9,6 +9,13 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
pr_number:
|
||||
description: 'PR number (required for manual dispatch)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number || github.event.number }}
|
||||
|
||||
jobs:
|
||||
destroy_pr:
|
||||
@@ -62,8 +69,8 @@ jobs:
|
||||
- name: Destroy PR Review ENV
|
||||
if: ${{ !inputs.self_hosting }}
|
||||
run: |
|
||||
kubectl delete metabase -n hosting-pr${{ github.event.number }} hosting-pr${{ github.event.number }}
|
||||
kubectl delete ns hosting-pr${{ github.event.number }}
|
||||
kubectl delete metabase -n hosting-pr${{ env.PR_NUMBER }} hosting-pr${{ env.PR_NUMBER }}
|
||||
kubectl delete ns hosting-pr${{ env.PR_NUMBER }}
|
||||
- name: Setup Helm
|
||||
if: ${{ inputs.self_hosting }}
|
||||
uses: azure/setup-helm@v4
|
||||
@@ -72,8 +79,8 @@ jobs:
|
||||
- name: Destroy PR Review ENV (self-hosting)
|
||||
if: ${{ inputs.self_hosting }}
|
||||
run: |
|
||||
helm uninstall hosting-pr${{ github.event.number }} --namespace hosting-pr${{ github.event.number }} || true
|
||||
kubectl delete ns hosting-pr${{ github.event.number }} || true
|
||||
helm uninstall hosting-pr${{ env.PR_NUMBER }} --namespace hosting-pr${{ env.PR_NUMBER }} || true
|
||||
kubectl delete ns hosting-pr${{ env.PR_NUMBER }} || true
|
||||
- name: Setup psql client
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -85,4 +92,4 @@ jobs:
|
||||
-h ${{ secrets.PR_ENV_DB_HOST }} \
|
||||
-U ${{ secrets.PR_ENV_DB_USER }} \
|
||||
-d ${{ secrets.PR_ENV_DB_NAME }} \
|
||||
-c "DROP DATABASE IF EXISTS hosting_pr${{ github.event.number }};"
|
||||
-c "DROP DATABASE IF EXISTS hosting_pr${{ env.PR_NUMBER }};"
|
||||
|
||||
4
.github/workflows/release-patch.yml
vendored
@@ -13,8 +13,8 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
# schedule:
|
||||
# - cron: '0 21 * * 1-5' # every weekday at 4 pm EST/5 pm EDT
|
||||
schedule:
|
||||
- cron: '0 21 * * 1-5' # every weekday at 4 pm EST/5 pm EDT
|
||||
|
||||
jobs:
|
||||
auto-patch-trigger:
|
||||
|
||||
266
.github/workflows/repro-bot.yml
vendored
Normal file
@@ -0,0 +1,266 @@
|
||||
name: "Repro Bot"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: "Issue number to reproduce"
|
||||
required: true
|
||||
type: string
|
||||
repro_bot_branch:
|
||||
description: "Branch of repro-bot to use"
|
||||
required: false
|
||||
default: "main"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
repro-bot:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
id-token: write
|
||||
if: |
|
||||
(github.event_name == 'push') ||
|
||||
(github.event_name == 'issues' && github.event.label.name == '.Run Repro Bot' && github.event.issue.state == 'open') ||
|
||||
(github.event_name == 'workflow_dispatch')
|
||||
|
||||
steps:
|
||||
- name: Get issue number
|
||||
id: issue
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "number=${{ github.event.inputs.issue_number }}" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.event_name }}" = "push" ]; then
|
||||
echo "number=70292" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/prepare-backend
|
||||
- uses: ./.github/actions/prepare-frontend
|
||||
|
||||
- name: Checkout repro-bot
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: metabase/repro-bot
|
||||
ref: ${{ github.event.inputs.repro_bot_branch || 'main' }}
|
||||
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
path: repro-bot
|
||||
|
||||
- name: Pre-clone metabase bare git repo
|
||||
run: git clone --bare .git repro-bot/git/metabase
|
||||
|
||||
- name: Capture Node.js path for MCP subprocesses
|
||||
run: echo "NODE_BIN_DIR=$(dirname $(which node))" >> $GITHUB_ENV
|
||||
|
||||
- name: Install playwright-cli
|
||||
run: |
|
||||
npm install -g @playwright/cli@latest
|
||||
playwright-cli install --skills
|
||||
|
||||
- name: Install bbin and clj-nrepl-eval
|
||||
run: |
|
||||
# Install babashka
|
||||
curl -sLO https://raw.githubusercontent.com/babashka/babashka/master/install
|
||||
chmod +x install
|
||||
sudo ./install
|
||||
rm install
|
||||
|
||||
# Install bbin
|
||||
mkdir -p ~/.bbin/bin
|
||||
curl -sL https://raw.githubusercontent.com/babashka/bbin/main/bbin > ~/.bbin/bin/bbin
|
||||
chmod +x ~/.bbin/bin/bbin
|
||||
export PATH="$HOME/.bbin/bin:$PATH"
|
||||
export BBIN_HOME="$HOME/.bbin"
|
||||
|
||||
# Install clj-nrepl-eval via bbin
|
||||
bbin install https://github.com/bhauman/clojure-mcp-light.git \
|
||||
--tag v0.2.1 \
|
||||
--as clj-nrepl-eval \
|
||||
--main-opts '["-m" "clojure-mcp-light.nrepl-eval"]'
|
||||
|
||||
# Add to PATH for the rest of the workflow
|
||||
echo "$HOME/.bbin/bin" >> $GITHUB_PATH
|
||||
|
||||
# Verify
|
||||
clj-nrepl-eval --help | head -3
|
||||
|
||||
- name: Register MCP servers
|
||||
env:
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY_REPRO_BOT }}
|
||||
run: |
|
||||
UV_PATH="$(which uv)"
|
||||
jq -n \
|
||||
--arg uv "$UV_PATH" \
|
||||
--arg key "$LINEAR_API_KEY" \
|
||||
'{
|
||||
mcpServers: {
|
||||
"metabase-repro": {
|
||||
command: $uv,
|
||||
args: ["run", "--directory", "mcp", "--with", "fastmcp", "python", "-m", "metabase_repro"]
|
||||
},
|
||||
"linear-server": {
|
||||
url: "https://mcp.linear.app/mcp",
|
||||
type: "http",
|
||||
headers: {
|
||||
Authorization: ("Bearer " + $key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}' > ~/.claude.json
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
uses: actions/create-github-app-token@v2
|
||||
id: claude-app-token
|
||||
with:
|
||||
app-id: ${{ secrets.METABASE_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.METABASE_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Run repro-bot with Claude Code
|
||||
id: claude
|
||||
continue-on-error: true
|
||||
uses: anthropics/claude-code-action@v1
|
||||
env:
|
||||
CLAUDE_CODE_DEBUG_LOGS_DIR: /tmp/claude-debug-logs
|
||||
CLAUDE_WORKING_DIR: ${{ github.workspace }}/repro-bot
|
||||
MB_PREMIUM_EMBEDDING_TOKEN: ${{ secrets.PR_ENV_MB_PREMIUM_EMBEDDING_TOKEN }}
|
||||
GITHUB_ACTIONS_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
timeout-minutes: 30
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_REPRO_BOT }}
|
||||
github_token: ${{ steps.claude-app-token.outputs.token }}
|
||||
prompt: |
|
||||
Read and follow the instructions in .claude/commands/repro.md to investigate this GitHub issue:
|
||||
https://github.com/${{ github.repository }}/issues/${{ steps.issue.outputs.number }}
|
||||
|
||||
## CI Environment Notes
|
||||
- The bare git repo has been pre-cloned at git/metabase. The master worktree still needs setup via setup_master_worktree().
|
||||
- The GITHUB_ACTIONS_RUN_URL environment variable is set. If any phase fails catastrophically, follow the CI error handling in repro.md to post the error to Linear with the action run URL.
|
||||
- Docker is available for appdb (Postgres/MySQL/MariaDB) if needed.
|
||||
- Java and Python/uv are pre-installed.
|
||||
claude_args: >-
|
||||
--allowedTools
|
||||
"Glob,
|
||||
Grep,
|
||||
Read,
|
||||
ToolSearch,
|
||||
Skill(*),
|
||||
WebSearch,
|
||||
WebFetch(domain:docs.metabase.com),
|
||||
WebFetch(domain:metabase.com),
|
||||
WebFetch(domain:www.metabase.com),
|
||||
Edit(//./**),
|
||||
Edit(repro/**),
|
||||
Edit(/tmp/**),
|
||||
Write(//./**),
|
||||
Write(repro/**),
|
||||
Write(/tmp/**),
|
||||
Bash(base64 *),
|
||||
Bash(cat *),
|
||||
Bash(cd *),
|
||||
Bash(clj-nrepl-eval *),
|
||||
Bash(clojure *),
|
||||
Bash(cp *),
|
||||
Bash(diff *),
|
||||
Bash(docker *),
|
||||
Bash(echo *),
|
||||
Bash(find *),
|
||||
Bash(gh api repos/metabase/metabase/*),
|
||||
Bash(gh issue list * --repo metabase/metabase *),
|
||||
Bash(gh issue view * --repo metabase/metabase *),
|
||||
Bash(gh release *),
|
||||
Bash(gh search *),
|
||||
Bash(git *),
|
||||
Bash(grep *),
|
||||
Bash(head *),
|
||||
Bash(jar *),
|
||||
Bash(jq *),
|
||||
Bash(kill *),
|
||||
Bash(ls *),
|
||||
Bash(mkdir *),
|
||||
Bash(mv *),
|
||||
Bash(npx playwright-cli:*),
|
||||
Bash(pgrep *),
|
||||
Bash(playwright-cli:*),
|
||||
Bash(printf *),
|
||||
Bash(ps *),
|
||||
Bash(psql *),
|
||||
Bash(PGPASSWORD=*),
|
||||
Bash(python -m mcp.metabase_repro *),
|
||||
Bash(python3 *),
|
||||
Bash(sed *),
|
||||
Bash(sort *),
|
||||
Bash(strings *),
|
||||
Bash(tail *),
|
||||
Bash(tee *),
|
||||
Bash(touch *),
|
||||
Bash(unzip *),
|
||||
Bash(wc *),
|
||||
Bash(which *),
|
||||
Bash(xargs *),
|
||||
mcp__metabase-repro__api_call,
|
||||
mcp__metabase-repro__browser_snapshot,
|
||||
mcp__metabase-repro__check_setup,
|
||||
mcp__metabase-repro__check_setup_token,
|
||||
mcp__metabase-repro__cleanup,
|
||||
mcp__metabase-repro__create_env,
|
||||
mcp__metabase-repro__get_session_token,
|
||||
mcp__metabase-repro__get_status,
|
||||
mcp__metabase-repro__jar_download,
|
||||
mcp__metabase-repro__repl_eval,
|
||||
mcp__metabase-repro__reset_metabase,
|
||||
mcp__metabase-repro__run_test,
|
||||
mcp__metabase-repro__save_test_patch,
|
||||
mcp__metabase-repro__setup_master_worktree,
|
||||
mcp__metabase-repro__setup_worktree,
|
||||
mcp__metabase-repro__start_appdb,
|
||||
mcp__metabase-repro__start_metabase,
|
||||
mcp__metabase-repro__stop_appdb_tool,
|
||||
mcp__metabase-repro__stop_metabase,
|
||||
mcp__linear-server__create_attachment,
|
||||
mcp__linear-server__get_issue,
|
||||
mcp__linear-server__list_comments,
|
||||
mcp__linear-server__list_issues,
|
||||
mcp__linear-server__list_issue_labels,
|
||||
mcp__linear-server__save_comment,
|
||||
mcp__linear-server__save_issue"
|
||||
--disallowedTools "Bash(git push*),Bash(rm -rf /*)"
|
||||
|
||||
- name: Verify repro-bot completed successfully
|
||||
run: |
|
||||
if [ "${{ steps.claude.outcome }}" = "failure" ]; then
|
||||
echo "::error::Claude Code crashed or exited with an error"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXEC_FILE="${{ steps.claude.outputs.execution_file }}"
|
||||
if [ -z "$EXEC_FILE" ] || [ ! -f "$EXEC_FILE" ]; then
|
||||
echo "::error::No execution file found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "mcp__linear-server__" "$EXEC_FILE"; then
|
||||
echo "::error::Claude did not post a comment on Linear"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Repro-bot completed successfully and posted to Linear"
|
||||
|
||||
- name: Upload repro-bot artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: repro-bot-${{ steps.issue.outputs.number }}
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
repro/*/SUGGESTIONS.md
|
||||
repro/*/*.log
|
||||
repro/*/*.patch
|
||||
repro/*/playwright/**
|
||||
${{ steps.claude.outputs.execution_file }}
|
||||
18
.github/workflows/rerun-workflows.yml
vendored
@@ -40,11 +40,19 @@ jobs:
|
||||
const BRANCH = process.env.BRANCH_NAME;
|
||||
|
||||
if (ATTEMPT <= MAX_ATTEMPTS) {
|
||||
github.rest.actions.reRunWorkflowFailedJobs({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }},
|
||||
});
|
||||
try {
|
||||
await github.rest.actions.reRunWorkflowFailedJobs({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status === 403) {
|
||||
console.log(`Skipping re-run: ${error.message}`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (!BRANCH.includes('backport-')) {
|
||||
// notify slack of repeated failure
|
||||
const { WebClient } = require('@slack/web-api');
|
||||
|
||||
63
.github/workflows/reset-cloud-test-dataset.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Reset Cloud Test Dataset
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
driver:
|
||||
description: 'Driver to reset dataset for'
|
||||
required: true
|
||||
default: 'snowflake'
|
||||
type: choice
|
||||
options:
|
||||
- snowflake
|
||||
- bigquery-cloud-sdk
|
||||
- redshift
|
||||
- databricks
|
||||
dataset-name:
|
||||
description: 'Dataset name to drop (e.g., test-data)'
|
||||
required: true
|
||||
default: 'test-data'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
reset-dataset:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: ${{ inputs.driver }}
|
||||
# Snowflake
|
||||
MB_SNOWFLAKE_TEST_USER: METABASE CI
|
||||
MB_SNOWFLAKE_TEST_ACCOUNT: ${{ secrets.MB_SNOWFLAKE_TEST_ACCOUNT }}
|
||||
MB_SNOWFLAKE_TEST_PASSWORD: ${{ secrets.MB_SNOWFLAKE_TEST_PASSWORD }}
|
||||
MB_SNOWFLAKE_TEST_PRIVATE_KEY: ${{ secrets.MB_SNOWFLAKE_TEST_PRIVATE_KEY }}
|
||||
MB_SNOWFLAKE_TEST_WAREHOUSE: ${{ secrets.MB_SNOWFLAKE_TEST_WAREHOUSE }}
|
||||
# BigQuery
|
||||
MB_BIGQUERY_TEST_PROJECT_ID: ${{ secrets.BIGQUERY_TEST_PROJECT_ID }}
|
||||
MB_BIGQUERY_TEST_CLIENT_ID: ${{ secrets.MB_BIGQUERY_TEST_CLIENT_ID }}
|
||||
MB_BIGQUERY_TEST_CLIENT_SECRET: ${{ secrets.MB_BIGQUERY_TEST_CLIENT_SECRET }}
|
||||
MB_BIGQUERY_TEST_ACCESS_TOKEN: ${{ secrets.MB_BIGQUERY_TEST_ACCESS_TOKEN }}
|
||||
MB_BIGQUERY_TEST_REFRESH_TOKEN: ${{ secrets.MB_BIGQUERY_TEST_REFRESH_TOKEN }}
|
||||
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON }}
|
||||
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING }}
|
||||
# Redshift
|
||||
MB_REDSHIFT_TEST_USER: metabase_ci
|
||||
MB_REDSHIFT_TEST_DB: testdb
|
||||
MB_REDSHIFT_TEST_HOST: ${{ secrets.MB_REDSHIFT_TEST_HOST }}
|
||||
MB_REDSHIFT_TEST_PASSWORD: ${{ secrets.MB_REDSHIFT_TEST_PASSWORD }}
|
||||
# Databricks
|
||||
MB_DATABRICKS_TEST_HOST: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HOST }}
|
||||
MB_DATABRICKS_TEST_HTTP_PATH: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HTTP_PATH }}
|
||||
MB_DATABRICKS_TEST_TOKEN: ${{ secrets.MB_DATABRICKS_JDBC_TEST_TOKEN }}
|
||||
MB_DATABRICKS_TEST_CATALOG: metabase_ci
|
||||
MB_DATABRICKS_TEST_CLIENT_ID: ${{ secrets.MB_DATABRICKS_JDBC_TEST_CLIENT_ID }}
|
||||
MB_DATABRICKS_TEST_OAUTH_SECRET: ${{ secrets.MB_DATABRICKS_JDBC_TEST_OAUTH_SECRET }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/prepare-backend
|
||||
- name: Drop dataset
|
||||
run: >-
|
||||
clojure -X:dev:drivers:drivers-dev:test
|
||||
metabase.test.data.impl/drop-dataset!
|
||||
:driver '"${{ inputs.driver }}"'
|
||||
:dataset-name '"${{ inputs.dataset-name }}"'
|
||||
6
.github/workflows/run-tests.yml
vendored
@@ -81,7 +81,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
skip: ${{ needs.files-changed.outputs.frontend_all != 'true' }}
|
||||
skip-lint: ${{ needs.files-changed.outputs.frontend_all != 'true' && needs.files-changed.outputs.e2e_specs != 'true' }}
|
||||
skip-lint: ${{ needs.files-changed.outputs.frontend_all != 'true' && needs.files-changed.outputs.e2e_all != 'true' }}
|
||||
|
||||
e2e-tests:
|
||||
needs: [files-changed, uberjar]
|
||||
@@ -103,7 +103,8 @@ jobs:
|
||||
(needs.files-changed.outputs.embedding_sdk_components == 'true' ||
|
||||
needs.files-changed.outputs.frontend_sources == 'true' ||
|
||||
needs.files-changed.outputs.embedding_documentation == 'true' ||
|
||||
needs.files-changed.outputs.embedding_sdk_ci == 'true')
|
||||
needs.files-changed.outputs.embedding_sdk_ci == 'true' ||
|
||||
needs.files-changed.outputs.embedding_sdk_host_sample_apps == 'true')
|
||||
uses: ./.github/workflows/embedding-sdk-package-build.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -113,6 +114,7 @@ jobs:
|
||||
uses: ./.github/workflows/embedding-sdk.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
bundle-size-check: ${{ github.event_name == 'pull_request' && needs.uberjar.result == 'success' && needs.files-changed.outputs.frontend_sources == 'true' }}
|
||||
cli-snippets-type-check: ${{ needs.files-changed.outputs.frontend_sources == 'true' }}
|
||||
docs-snippets-type-check: ${{ needs.files-changed.outputs.embedding_documentation == 'true' }}
|
||||
documentation-validation: ${{ needs.files-changed.outputs.frontend_sources == 'true' }}
|
||||
|
||||
18
.github/workflows/semantic-search.yml
vendored
@@ -75,9 +75,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: MariaDB 10.2 with pgvector pg17
|
||||
junit-name: semantic-search-tests-mariadb-10-2
|
||||
mariadb-image: circleci/mariadb:10.2.23
|
||||
- name: MariaDB 10.6 with pgvector pg17
|
||||
junit-name: semantic-search-tests-mariadb-10-6
|
||||
mariadb-image: circleci/mariadb:10.6
|
||||
pgvector-image: pgvector/pgvector:pg17
|
||||
- name: MariaDB Latest with pgvector pg17
|
||||
junit-name: semantic-search-tests-mariadb-latest
|
||||
@@ -149,9 +149,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: MySQL 8.0 with pgvector pg17
|
||||
junit-name: semantic-search-tests-mysql-8-0
|
||||
mysql-image: cimg/mysql:8.0
|
||||
- name: MySQL 8.4 with pgvector pg17
|
||||
junit-name: semantic-search-tests-mysql-8-4
|
||||
mysql-image: cimg/mysql:8.4
|
||||
pgvector-image: pgvector/pgvector:pg17
|
||||
- name: MySQL Latest with pgvector pg17
|
||||
junit-name: semantic-search-tests-mysql-latest
|
||||
@@ -223,9 +223,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: Postgres 12.x with pgvector pg17
|
||||
junit-name: semantic-search-tests-postgres-12
|
||||
postgres-image: postgres:12-alpine
|
||||
- name: Postgres 14.x with pgvector pg17
|
||||
junit-name: semantic-search-tests-postgres-14
|
||||
postgres-image: postgres:14-alpine
|
||||
pgvector-image: pgvector/pgvector:pg17
|
||||
- name: Postgres Latest with pgvector pg17
|
||||
junit-name: semantic-search-tests-postgres-latest
|
||||
|
||||
50
.github/workflows/translation-context.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Crowdin Context Harvester
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "30 6 * * 2" # Tuesday at 06:30 UTC, one hour after translation-update.yml
|
||||
|
||||
jobs:
|
||||
harvest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Harvest and write AI context to Crowdin
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
OPENAI_KEY: ${{ secrets.CROWDIN_OPENAI_API_KEY }}
|
||||
run: |
|
||||
npx --yes crowdin-context-harvester@latest harvest \
|
||||
--project="$CROWDIN_PROJECT_ID" \
|
||||
--ai="openai" \
|
||||
--model="gpt-5.1" \
|
||||
--croql='not (context contains "✨ AI Context")' \
|
||||
--output="crowdin" \
|
||||
--concurrency=10
|
||||
|
||||
# We only want to trigger pre-translations, not wait for them to finish running upstream.
|
||||
# Weirdly, Crowdin keeps this step running as long as it runs pre-translations on their servers.
|
||||
# Hence, the three minute timeout and continue-on-error.
|
||||
- name: Trigger pre-translations
|
||||
timeout-minutes: 3
|
||||
uses: crowdin/github-action@v2
|
||||
# https://crowdin.github.io/crowdin-cli/commands/crowdin-pre-translate
|
||||
with:
|
||||
command: "pre-translate"
|
||||
command_args: >-
|
||||
--method ai
|
||||
--ai-prompt ${{ secrets.CROWDIN_AI_PROMPT_ID }}
|
||||
--translate-untranslated-only
|
||||
--token ${{ secrets.CROWDIN_TOKEN }}
|
||||
--project-id ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
--config=locales/crowdin.yml
|
||||
--no-progress
|
||||
continue-on-error: true
|
||||
22
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
*.class
|
||||
*.*.rej
|
||||
*.d.css
|
||||
*.iml
|
||||
*.jar
|
||||
*.log
|
||||
@@ -16,6 +17,7 @@
|
||||
.nrepl-port
|
||||
.portal
|
||||
.vscode
|
||||
/.zed
|
||||
/*.h2.db
|
||||
/*.lock.db
|
||||
/*.mv.db
|
||||
@@ -24,6 +26,7 @@
|
||||
/.env*
|
||||
!/.env.example
|
||||
/.envrc
|
||||
/.workmux.yaml
|
||||
/.lein-env
|
||||
/.lein-failures
|
||||
/.lein-plugins
|
||||
@@ -43,6 +46,7 @@ cypress.env.json
|
||||
/docs/**/package.json
|
||||
/docs/**/yarn.lock
|
||||
/docs/embedding/sdk/api/
|
||||
/docs/embedding/eajs/snippets/
|
||||
/frontend/test/snapshots/*
|
||||
/e2e/snapshots/*
|
||||
/e2e/tmp/*
|
||||
@@ -63,6 +67,7 @@ cypress.env.json
|
||||
/resources/frontend_client/app/embed.js
|
||||
/resources/frontend_client/app/embedding-sdk.js
|
||||
/resources/frontend_client/app/embedding-sdk.js.map
|
||||
/resources/frontend_client/app/embedding-sdk/
|
||||
/resources/frontend_client/tmp-*/
|
||||
/resources/frontend_client/embed.html
|
||||
/resources/frontend_client/embed-sdk.html
|
||||
@@ -73,6 +78,7 @@ cypress.env.json
|
||||
/resources/instance_analytics.zip
|
||||
/resources/license-backend-third-party.txt
|
||||
/resources/license-frontend-third-party.txt
|
||||
/resources/metabase/config/modules.edn
|
||||
/resources/namespaces.edn
|
||||
/resources/sample-dataset.db.trace.db
|
||||
/resources/version.properties
|
||||
@@ -121,13 +127,13 @@ modules/drivers/*/.lsp/*
|
||||
**/.clj-kondo/.cache
|
||||
|
||||
# clj-kondo: ignore all except our defined config
|
||||
.clj-kondo/*
|
||||
!.clj-kondo/README.md
|
||||
!.clj-kondo/config.edn
|
||||
!.clj-kondo/config/
|
||||
!.clj-kondo/src/
|
||||
!.clj-kondo/test/
|
||||
!.clj-kondo/macros/
|
||||
**/.clj-kondo/*
|
||||
!**/.clj-kondo/README.md
|
||||
!**/.clj-kondo/config.edn
|
||||
!**/.clj-kondo/config/
|
||||
!**/.clj-kondo/src/
|
||||
!**/.clj-kondo/test/
|
||||
!**/.clj-kondo/macros/
|
||||
|
||||
# Editor- or environment-specific local files
|
||||
*.code-workspace
|
||||
@@ -168,5 +174,5 @@ CLAUDE.local.md
|
||||
# Python dependencies (pip-installed, not committed)
|
||||
/resources/python-sources/sqlglot/
|
||||
/resources/python-sources/sqlglot-*.dist-info/
|
||||
/resources/python-sources/.lock
|
||||
__pycache__/
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
151
.typedoc/typedoc-plugin-props-doc-transform.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
ArrayType,
|
||||
Comment,
|
||||
Converter,
|
||||
IntrinsicType,
|
||||
LiteralType,
|
||||
ReflectionFlag,
|
||||
ReflectionKind,
|
||||
UnionType,
|
||||
} from "typedoc";
|
||||
|
||||
/**
|
||||
* Check if a UnionType has ALL members as string LiteralTypes.
|
||||
*/
|
||||
function isAllStringLiteralUnion(type) {
|
||||
if (!(type instanceof UnionType)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
type.types.length > 0 &&
|
||||
type.types.every(
|
||||
(t) => t instanceof LiteralType && typeof t.value === "string",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the text content from a Comment tag's content array.
|
||||
*/
|
||||
function getTagText(tag) {
|
||||
if (!tag.content || tag.content.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return tag.content.map((part) => part.text).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform @remarks text into a human-readable sentence.
|
||||
*
|
||||
* "Guest embed" → "Available in Guest embed."
|
||||
* "Pro/Enterprise" → "Available in Pro/Enterprise."
|
||||
* "Pro/Enterprise, Guest embed" → "Available in Pro/Enterprise and Guest embed."
|
||||
*/
|
||||
function transformRemarks(text) {
|
||||
const trimmed = text.trim();
|
||||
|
||||
if (trimmed.includes(",")) {
|
||||
const parts = trimmed.split(",").map((s) => s.trim());
|
||||
const last = parts.pop();
|
||||
return `Available in ${parts.join(", ")} and ${last}.`;
|
||||
}
|
||||
|
||||
return `Available in ${trimmed}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A TypeDoc plugin that transforms property reflections before markdown
|
||||
* rendering to produce cleaner props documentation tables.
|
||||
*
|
||||
* For each property on interfaces/type aliases:
|
||||
* 1. Moves optional `?` markers into the description as "Optional."
|
||||
* 2. Extracts @defaultValue tags into the description as "Default: `value`."
|
||||
* 3. Replaces all-string-literal union types with `string` and lists possible values
|
||||
* 4. Transforms @remarks tags into human-readable availability notes
|
||||
*/
|
||||
export function load(app) {
|
||||
app.converter.on(Converter.EVENT_RESOLVE_END, (context) => {
|
||||
const project = context.project;
|
||||
|
||||
for (const reflection of project.getReflectionsByKind(
|
||||
ReflectionKind.Property,
|
||||
)) {
|
||||
const prefix = [];
|
||||
|
||||
// 1. Optional: move ? into description
|
||||
if (reflection.flags.isOptional) {
|
||||
prefix.push("Optional");
|
||||
reflection.setFlag(ReflectionFlag.Optional, false);
|
||||
}
|
||||
|
||||
// 2. Default value: extract @defaultValue tag
|
||||
if (reflection.comment) {
|
||||
const defaultTag = reflection.comment.getTag("@defaultValue");
|
||||
if (defaultTag) {
|
||||
const defaultText = getTagText(defaultTag).trim();
|
||||
|
||||
// If the value already contains backticks, use as-is
|
||||
if (defaultText.includes("`")) {
|
||||
prefix.push(`Default: ${defaultText}`);
|
||||
} else {
|
||||
prefix.push(`Default: \`${defaultText}\``);
|
||||
}
|
||||
|
||||
reflection.comment.removeTags("@defaultValue");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Complex union types: simplify all-string-literal unions
|
||||
let unionType = null;
|
||||
|
||||
if (isAllStringLiteralUnion(reflection.type)) {
|
||||
unionType = reflection.type;
|
||||
reflection.type = new IntrinsicType("string");
|
||||
} else if (
|
||||
reflection.type instanceof ArrayType &&
|
||||
isAllStringLiteralUnion(reflection.type.elementType)
|
||||
) {
|
||||
unionType = reflection.type.elementType;
|
||||
reflection.type = new ArrayType(new IntrinsicType("string"));
|
||||
}
|
||||
|
||||
if (unionType) {
|
||||
const values = unionType.types
|
||||
.map((t) => `\`"${t.value}"\``)
|
||||
.join(", ");
|
||||
prefix.push(`Possible values: ${values}`);
|
||||
}
|
||||
|
||||
// 4. Remarks: transform into availability notes
|
||||
if (reflection.comment) {
|
||||
const remarksTag = reflection.comment.getTag("@remarks");
|
||||
if (remarksTag) {
|
||||
const remarksText = getTagText(remarksTag);
|
||||
if (remarksText.trim()) {
|
||||
prefix.push(transformRemarks(remarksText));
|
||||
}
|
||||
reflection.comment.removeTags("@remarks");
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble prefix and prepend to comment summary
|
||||
if (prefix.length > 0) {
|
||||
if (!reflection.comment) {
|
||||
reflection.comment = new Comment();
|
||||
}
|
||||
|
||||
const prefixText = ["---", ...prefix].join("<br>");
|
||||
const existingSummary = reflection.comment.summary || [];
|
||||
|
||||
if (existingSummary.length > 0) {
|
||||
reflection.comment.summary = [
|
||||
...existingSummary,
|
||||
{ kind: "text", text: "<br>" + prefixText },
|
||||
];
|
||||
} else {
|
||||
reflection.comment.summary = [{ kind: "text", text: prefixText }];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ const eajsMarkdownConfig = {
|
||||
plugin: [
|
||||
"typedoc-plugin-mdn-links",
|
||||
"typedoc-plugin-markdown",
|
||||
"./typedoc-plugin-props-doc-transform.js",
|
||||
"./typedoc-plugin-markdown-prepare-for-embedding.js",
|
||||
],
|
||||
entryPoints: [
|
||||
@@ -32,7 +33,7 @@ const eajsMarkdownConfig = {
|
||||
typeAliasPropertiesFormat: "table",
|
||||
typeDeclarationFormat: "table",
|
||||
tableColumnSettings: {
|
||||
hideDefaults: false,
|
||||
hideDefaults: true,
|
||||
hideInherited: true,
|
||||
hideModifiers: true,
|
||||
hideOverrides: true,
|
||||
|
||||
3
.vscode/extensions.json
vendored
@@ -9,6 +9,7 @@
|
||||
"github.vscode-pull-request-github",
|
||||
"limejuly.vscode-yaml-block-highlight",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"styled-components.vscode-styled-components"
|
||||
"styled-components.vscode-styled-components",
|
||||
"vunguyentuan.vscode-css-variables"
|
||||
]
|
||||
}
|
||||
|
||||
3
.vscode/settings.json
vendored
@@ -4,5 +4,6 @@
|
||||
"jake.autoDetect": "off",
|
||||
"gulp.autoDetect": "off",
|
||||
"npm.autoDetect": "off",
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"cssVariables.lookupFiles": ["**/*.d.css", "node_modules/@mantine/core/*.css"]
|
||||
}
|
||||
|
||||
12
CLAUDE.md
@@ -25,6 +25,18 @@ For detailed guidance on writing and reviewing code and documentation, see the s
|
||||
|
||||
**Important**: When working with frontend code, read [frontend/CLAUDE.md](frontend/CLAUDE.md) for project-specific guidelines on component preferences, styling, TypeScript migration, testing requirements, and available scripts.
|
||||
|
||||
## Running Backend Tests
|
||||
|
||||
Use `./bin/test-agent` to run Clojure tests. It produces clean, plain-text output with no progress bars or ANSI codes.
|
||||
|
||||
```bash
|
||||
./bin/test-agent :only '[metabase.foo-test]' # run a namespace
|
||||
./bin/test-agent :only '[metabase.foo-test/some-test]' # run a single test
|
||||
./bin/test-agent :only '[metabase.foo-test metabase.bar-test]' # multiple namespaces
|
||||
```
|
||||
|
||||
Do not use `clj -X:dev:test` directly — its progress-bar output is hard to parse.
|
||||
|
||||
## Tool Preferences
|
||||
|
||||
If `clojure-mcp` tools are available, prefer them over shell-based alternatives for Clojure development.
|
||||
|
||||
71
README.md
@@ -10,20 +10,24 @@
|
||||
|
||||
## Get started
|
||||
|
||||
The easiest way to get started with Metabase is to sign up for a free trial of [Metabase Cloud](https://store.metabase.com/checkout). You get support, backups, upgrades, an SMTP server, SSL certificate, SoC2 Type 2 security auditing, and more (plus your money goes toward improving Metabase). Check out our quick overview of [cloud vs self-hosting](https://www.metabase.com/docs/latest/cloud/cloud-vs-self-hosting). If you need to, you can always switch to [self-hosting](https://www.metabase.com/docs/latest/installation-and-operation/installing-metabase) Metabase at any time (or vice versa).
|
||||
The easiest way to get started with Metabase is to sign up for a free trial of [Metabase Cloud](https://store.metabase.com/checkout).
|
||||
|
||||
You get expert support, backups, upgrades, an SMTP server, SSL certificate, SoC2 Type 2 security auditing, and more (plus your money goes toward improving a major open-source project). Check out our quick overview of [cloud vs self-hosting](https://www.metabase.com/docs/latest/cloud/cloud-vs-self-hosting). If you need to, you can always switch to [self-hosting](https://www.metabase.com/docs/latest/installation-and-operation/installing-metabase) Metabase at any time (or vice versa).
|
||||
|
||||
## Key Features
|
||||
|
||||
- [Set up in five minutes](https://www.metabase.com/docs/latest/configuring-metabase/setting-up-metabase) (we're not kidding).
|
||||
- [Set up in five minutes](https://www.metabase.com/docs/latest/configuring-metabase/setting-up-metabase) (we're not kidding), or have us [host Metabase for you](https://www.metabase.com/cloud/) so you don't even need to think about it.
|
||||
- Let anyone on your team [ask questions](https://www.metabase.com/docs/latest/questions/introduction) without knowing SQL.
|
||||
- Use the [SQL editor](https://www.metabase.com/docs/latest/questions/native-editor/writing-sql) for more complex queries.
|
||||
- Build handsome, interactive [dashboards](https://www.metabase.com/docs/latest/dashboards/introduction) with filters, auto-refresh, fullscreen, and custom click behavior.
|
||||
- Create [models](https://www.metabase.com/learn/metabase-basics/getting-started/models) that clean up, annotate, and/or combine raw tables.
|
||||
- Define canonical [segments and metrics](https://www.metabase.com/docs/latest/data-modeling/metrics) for your team to use.
|
||||
- Send data to Slack or email on a schedule with [dashboard subscriptions](https://www.metabase.com/docs/latest/dashboards/subscriptions).
|
||||
- Set up [alerts](https://www.metabase.com/docs/latest/questions/alerts) to have Metabase notify you when your data changes.
|
||||
- [Embed charts and dashboards](https://www.metabase.com/docs/latest/embedding/introduction) in your app, or even [your entire Metabase](https://www.metabase.com/docs/latest/embedding/interactive-embedding).
|
||||
- Build with the [Embedded Analytics SDK for React](https://www.metabase.com/docs/latest/embedding/sdk/introduction) to embed standalone components with custom styling that matches your app's design.
|
||||
- Ask AI: [Metabot](https://www.metabase.com/docs/latest/ai/metabot) gives you answers you can trust, helps you write queries, and more. Or build your own [AI agent](https://www.metabase.com/docs/latest/ai/agent-api) to query your data.
|
||||
- Build handsome, interactive [dashboards](https://www.metabase.com/docs/latest/dashboards/introduction) with filters, auto-refresh, fullscreen, custom click behavior, and more.
|
||||
- Use [documents](https://www.metabase.com/docs/latest/documents/introduction) for long-form data analysis, and invite people to comment.
|
||||
- [Transform](https://www.metabase.com/docs/latest/data-studio/transforms/transforms-overview) raw data into analytics-ready tables, track down broken dependencies, and define canonical metrics in Metabase's [Data Studio](https://www.metabase.com/docs/latest/data-studio/overview).
|
||||
- Set up [alerts on your data](https://www.metabase.com/docs/latest/questions/alerts), or schedule [dashboard subscriptions](https://www.metabase.com/docs/latest/dashboards/subscriptions) to email, Slack, or even a webhook.
|
||||
- Curate content in a [Library](https://www.metabase.com/docs/latest/data-studio/library), and [version your work with Git](https://www.metabase.com/docs/latest/installation-and-operation/remote-sync).
|
||||
- [Embed Metabase in your app](https://www.metabase.com/docs/latest/embedding/introduction), with components for charts, dashboards, data browser, AI chat, and more. You can even put [an entire Metabase](https://www.metabase.com/docs/latest/embedding/interactive-embedding) in your app.
|
||||
- Set granular [permissions](https://www.metabase.com/docs/latest/permissions/introduction) that work for both internal teams and embedded analytics, whether you co-locate your customer data, or give each customer their own database.
|
||||
- Dark mode, content translations, and way more stuff than we can list here.
|
||||
|
||||
Take a [tour of Metabase](https://www.metabase.com/learn/metabase-basics/overview/tour-of-metabase).
|
||||
|
||||
@@ -36,57 +40,22 @@ Take a [tour of Metabase](https://www.metabase.com/learn/metabase-basics/overvie
|
||||
|
||||
Metabase can be run just about anywhere. Check out our [Installation Guides](https://www.metabase.com/docs/latest/installation-and-operation/installing-metabase).
|
||||
|
||||
## Documentation
|
||||
|
||||
The [Metabase handbook](https://www.metabase.com/docs/latest/).
|
||||
|
||||
## Contributing
|
||||
|
||||
## Quick Setup: Dev environment
|
||||
To contribute to Metabase, see our [Developer docs](./docs/developers-guide/start.md).
|
||||
|
||||
In order to spin up a development environment, you need to start the front end and the backend as follows:
|
||||
## Extending Metabase
|
||||
|
||||
### Frontend quick setup
|
||||
|
||||
The following command will install the JavaScript dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To build and run without watching changes:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
To build and run with hot-reload:
|
||||
|
||||
```bash
|
||||
bun run build-hot
|
||||
```
|
||||
|
||||
### Backend quick setup
|
||||
|
||||
To run the backend, you'll need to build the drivers first, and then start the backend:
|
||||
|
||||
```bash
|
||||
./bin/build-drivers.sh
|
||||
clojure -M:run
|
||||
```
|
||||
|
||||
For a more detailed setup of a dev environment for Metabase, check out our [Developers Guide](./docs/developers-guide/start.md).
|
||||
Hit our API to integrate analytics. Check out our guide, [Working with the Metabase API](https://www.metabase.com/learn/metabase-basics/administration/administration-and-operation/metabase-api).
|
||||
|
||||
## Internationalization
|
||||
|
||||
We want Metabase to be available in as many languages as possible. See which translations are available and help contribute to internationalization using our project over at [Crowdin](https://crowdin.com/project/metabase-i18n). You can also check out our [policies on translations](https://www.metabase.com/docs/latest/administration-guide/localization.html).
|
||||
|
||||
## Extending Metabase
|
||||
|
||||
Hit our Query API from JavaScript to integrate analytics. Metabase enables your application to:
|
||||
|
||||
- Build moderation interfaces.
|
||||
- Export subsets of your users to third party marketing automation software.
|
||||
- Provide a custom customer lookup application for the people in your company.
|
||||
|
||||
Check out our guide, [Working with the Metabase API](https://www.metabase.com/learn/metabase-basics/administration/administration-and-operation/metabase-api).
|
||||
|
||||
## Security Disclosure
|
||||
|
||||
See [SECURITY.md](./SECURITY.md) for details.
|
||||
|
||||
65
bb.edn
@@ -162,6 +162,15 @@
|
||||
(p/shell "bin/pre-lint-migrations-file.sh")
|
||||
(lint-migrations-file/-main))}
|
||||
|
||||
new-migration
|
||||
{:doc "Create a new directory-based migration file (v60+)"
|
||||
:requires [[mage.new-migration :as new-migration]]
|
||||
:examples [["./bin/mage new-migration 60 mq_indexes" "Create resources/migrations/060/20260905_mq_indexes.yaml"]]
|
||||
:arg-schema [:tuple
|
||||
[:int {:name "version" :description "Major version number (e.g., 60)"}]
|
||||
[:string {:name "description" :description "Lowercase alphanumeric/underscore name (e.g., mq_indexes)"}]]
|
||||
:task (task! (new-migration/new-migration parsed))}
|
||||
|
||||
jar-download
|
||||
{:doc "Download (and optionally run) jar for a metabase version or branch"
|
||||
:examples [["./bin/mage jar-download 50" "Download the latest enterprise version of release 50 to ~/path/to/my/metabase/jars"]
|
||||
@@ -247,6 +256,14 @@
|
||||
:requires [[mage.util :as u]]
|
||||
:task (task! (u/print-tasks parsed))}
|
||||
|
||||
generate-cursive-resolve-as
|
||||
{:doc "Generate XML configuration for Cursive's symbol resolution based on the current clj-kondo lint-as configuration."
|
||||
:examples [["./bin/mage generate-cursive-resolve-as" "Generate resolve-as XML configuration"]
|
||||
["./bin/mage generate-cursive-resolve-as > /Users/bgrabow/Library/Application\\ Support/JetBrains/IntelliJIdea2025.3/options/ClojureResolveSettings.xml"
|
||||
"Generate resolve-as XML configuration and install it in your global editor configuration"]]
|
||||
:requires [[mage.cursive-resolve-as :as cursive-resolve-as]]
|
||||
:task (task! (cursive-resolve-as/do-it))}
|
||||
|
||||
analytics:setup
|
||||
{:doc "Set up usage analytics development mode"
|
||||
:examples [["./bin/mage analytics:setup" "Enable usage analytics dev mode and import content"]]
|
||||
@@ -302,7 +319,7 @@
|
||||
"If you see 'gh: command not found' or auth errors, run: gh auth login"))
|
||||
:requires [[mage.ci-report :as ci-report]]
|
||||
:task (task! (ci-report/generate-report! parsed))}
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Private Tasks:
|
||||
;; These tasks are more for internal tooling, e.g. for use from git hooks etc.
|
||||
@@ -334,12 +351,13 @@
|
||||
"RUN"
|
||||
(str "any of " (pr-str driver-triggers) " affected by cloud-driver module changes")]
|
||||
["7" "Cloud driver + query-processor updated" "RUN" "query-processor module updated"]
|
||||
["8" "Cloud driver, no relevant changes" "SKIP" "no relevant changes for cloud driver"]
|
||||
["9"
|
||||
["8" "Cloud driver + driver deps affected" "RUN" "driver module affected by shared code changes"]
|
||||
["9" "Cloud driver, no relevant changes" "SKIP" "no relevant changes for cloud driver"]
|
||||
["10"
|
||||
(str (pr-str driver-triggers) " module deps affected")
|
||||
"RUN"
|
||||
(str "any of " (pr-str driver-triggers) " affected by module changes")]
|
||||
["10" "Self-hosted driver, not affected" "SKIP" "driver module not affected"]])))))
|
||||
["11" "Self-hosted driver, not affected" "SKIP" "driver module not affected"]])))))
|
||||
:options [[nil "--git-ref REF" "The git ref to compute changed modules against"]
|
||||
[nil "--is-master-or-release MASTER_OR_RELEASE"]
|
||||
[nil "--pr-labels PR_LABELS" "Comma delimited labels on the pr"]
|
||||
@@ -525,4 +543,41 @@
|
||||
:requires [[mage.escherize.fix-whitespace :as fix-whitespace]]
|
||||
:examples [["mage -escherize-fix-whitespace '**/*.clj{,c,s}'" "Removes all lines like ' ' from all clj files"]]
|
||||
:arg-schema [:sequential [:string {:name "glob-pattern" :description "Glob pattern to match files"}]]
|
||||
:task (task! (fix-whitespace/run-clean parsed))}}}
|
||||
:task (task! (fix-whitespace/run-clean parsed))}
|
||||
|
||||
-edpaget-dev-env
|
||||
{:doc "Stand up (or tear down) an isolated dev environment for this worktree"
|
||||
:examples [["./bin/mage -edpaget-dev-env --app-db postgres" "Postgres app-db with auto ports"]
|
||||
["./bin/mage -edpaget-dev-env --app-db postgres --with mysql --with mongo" "Full stack"]
|
||||
["./bin/mage -edpaget-dev-env" "Minimal: just port config (H2 app-db)"]
|
||||
["./bin/mage -edpaget-dev-env --down" "Tear down containers for this worktree"]
|
||||
["./bin/mage -edpaget-dev-env --status" "Show running containers and ports"]]
|
||||
:requires [[mage.edpaget.dev-env :as dev-env]]
|
||||
:options [[nil "--app-db DB" "App database: h2 (default), postgres, mysql" :default "h2"]
|
||||
[nil "--with SERVICE" "Warehouse service (repeatable): postgres, mysql, mariadb, mongo, clickhouse, ldap, maildev"
|
||||
:multi true :default [] :update-fn conj]
|
||||
[nil "--down" "Tear down all containers for this worktree"]
|
||||
[nil "--status" "Show current env status"]
|
||||
[nil "--slot SLOT" "Override port slot (0-99)" :parse-fn Integer/parseInt]]
|
||||
:task (task! (dev-env/dev-env! parsed))}
|
||||
|
||||
-johnswanson-dev-env
|
||||
{:doc "Stand up (or tear down) an isolated dev environment for this worktree. Interactive prompts when flags omitted."
|
||||
:examples [["./bin/mage -johnswanson-dev-env" "Interactive TUI to configure infra (saves config)"]
|
||||
["./bin/mage -johnswanson-dev-env --run" "Configure + launch backend/frontend"]
|
||||
["./bin/mage -johnswanson-dev-env --run --no-frontend" "Configure + backend only"]
|
||||
["./bin/mage -johnswanson-dev-env --down" "Stop everything for this worktree"]
|
||||
["./bin/mage -johnswanson-dev-env --status" "Show running containers and processes"]]
|
||||
:requires [[mage.johnswanson.dev-env :as jds-dev-env]]
|
||||
:options [["-e" "--edition EDITION" "ee or oss (default: prompt)"]
|
||||
["-t" "--token TOKEN" "Token type: all-features, starter-cloud, pro-cloud, pro-self-hosted, none"]
|
||||
[nil "--app-db DB" "App database: h2, postgres, mysql, mariadb (default: prompt)"]
|
||||
[nil "--with SERVICE" "Warehouse service (repeatable): postgres, mysql, mariadb, mongo, clickhouse, ldap, maildev"
|
||||
:multi true :default [] :update-fn conj]
|
||||
[nil "--run" "Also launch backend + frontend in background"]
|
||||
[nil "--no-frontend" "Skip frontend dev server (only with --run)"]
|
||||
[nil "--down" "Stop everything for this worktree"]
|
||||
[nil "--status" "Show current env status"]
|
||||
[nil "--slot SLOT" "Override port slot (0-99)" :parse-fn Integer/parseInt]
|
||||
[nil "--fresh" "Recreate containers from scratch"]]
|
||||
:task (task! (jds-dev-env/dev-env! parsed))}}}
|
||||
|
||||
96
bin/claude-dangerous
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs Claude Code in a custom Docker container with --dangerously-skip-permissions.
|
||||
# The image includes Clojure, Java, Babashka, and all tools from mise.toml.
|
||||
# DB connection info is forwarded from mise.local.toml.
|
||||
#
|
||||
# Usage: workmux add --agent ./bin/claude-dangerous feat/my-branch
|
||||
#
|
||||
# Pass --build as the first arg to force a rebuild of the image.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
IMAGE_NAME="metabase-claude-code"
|
||||
DOCKERFILE="$PROJECT_ROOT/dev/docker/claude-code/Dockerfile"
|
||||
|
||||
# Build image if --build passed or image doesn't exist
|
||||
if [[ "${1:-}" == "--build" ]]; then
|
||||
shift
|
||||
echo "Building $IMAGE_NAME image..."
|
||||
docker build -t "$IMAGE_NAME" -f "$DOCKERFILE" "$PROJECT_ROOT"
|
||||
elif ! docker image inspect "$IMAGE_NAME" &>/dev/null; then
|
||||
echo "Image $IMAGE_NAME not found, building..."
|
||||
docker build -t "$IMAGE_NAME" -f "$DOCKERFILE" "$PROJECT_ROOT"
|
||||
fi
|
||||
|
||||
# On macOS, extract Claude OAuth credentials from the system keychain
|
||||
# and prepare a minimal .claude.json for the container.
|
||||
AUTH_TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$AUTH_TMPDIR"' EXIT
|
||||
|
||||
AUTH_MOUNT_ARGS=()
|
||||
if [[ "$OSTYPE" == darwin* ]]; then
|
||||
KEYCHAIN_CREDS=$(security find-generic-password -s "Claude Code-credentials" -a "$USER" -w 2>/dev/null || true)
|
||||
if [[ -n "$KEYCHAIN_CREDS" ]]; then
|
||||
mkdir -p "$AUTH_TMPDIR/.claude"
|
||||
echo "$KEYCHAIN_CREDS" > "$AUTH_TMPDIR/.claude/.credentials.json"
|
||||
AUTH_MOUNT_ARGS+=(-v "$AUTH_TMPDIR/.claude/.credentials.json:/home/claude/.claude/.credentials.json")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build a minimal .claude.json with onboarding complete and project trust.
|
||||
# Mounting the host's full ~/.claude.json won't work (host-specific paths).
|
||||
python3 -c "
|
||||
import json, os
|
||||
host_config = '$HOME/.claude.json'
|
||||
config = {}
|
||||
if os.path.exists(host_config):
|
||||
with open(host_config) as f:
|
||||
full = json.load(f)
|
||||
# Copy only the auth-related keys
|
||||
for key in ('oauthAccount', 'customApiKeyResponses'):
|
||||
if key in full:
|
||||
config[key] = full[key]
|
||||
config['hasCompletedOnboarding'] = True
|
||||
config['bypassPermissionsModeAccepted'] = True
|
||||
config['dangerouslySkipPermissions'] = True
|
||||
config['autoAcceptPermissions'] = True
|
||||
config['numStartups'] = 1
|
||||
config['projects'] = {
|
||||
'/app': {
|
||||
'allowedTools': [],
|
||||
'hasTrustDialogAccepted': True,
|
||||
'skipConfirmationDialogs': True,
|
||||
}
|
||||
}
|
||||
with open('$AUTH_TMPDIR/claude.json', 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
"
|
||||
AUTH_MOUNT_ARGS+=(-v "$AUTH_TMPDIR/claude.json:/home/claude/.claude.json")
|
||||
|
||||
# Parse mise.local.toml for env vars (DB connection, ports, etc.)
|
||||
ENV_ARGS=()
|
||||
if [[ -f "$PROJECT_ROOT/mise.local.toml" ]]; then
|
||||
while IFS='=' read -r key value; do
|
||||
key="$(echo "$key" | xargs)"
|
||||
value="$(echo "$value" | sed 's/^[[:space:]]*"//;s/"[[:space:]]*$//')"
|
||||
if [[ -n "$key" && -n "$value" ]]; then
|
||||
# Rewrite localhost to host.docker.internal so container can reach host DBs
|
||||
if [[ "$key" == "MB_DB_CONNECTION_URI" ]]; then
|
||||
value="${value//localhost/host.docker.internal}"
|
||||
fi
|
||||
ENV_ARGS+=(-e "$key=$value")
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+ *= *"' "$PROJECT_ROOT/mise.local.toml")
|
||||
fi
|
||||
|
||||
exec docker run -it --rm \
|
||||
-v "$PROJECT_ROOT:/app" \
|
||||
-w /app \
|
||||
-v /dev/null:/app/mise.local.toml:ro \
|
||||
"${AUTH_MOUNT_ARGS[@]}" \
|
||||
-e ANTHROPIC_API_KEY \
|
||||
"${ENV_ARGS[@]}" \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
"$IMAGE_NAME" \
|
||||
--dangerously-skip-permissions
|
||||
11
bin/e2e-backend
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${MB_JETTY_PORT:-4000}"
|
||||
|
||||
if curl -sf "http://localhost:$PORT/api/health" > /dev/null 2>&1; then
|
||||
echo "⚠️ Backend already running on :$PORT — reusing it. If you need a different MB_EDITION, kill it first: lsof -ti:$PORT | xargs kill"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec node e2e/runner/start-backend.js
|
||||
42
bin/e2e-stress-test
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
RUNS="${E2E_STRESS_RUNS:-5}"
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: bin/e2e-stress-test [cypress args...]"
|
||||
echo " e.g. bin/e2e-stress-test --spec e2e/test/scenarios/organization/bookmarks-dashboard.cy.spec.js"
|
||||
echo ""
|
||||
echo "Set E2E_STRESS_RUNS=N to change iteration count (default: 5)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf cypress/screenshots cypress/videos
|
||||
|
||||
for i in $(seq 1 "$RUNS"); do
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Run $i / $RUNS"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
if ! CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false GENERATE_SNAPSHOTS=false bun test-cypress "$@"; then
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " FAILED on run $i / $RUNS"
|
||||
echo "=========================================="
|
||||
|
||||
if ls cypress/screenshots/**/*.png 1>/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "Screenshots:"
|
||||
find cypress/screenshots -name "*.png" -print
|
||||
fi
|
||||
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " All $RUNS runs passed"
|
||||
echo "=========================================="
|
||||
@@ -18,16 +18,9 @@
|
||||
This format should no longer be used for new migrations, use `id-timestamp-format-re` instead."
|
||||
#"^v\d{2,}\.\d{2}-\d{3}$")
|
||||
|
||||
(s/def ::id (s/and string?
|
||||
(s/or
|
||||
;; new ids should conform this format
|
||||
:id-with-timestamp ;; e.g: v49.2023-12-14T08:54:54
|
||||
(s/and #(re-matches id-timestamp-format-re %))
|
||||
:id-with-old-version-format ;; e.g: v49.00-008
|
||||
(s/and #(re-matches id-number-format-re %)
|
||||
;; the cut off id for this old format is v49.00-060
|
||||
;; see #36787 for context
|
||||
#(neg? (compare % "v49.00-061"))))))
|
||||
;; Basic type check only — format enforcement is file-aware and happens in
|
||||
;; `lint-migrations-file/require-change-set-ids-match-file-format`.
|
||||
(s/def ::id string?)
|
||||
|
||||
(s/def ::author string?)
|
||||
|
||||
@@ -74,54 +67,6 @@
|
||||
[]))) ; provide an empty list if dbms-val is nil
|
||||
changes))))))
|
||||
|
||||
(def change-types-supporting-rollback
|
||||
"This set was generated with a little grep and awk from the docs here:
|
||||
https://docs.liquibase.com/workflows/liquibase-community/liquibase-auto-rollback.html
|
||||
|
||||
If a new change type is introduced that supports automatic rollback, it should be added
|
||||
to this set."
|
||||
#{:addCheckConstraint
|
||||
:addColumn
|
||||
:addDefaultValue
|
||||
:addForeignKeyConstraint
|
||||
:addLookupTable
|
||||
:addNotNullConstraint
|
||||
:addPrimaryKey
|
||||
:addUniqueConstraint
|
||||
:createIndex
|
||||
:createSequence
|
||||
:createSynonym
|
||||
:createTable
|
||||
:createView
|
||||
:disableCheckConstraint
|
||||
:disableTrigger
|
||||
:dropNotNullConstraint
|
||||
:enableCheckConstraint
|
||||
:enableTrigger
|
||||
:renameColumn
|
||||
:renameSequence
|
||||
:renameTable
|
||||
:renameTrigger
|
||||
:renameView
|
||||
;; assumes all custom changes use the `def-migration` or `define-reversible-migration` in
|
||||
;; [[metabase.app-db.custom-migrations]]
|
||||
:customChange})
|
||||
|
||||
(defn- major-version
|
||||
"Returns major version from id string, e.g. 44 from \"v44.00-034\""
|
||||
[id-str]
|
||||
(when (string? id-str)
|
||||
(some-> (re-find #"\d+" id-str) Integer/parseInt)))
|
||||
|
||||
(defn- rollback-present-when-required?
|
||||
"Ensures rollback key is present when change type doesn't support auto rollback"
|
||||
[{:keys [id changes] :as change-set}]
|
||||
(or
|
||||
(int? id)
|
||||
(< (major-version id) 45)
|
||||
(some change-types-supporting-rollback (mapcat keys changes))
|
||||
(contains? change-set :rollback)))
|
||||
|
||||
(defn- disallow-delete-cascade-with-add-column
|
||||
"Returns false if addColumn changeSet uses deleteCascade. See Metabase issue #14321"
|
||||
[{:keys [changes]}]
|
||||
@@ -135,7 +80,6 @@
|
||||
|
||||
(s/def ::change-set
|
||||
(s/and
|
||||
rollback-present-when-required?
|
||||
disallow-delete-cascade-with-add-column
|
||||
(s/keys :req-un [::id ::author ::changes ::comment]
|
||||
:opt-un [::preConditions])))
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
[clojure.spec.alpha :as s]
|
||||
[clojure.string :as str])
|
||||
(:import
|
||||
[clojure.lang ExceptionInfo]))
|
||||
(clojure.lang ExceptionInfo)
|
||||
(java.lang Integer)))
|
||||
|
||||
#_:clj-kondo/ignore
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -52,26 +53,58 @@
|
||||
(when (seq duplicates)
|
||||
(throw (validation-error "Change set IDs are not distinct." {:duplicates duplicates})))))
|
||||
|
||||
(defn- require-change-set-ids-in-order [change-log]
|
||||
(let [ids (change-set-ids change-log)
|
||||
out-of-order-ids (->> ids
|
||||
(partition 2 1)
|
||||
(filter (fn [[id1 id2]]
|
||||
(pos? (compare id1 id2)))))]
|
||||
(defn- directory-based-migration-file?
|
||||
"Returns true if the file is a directory-based migration file (e.g., `060/20260905_mq_indexes.yaml`)."
|
||||
[file]
|
||||
(boolean (re-matches #".*\d{3}/\d{8}_[a-z0-9_]+\.yaml$" (str file))))
|
||||
|
||||
(when (seq out-of-order-ids)
|
||||
(throw (validation-error "Change set IDs are not in order"
|
||||
{:out-of-order-ids out-of-order-ids})))))
|
||||
(defn- file-version
|
||||
"Extracts the migration version number from a file.
|
||||
For per-release files like `059_update_migrations.yaml`, parses from the filename.
|
||||
For directory-based files like `060/20260106_125531.yaml`, parses from the parent directory name."
|
||||
[^java.io.File file]
|
||||
(if (directory-based-migration-file? file)
|
||||
(parse-long (re-find #"\d+" (.getName (.getParentFile file))))
|
||||
(parse-long (re-find #"\d+" (.getName file)))))
|
||||
|
||||
(defn- changeset-version+id
|
||||
"Returns [version local-id] for a changeset.
|
||||
Parses from the ID itself, which should be of the form 'v{version}.{local-id}'."
|
||||
[_file changeset-id]
|
||||
(let [id-str (str changeset-id)]
|
||||
(when-let [[_ version local-id] (re-matches #"^v(\d+)\.(.+)$" id-str)]
|
||||
[(parse-long version) local-id])))
|
||||
|
||||
(defn- changeset-at-or-after?
|
||||
"Returns true if the changeset (identified by file context and changeset ID) is at or after
|
||||
the given [threshold-version threshold-id]."
|
||||
[file changeset-id threshold-version threshold-id]
|
||||
(when-let [[v local-id] (changeset-version+id file changeset-id)]
|
||||
(or (> v threshold-version)
|
||||
(and (= v threshold-version)
|
||||
(not (neg? (compare local-id threshold-id)))))))
|
||||
|
||||
(defn- require-change-set-ids-in-order [change-log file]
|
||||
(when-not (directory-based-migration-file? file)
|
||||
(let [ids (change-set-ids change-log)
|
||||
out-of-order-ids (->> ids
|
||||
(partition 2 1)
|
||||
(filter (fn [[id1 id2]]
|
||||
(pos? (compare id1 id2)))))]
|
||||
(when (seq out-of-order-ids)
|
||||
(throw (validation-error "Change set IDs are not in order"
|
||||
{:out-of-order-ids out-of-order-ids}))))))
|
||||
|
||||
(defn- require-change-set-ids-in-correct-file [change-log file]
|
||||
(let [file-version (parse-long (re-find #"\d+" (.getName file)))
|
||||
(let [fv (file-version file)
|
||||
ids (change-set-ids change-log)
|
||||
wrong-file-ids (->> ids
|
||||
(filter (fn [id]
|
||||
(let [id-version (parse-long (re-find #"\d+" id))]
|
||||
(if (= file-version 1)
|
||||
(> id-version 55)
|
||||
(not= file-version id-version))))))]
|
||||
wrong-file-ids
|
||||
(->> ids
|
||||
(filter (fn [id]
|
||||
(let [id-version (parse-long (re-find #"\d+" id))]
|
||||
(if (= fv 1)
|
||||
(> id-version 55)
|
||||
(not= fv id-version))))))]
|
||||
(when (seq wrong-file-ids)
|
||||
(throw (validation-error "Change set IDs are in the wrong file"
|
||||
{:wrong-file-ids wrong-file-ids})))))
|
||||
@@ -101,18 +134,19 @@
|
||||
(some #(check-change-use-types? target-types %) (get-in change-set [:changeSet :changes])))
|
||||
|
||||
(defn- require-no-types-in-change-log!
|
||||
"Returns true if none of the changes in the change-log contain usage of any type specified in `target-types`.
|
||||
|
||||
`id-filter-fn` is a function that takes an ID and return true if the changeset should be checked."
|
||||
"Throws if any changeset at or after [version id] uses a type in target-types.
|
||||
When version and id are not provided, checks all changesets."
|
||||
([target-types change-log]
|
||||
(require-no-types-in-change-log! target-types change-log (constantly true)))
|
||||
([target-types change-log id-filter-fn]
|
||||
(require-no-types-in-change-log! target-types change-log nil nil nil))
|
||||
([target-types change-log file version id]
|
||||
{:pre [(set? target-types)]}
|
||||
(when-let [using-types? (->> change-log
|
||||
(filter (fn [change-set]
|
||||
(let [id (get-in change-set [:changeSet :id])]
|
||||
(and (string? id)
|
||||
(id-filter-fn id)))))
|
||||
(let [cs-id (get-in change-set [:changeSet :id])]
|
||||
(and cs-id
|
||||
(if version
|
||||
(changeset-at-or-after? file cs-id version id)
|
||||
true)))))
|
||||
(filter #(check-change-set-use-types? target-types %))
|
||||
(map #(get-in % [:changeSet :id]))
|
||||
seq)]
|
||||
@@ -132,17 +166,46 @@
|
||||
(defn require-no-bare-boolean-types
|
||||
"Ensures that no \"boolean\" type columns are added in changesets with id later than v49.00-032. From that point on,
|
||||
\"${boolean.type}\" should be used instead, so that we can consistently use `BIT(1)` for Boolean columns on MySQL."
|
||||
[change-log]
|
||||
(require-no-types-in-change-log! #{"boolean"} change-log #(pos? (compare % "v49.00-032"))))
|
||||
[change-log file]
|
||||
(require-no-types-in-change-log! #{"boolean"} change-log file 49 "00-032"))
|
||||
|
||||
(defn require-no-datetime-type
|
||||
"Ensures that no \"datetime\" or \"timestamp without time zone\".
|
||||
From that point on, \"${timestamp_type}\" should be used instead, so that all of our time related columns are tz-aware."
|
||||
[change-log]
|
||||
[change-log file]
|
||||
(require-no-types-in-change-log!
|
||||
#{"datetime" "timestamp" "timestamp without time zone"}
|
||||
change-log
|
||||
#(pos? (compare % "v49.00-000"))))
|
||||
change-log file 49 "00-000"))
|
||||
|
||||
(def ^:private directory-based-id-format-re
|
||||
"For directory-based migration files (v60+), IDs must be prefixed with the correct version.
|
||||
E.g., `v60.my-migration-name` or `v60.1`"
|
||||
#"^v\d{2,}\..+$")
|
||||
|
||||
(defn- require-change-set-ids-match-file-format
|
||||
"Enforces that changeset IDs use the correct format for the file type:
|
||||
- Directory-based files (v60+): IDs must be prefixed with v{version}. (e.g., v60.anything)
|
||||
- 001_update_migrations.yaml: any string ID is allowed (legacy file)
|
||||
- Other per-release files: IDs must match the timestamp format"
|
||||
[change-log file]
|
||||
(cond
|
||||
(directory-based-migration-file? file)
|
||||
(let [ids (change-set-ids change-log)
|
||||
bad-ids (remove #(re-matches directory-based-id-format-re %) ids)]
|
||||
(when (seq bad-ids)
|
||||
(throw (validation-error
|
||||
(format "Directory-based migration file contains IDs without v{version}. prefix: %s"
|
||||
(str/join ", " bad-ids))
|
||||
{:invalid-ids (vec bad-ids)}))))
|
||||
|
||||
(not= (file-version file) 1)
|
||||
(let [ids (change-set-ids change-log)
|
||||
bad-ids (remove #(re-matches change-set.strict/id-timestamp-format-re %) ids)]
|
||||
(when (seq bad-ids)
|
||||
(throw (validation-error
|
||||
(format "Per-release migration file contains non-timestamp ID formats: %s"
|
||||
(str/join ", " bad-ids))
|
||||
{:invalid-ids (vec bad-ids)}))))))
|
||||
|
||||
(defn require-primary-key-exists-has-table-name
|
||||
"Ensures that all primaryKeyExists preconditions specify a tableName."
|
||||
@@ -165,25 +228,82 @@
|
||||
(throw (validation-error "Expected exactly one key." {:keys keys})))
|
||||
(first keys)))
|
||||
|
||||
(defn- major-version
|
||||
"Returns major version from id string, e.g. 44 from \"v44.00-034\".
|
||||
For directory-based migrations (file path matches `NNN/`), extracts
|
||||
version from the file path. Otherwise parses from the id string."
|
||||
[id-str file]
|
||||
(if-let [[_ file-version] (when file (re-find #"(\d{3})[/\\]" (str file)))]
|
||||
(Integer/parseInt file-version)
|
||||
(when (string? id-str)
|
||||
(some-> (re-find #"\d+" id-str) Integer/parseInt))))
|
||||
|
||||
(def change-types-supporting-rollback
|
||||
"This set was generated with a little grep and awk from the docs here:
|
||||
https://docs.liquibase.com/workflows/liquibase-community/liquibase-auto-rollback.html
|
||||
|
||||
If a new change type is introduced that supports automatic rollback, it should be added
|
||||
to this set."
|
||||
#{:addCheckConstraint
|
||||
:addColumn
|
||||
:addDefaultValue
|
||||
:addForeignKeyConstraint
|
||||
:addLookupTable
|
||||
:addNotNullConstraint
|
||||
:addPrimaryKey
|
||||
:addUniqueConstraint
|
||||
:createIndex
|
||||
:createSequence
|
||||
:createSynonym
|
||||
:createTable
|
||||
:createView
|
||||
:disableCheckConstraint
|
||||
:disableTrigger
|
||||
:dropNotNullConstraint
|
||||
:enableCheckConstraint
|
||||
:enableTrigger
|
||||
:renameColumn
|
||||
:renameSequence
|
||||
:renameTable
|
||||
:renameTrigger
|
||||
:renameView
|
||||
;; assumes all custom changes use the `def-migration` or `define-reversible-migration` in
|
||||
;; [[metabase.app-db.custom-migrations]]
|
||||
:customChange})
|
||||
|
||||
(defn- rollback-present-when-required?
|
||||
"Ensures rollback key is present when change type doesn't support auto rollback.
|
||||
`file` is used to extract version for directory-based migrations."
|
||||
[{:keys [id changes] :as change-set} file]
|
||||
(or
|
||||
(let [v (major-version (str id) file)]
|
||||
(and v (< v 45)))
|
||||
(some change-types-supporting-rollback (mapcat keys changes))
|
||||
(contains? change-set :rollback)))
|
||||
|
||||
(defn- validate-database-change-log [change-log file]
|
||||
(when (string? change-log)
|
||||
(throw (validation-error "Expected `:databaseChangeLog` to be a map, not a string.")))
|
||||
|
||||
(require-distinct-change-set-ids change-log)
|
||||
(require-change-set-ids-in-correct-file change-log file)
|
||||
(require-change-set-ids-in-order change-log)
|
||||
(require-change-set-ids-in-order change-log file)
|
||||
(require-change-set-ids-match-file-format change-log file)
|
||||
(require-no-bare-blob-or-text-types change-log)
|
||||
(require-no-bare-boolean-types change-log)
|
||||
(require-no-datetime-type change-log)
|
||||
(require-no-bare-boolean-types change-log file)
|
||||
(require-no-datetime-type change-log file)
|
||||
(require-primary-key-exists-has-table-name change-log)
|
||||
(let [{:keys [changeSet]
|
||||
:as all} (group-by only-key change-log)]
|
||||
(when-not (set/subset? (set (keys all)) #{:property :objectQuotingStrategy :changeSet})
|
||||
(throw (validation-error "Expected exactly one of :property, :objectQuotingStrategy, or :changeSet."
|
||||
(when-not (set/subset? (set (keys all)) #{:property :objectQuotingStrategy :changeSet :logicalFilePath})
|
||||
(throw (validation-error "Expected exactly one of :property, :objectQuotingStrategy, :logicalFilePath, or :changeSet."
|
||||
{:keys (keys all)})))
|
||||
(doseq [{:keys [changeSet]} changeSet
|
||||
:when (not (s/valid? ::changeSet changeSet))]
|
||||
(throw (validation-error "Invalid change set." (s/explain-data ::changeSet changeSet))))))
|
||||
(doseq [{:keys [changeSet]} changeSet]
|
||||
(when-not (s/valid? ::changeSet changeSet)
|
||||
(throw (validation-error "Invalid change set." (s/explain-data ::changeSet changeSet))))
|
||||
(when-not (rollback-present-when-required? changeSet file)
|
||||
(throw (validation-error "Rollback is required but not present."
|
||||
{:id (:id changeSet)}))))))
|
||||
|
||||
(defn- validate-migrations [migrations file]
|
||||
(require-database-change-log! migrations)
|
||||
@@ -195,7 +315,14 @@
|
||||
(let [dir-str #?(:bb "resources/migrations" :clj "../../resources/migrations")
|
||||
dir (io/file dir-str)]
|
||||
(->> (file-seq dir)
|
||||
(filter #(and (.isFile %) (str/ends-with? (.getName %) "_update_migrations.yaml"))))))
|
||||
(filter (fn [^java.io.File f]
|
||||
(and (.isFile f)
|
||||
(or
|
||||
;; Per-release files: 059_update_migrations.yaml
|
||||
(str/ends-with? (.getName f) "_update_migrations.yaml")
|
||||
;; Directory-based files: 060/20260106_125531.yaml
|
||||
(directory-based-migration-file? f)))))
|
||||
(sort-by str))))
|
||||
|
||||
(defn- migrations [file]
|
||||
(assert (.exists file) (format "%s does not exist" file))
|
||||
@@ -207,13 +334,22 @@
|
||||
(fix-vals (yaml/parse-string
|
||||
#_:clj-kondo/ignore (slurp file)))))
|
||||
|
||||
(defn- display-name
|
||||
"Returns a human-readable name for a migration file.
|
||||
For per-release files, returns just the filename.
|
||||
For directory-based files, returns `parent/filename`."
|
||||
[^java.io.File file]
|
||||
(if (directory-based-migration-file? file)
|
||||
(str (.getName (.getParentFile file)) "/" (.getName file))
|
||||
(.getName file)))
|
||||
|
||||
(defn- validate-all []
|
||||
(doseq [file (migration-files)]
|
||||
(println "Validating" (.getName file) "...")
|
||||
(println "Validating" (display-name file) "...")
|
||||
(try
|
||||
(validate-migrations (migrations file) file)
|
||||
(catch ExceptionInfo e
|
||||
(throw (ex-info (.getMessage e) (assoc (ex-data e) :file (.getName file)) e))))))
|
||||
(throw (ex-info (.getMessage e) (assoc (ex-data e) :file (display-name file)) e))))))
|
||||
|
||||
(defn -main
|
||||
"Entry point for Clojure CLI task `lint-migrations-file`. Run it with
|
||||
|
||||
@@ -200,17 +200,19 @@
|
||||
(validate-id "v49.2024-01-01T10:30:00"))))
|
||||
|
||||
(testing "invalid date components should throw an error"
|
||||
(are [msg id]
|
||||
(thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"Invalid change set\."
|
||||
(validate-id "v49.2024-30-01T10:30:00")
|
||||
msg)
|
||||
"invalid month" "v49.2024-13-01T10:30:00"
|
||||
"invalid day" "v49.2024-01-32T10:30:00"
|
||||
"invalid hour" "v49.2024-01-01T25:30:00"
|
||||
"invalid minute" "v49.2024-01-01T10:60:00"
|
||||
"invalid second" "v49.2024-01-01T10:30:60"))))
|
||||
(let [validate-id-strict (fn [id]
|
||||
(validate-file (io/file "049_update_migrations.yaml")
|
||||
(mock-change-set :id id)))]
|
||||
(are [msg id]
|
||||
(thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"non-timestamp ID formats"
|
||||
(validate-id-strict id))
|
||||
"invalid month" "v49.2024-13-01T10:30:00"
|
||||
"invalid day" "v49.2024-01-32T10:30:00"
|
||||
"invalid hour" "v49.2024-01-01T25:30:00"
|
||||
"invalid minute" "v49.2024-01-01T10:60:00"
|
||||
"invalid second" "v49.2024-01-01T10:30:60")))))
|
||||
|
||||
(deftest ^:parallel validate-id-in-file-test
|
||||
(letfn [(validate-id [id file]
|
||||
@@ -285,7 +287,7 @@
|
||||
(testing "missing rollback key fails"
|
||||
(is (thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"Invalid change set\."
|
||||
#"Rollback is required but not present\."
|
||||
(validate (update (mock-change-set :id "v49.2024-01-01T10:30:00" :changes [{:sql {:sql "select 1"}}])
|
||||
:changeSet dissoc :rollback)))))
|
||||
(testing "nil rollback is allowed"
|
||||
|
||||
31
bin/test-agent
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Agent-friendly test runner for Metabase backend (Clojure) tests.
|
||||
#
|
||||
# Differences from a normal `clj -X:dev:test` run:
|
||||
# - Uses HAWK_MODE=cli/ci so eftest.report.pretty is used instead of the
|
||||
# progress-bar reporter. Failures print inline as they happen.
|
||||
# - Strips ANSI escape codes from output so results are plain text.
|
||||
# - Filters Maven download noise and reflection warnings.
|
||||
#
|
||||
# Usage:
|
||||
# bin/test-agent :only '[metabase.foo-test]'
|
||||
# bin/test-agent :only '[metabase.foo-test/some-test]'
|
||||
# bin/test-agent # runs all tests (rarely what you want)
|
||||
#
|
||||
# Any extra arguments are forwarded directly to `clojure -X:dev:ee:ee-dev:test`.
|
||||
# The :test alias uses port 0 (OS-assigned random port) so parallel runs never collide.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export HAWK_MODE=cli/ci
|
||||
|
||||
# Filter noise and strip ANSI escape sequences
|
||||
clean_output() {
|
||||
grep -v '^Downloading: ' \
|
||||
| grep -v '^Reflection warning,' \
|
||||
| grep -v '^Excluding directory' \
|
||||
| grep -v '^Looking for test namespaces in directory' \
|
||||
| sed $'s/\033[\[(][0-9;]*[A-Za-z]//g; s/\033[^[]*//g'
|
||||
}
|
||||
|
||||
exec clojure -X:dev:ee:ee-dev:test "$@" 2>&1 | clean_output
|
||||
55
cross-version/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Cross-Version Migration Testing
|
||||
|
||||
Tests Metabase database migrations across version upgrades and downgrades.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Test upgrade (OSS)
|
||||
./test.sh --source v0.58.6 --target v0.58.7
|
||||
|
||||
# Test downgrade (OSS)
|
||||
./test.sh --source v0.58.7 --target v0.58.6
|
||||
|
||||
# Test Enterprise edition (use v1.x.x)
|
||||
./test.sh --source v1.58.6 --target v1.58.7
|
||||
```
|
||||
|
||||
Edition is inferred from version prefix: `v0.x.x` = OSS, `v1.x.x` = EE.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker
|
||||
- Docker Compose v2
|
||||
- Bun (for version helpers CLI)
|
||||
|
||||
## How It Works
|
||||
|
||||
### Upgrade Test
|
||||
1. Start PostgreSQL container
|
||||
2. Start SOURCE Metabase version, wait for healthy
|
||||
3. Stop SOURCE container (PostgreSQL persists)
|
||||
4. Start TARGET version - migrations run automatically
|
||||
5. Verify health check passes
|
||||
|
||||
### Downgrade Test
|
||||
1. Start PostgreSQL container
|
||||
2. Start SOURCE (newer) Metabase version, wait for healthy
|
||||
3. Stop SOURCE container
|
||||
4. Start TARGET (older) version - expects refusal
|
||||
5. Run `migrate down` command
|
||||
6. Start TARGET version again
|
||||
7. Verify health check passes
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `METABASE_PORT` | 3000 | Port to expose Metabase |
|
||||
| `HEALTH_TIMEOUT` | 120 | Seconds to wait for health check |
|
||||
|
||||
## CI
|
||||
|
||||
The GitHub workflow (`.github/workflows/cross-version.yml`) supports:
|
||||
- **Manual trigger**: Specify source/target versions
|
||||
- **Nightly schedule**: Runs predefined version matrix
|
||||
19
cross-version/cli.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bun
|
||||
import {
|
||||
compareVersions,
|
||||
getDockerImage,
|
||||
getMajorVersion,
|
||||
} from "../release/src/cross-version-helpers";
|
||||
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
|
||||
if (cmd === "compare") {
|
||||
console.log(compareVersions(args[0], args[1]));
|
||||
} else if (cmd === "image") {
|
||||
console.log(getDockerImage(args[0]));
|
||||
} else if (cmd === "major") {
|
||||
console.log(getMajorVersion(args[0]));
|
||||
} else {
|
||||
console.error("Usage: cli.ts <compare|image|major> [args]");
|
||||
process.exit(1);
|
||||
}
|
||||
32
cross-version/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: metabase
|
||||
POSTGRES_PASSWORD: metabase
|
||||
POSTGRES_DB: metabase
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U metabase"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
metabase:
|
||||
image: ${METABASE_IMAGE:-metabase/metabase:latest}
|
||||
ports:
|
||||
- "${METABASE_PORT:-3000}:3000"
|
||||
environment:
|
||||
MB_DB_TYPE: postgres
|
||||
MB_DB_DBNAME: metabase
|
||||
MB_DB_PORT: 5432
|
||||
MB_DB_USER: metabase
|
||||
MB_DB_PASS: metabase
|
||||
MB_DB_HOST: postgres
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
383
cross-version/test.sh
Executable file
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Cross-version migration testing for Metabase
|
||||
#
|
||||
# Usage:
|
||||
# ./test.sh --source v0.58.6 --target v0.58.7
|
||||
#
|
||||
# Edition (OSS/EE) is inferred from version prefix (v0.x = OSS, v1.x = EE).
|
||||
# Direction (upgrade/downgrade) is inferred from version comparison.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Defaults
|
||||
SOURCE_VERSION=""
|
||||
TARGET_VERSION=""
|
||||
METABASE_PORT="${METABASE_PORT:-3000}"
|
||||
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-120}"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log() { echo -e "${GREEN}[cross-version]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[cross-version]${NC} $*"; }
|
||||
error() { echo -e "${RED}[cross-version]${NC} $*" >&2; }
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 --source VERSION --target VERSION
|
||||
|
||||
Options:
|
||||
--source VERSION Source version (e.g., v0.58.6 for OSS, v1.58.6 for EE)
|
||||
--target VERSION Target version (e.g., v0.58.7 for OSS, v1.58.7 for EE)
|
||||
--help Show this help
|
||||
|
||||
Examples:
|
||||
$0 --source v0.58.6 --target v0.58.7 # Upgrade test (OSS)
|
||||
$0 --source v0.58.7 --target v0.58.6 # Downgrade test (OSS)
|
||||
$0 --source v1.58.6 --target v1.58.7 # Upgrade test (EE)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--source) SOURCE_VERSION="$2"; shift 2 ;;
|
||||
--target) TARGET_VERSION="$2"; shift 2 ;;
|
||||
--help) usage ;;
|
||||
*) error "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$SOURCE_VERSION" || -z "$TARGET_VERSION" ]]; then
|
||||
error "Both --source and --target are required"
|
||||
usage
|
||||
fi
|
||||
|
||||
# CLI wrapper for version helpers (uses tested TypeScript implementation)
|
||||
cli() {
|
||||
bun "$SCRIPT_DIR/cli.ts" "$@"
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
local timeout="$1"
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log "Waiting for Metabase to be healthy (timeout: ${timeout}s)..."
|
||||
|
||||
while true; do
|
||||
local current_time=$(date +%s)
|
||||
local elapsed=$((current_time - start_time))
|
||||
|
||||
if (( elapsed >= timeout )); then
|
||||
error "Health check timed out after ${timeout}s"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if curl -sf "http://localhost:${METABASE_PORT}/api/health" > /dev/null 2>&1; then
|
||||
local response=$(curl -sf "http://localhost:${METABASE_PORT}/api/health")
|
||||
if [[ "$response" == '{"status":"ok"}' ]]; then
|
||||
log "Health check passed after ${elapsed}s"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
# Start Metabase with a specific image
|
||||
start_metabase() {
|
||||
local image="$1"
|
||||
log "Starting Metabase with image: $image"
|
||||
|
||||
METABASE_IMAGE="$image" METABASE_PORT="$METABASE_PORT" \
|
||||
docker compose up -d --quiet-pull
|
||||
}
|
||||
|
||||
# Stop Metabase (but keep postgres)
|
||||
stop_metabase() {
|
||||
log "Stopping Metabase container..."
|
||||
docker compose stop metabase
|
||||
docker compose rm -f metabase
|
||||
}
|
||||
|
||||
# Get edition prefix from version (0 for OSS, 1 for EE)
|
||||
get_edition_prefix() {
|
||||
local version="$1"
|
||||
if [[ "$version" == "HEAD" ]]; then
|
||||
echo "1" # HEAD is EE
|
||||
elif [[ "$version" =~ ^v([01])\. ]]; then
|
||||
echo "${BASH_REMATCH[1]}"
|
||||
else
|
||||
error "Cannot determine edition from version: $version"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# https://www.metabase.com/docs/latest/installation-and-operation/upgrading-metabase#using-the-migrate-down-command
|
||||
# Run a single migrate down step (rolls back exactly one major version)
|
||||
# This function is idempotent - it takes a version string and handles everything internally.
|
||||
migrate_down_step() {
|
||||
local version="$1"
|
||||
local image is_head="false"
|
||||
|
||||
# Resolve image from version
|
||||
image=$(cli image "$version") || return 1
|
||||
[[ "$version" == "HEAD" ]] && is_head="true"
|
||||
|
||||
log "Running 'migrate down' for version: $version (image: $image)"
|
||||
|
||||
local output
|
||||
local exit_code=0
|
||||
|
||||
# The HEAD Docker image has version "vUNKNOWN" in its version.properties,
|
||||
# which causes `migrate down` to fail with a NullPointerException when trying
|
||||
# to determine the current major version. To work around this, we extract the
|
||||
# JAR, replace version.properties with the upcoming version, and mount the
|
||||
# modified JAR.
|
||||
#
|
||||
# HEAD is the next major version (CURRENT_VERSION + 1).
|
||||
#
|
||||
# See DEV-1636 for a potential backend fix that would make this unnecessary.
|
||||
if [[ "$is_head" == "true" ]]; then
|
||||
if [[ -z "$CURRENT_VERSION" ]]; then
|
||||
error "CURRENT_VERSION must be set when testing HEAD downgrades"
|
||||
return 1
|
||||
fi
|
||||
local next_version=$((CURRENT_VERSION + 1))
|
||||
local upcoming_version="v1.${next_version}.0"
|
||||
local work_dir="/tmp/metabase-head-jar"
|
||||
local modified_jar="$work_dir/metabase.jar"
|
||||
|
||||
log "HEAD JAR needs patching (version.properties says 'vUNKNOWN', setting to $upcoming_version)"
|
||||
rm -rf "$work_dir"
|
||||
mkdir -p "$work_dir"
|
||||
|
||||
# Extract JAR from container (copy to host with proper permissions)
|
||||
docker create --name metabase-jar-extract "$image" >/dev/null
|
||||
docker cp metabase-jar-extract:/app/metabase.jar "$modified_jar"
|
||||
docker rm metabase-jar-extract >/dev/null
|
||||
|
||||
# Create version.properties with upcoming version and update the JAR
|
||||
echo "tag=$upcoming_version" > "$work_dir/version.properties"
|
||||
echo "hash=HEAD" >> "$work_dir/version.properties"
|
||||
echo "date=$(date +%Y-%m-%d)" >> "$work_dir/version.properties"
|
||||
(cd "$work_dir" && zip -u metabase.jar version.properties >/dev/null)
|
||||
|
||||
log "HEAD JAR patched - proceeding with migrate down"
|
||||
|
||||
output=$(METABASE_IMAGE="$image" docker compose run --rm --quiet-pull \
|
||||
-v "$modified_jar:/app/metabase.jar:ro" \
|
||||
metabase "migrate down" 2>&1) || exit_code=$?
|
||||
else
|
||||
output=$(METABASE_IMAGE="$image" docker compose run --rm --quiet-pull metabase "migrate down" 2>&1) || exit_code=$?
|
||||
fi
|
||||
|
||||
# Always print output for visibility
|
||||
echo "$output"
|
||||
|
||||
# Check for errors - Metabase returns 0 even on partial rollback failures
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
error "migrate down failed with exit code $exit_code"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if echo "$output" | grep -q "ERROR.*liquibase\|RollbackFailedException\|Command failed with exception"; then
|
||||
error "migrate down encountered errors (check logs above)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Cascading migrate down: rolls back multiple major versions one at a time
|
||||
# Metabase only supports rolling back one major version per `migrate down` call,
|
||||
# so we need to iterate through intermediate versions.
|
||||
cascading_migrate_down() {
|
||||
local source_version="$1"
|
||||
local target_version="$2"
|
||||
|
||||
local source_major target_major edition_prefix
|
||||
source_major=$(cli major "$source_version") || return 1
|
||||
target_major=$(cli major "$target_version") || return 1
|
||||
edition_prefix=$(get_edition_prefix "$source_version") || return 1
|
||||
|
||||
local steps=$((source_major - target_major))
|
||||
log "Cascading migrate down: v$source_major → v$target_major ($steps step(s))"
|
||||
|
||||
# Build version sequence: source version first, then intermediate rolling versions
|
||||
local versions=("$source_version")
|
||||
local current=$((source_major - 1))
|
||||
while (( current > target_major )); do
|
||||
versions+=("v${edition_prefix}.${current}.x")
|
||||
((current--))
|
||||
done
|
||||
|
||||
# Simple loop - each step handles its own image resolution
|
||||
local step=1
|
||||
for version in "${versions[@]}"; do
|
||||
log "--------------------------------------------"
|
||||
log "Cascade step $step/$steps: $version"
|
||||
log "--------------------------------------------"
|
||||
if ! migrate_down_step "$version"; then
|
||||
error "Migrate down failed at step $step ($version)"
|
||||
return 1
|
||||
fi
|
||||
((step++))
|
||||
done
|
||||
|
||||
log "--------------------------------------------"
|
||||
log "Cascading migrate down complete"
|
||||
}
|
||||
|
||||
# Check if Metabase refuses to start (downgrade detection)
|
||||
check_downgrade_refused() {
|
||||
local timeout=60
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log "Checking if Metabase detects downgrade and refuses to start..."
|
||||
|
||||
while true; do
|
||||
local current_time=$(date +%s)
|
||||
local elapsed=$((current_time - start_time))
|
||||
|
||||
if (( elapsed >= timeout )); then
|
||||
error "Timed out waiting for downgrade detection"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check container logs for downgrade error
|
||||
local logs=$(docker compose logs metabase 2>&1 || true)
|
||||
|
||||
if echo "$logs" | grep -qi "migrate.*down\|downgrade\|database.*newer"; then
|
||||
log "Metabase correctly detected downgrade scenario"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local status=$(docker compose ps metabase --format '{{.Status}}' 2>/dev/null || echo "")
|
||||
if [[ "$status" =~ [Ee]xit ]]; then
|
||||
log "Metabase container exited (expected for downgrade)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
log "Cleaning up..."
|
||||
docker compose down -v --remove-orphans 2>/dev/null || true
|
||||
}
|
||||
|
||||
check_image_exists() {
|
||||
local image="$1"
|
||||
if ! docker manifest inspect "$image" > /dev/null 2>&1; then
|
||||
error "Docker image not found: $image"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local direction source_image target_image
|
||||
|
||||
direction=$(cli compare "$SOURCE_VERSION" "$TARGET_VERSION") || exit 1
|
||||
source_image=$(cli image "$SOURCE_VERSION") || exit 1
|
||||
target_image=$(cli image "$TARGET_VERSION") || exit 1
|
||||
|
||||
if [[ "$direction" == "same" ]]; then
|
||||
error "Source and target versions are the same"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Checking if Docker images exist..."
|
||||
check_image_exists "$source_image"
|
||||
check_image_exists "$target_image"
|
||||
|
||||
log "============================================"
|
||||
log "Cross-Version Migration Test"
|
||||
log "============================================"
|
||||
log "Direction: $direction"
|
||||
log "Source: $source_image"
|
||||
log "Target: $target_image"
|
||||
log "============================================"
|
||||
|
||||
# Ensure clean state
|
||||
cleanup
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
log ""
|
||||
log "Step 1: Starting SOURCE version ($SOURCE_VERSION)..."
|
||||
start_metabase "$source_image"
|
||||
|
||||
if ! wait_for_health "$HEALTH_TIMEOUT"; then
|
||||
error "❌ SOURCE version ($SOURCE_VERSION) failed health check"
|
||||
docker compose logs metabase
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "✅ SOURCE version ($SOURCE_VERSION) is healthy"
|
||||
|
||||
log ""
|
||||
log "Step 2: Stopping SOURCE version ($SOURCE_VERSION)..."
|
||||
stop_metabase
|
||||
|
||||
log ""
|
||||
log "Step 3: Starting TARGET version ($TARGET_VERSION)..."
|
||||
start_metabase "$target_image"
|
||||
|
||||
if [[ "$direction" == "upgrade" ]]; then
|
||||
# Upgrade: should migrate automatically and become healthy
|
||||
if ! wait_for_health "$HEALTH_TIMEOUT"; then
|
||||
error "❌ TARGET version ($TARGET_VERSION) failed health check after upgrade"
|
||||
docker compose logs metabase
|
||||
exit 1
|
||||
fi
|
||||
log "✅ UPGRADE successful - TARGET version ($TARGET_VERSION) is healthy"
|
||||
|
||||
else
|
||||
# Downgrade: should refuse to start, then we run migrate down
|
||||
if ! check_downgrade_refused; then
|
||||
error "❌ TARGET version ($TARGET_VERSION) did not properly detect downgrade"
|
||||
docker compose logs metabase
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop the failed container
|
||||
stop_metabase
|
||||
|
||||
log ""
|
||||
log "============================================"
|
||||
log "Step 4: Rolling back database ($SOURCE_VERSION → $TARGET_VERSION)..."
|
||||
log "============================================"
|
||||
if ! cascading_migrate_down "$SOURCE_VERSION" "$TARGET_VERSION"; then
|
||||
error "❌ migrate down failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Try starting again
|
||||
log ""
|
||||
log "Step 5: Starting TARGET version ($TARGET_VERSION) after migrate down..."
|
||||
start_metabase "$target_image"
|
||||
|
||||
if ! wait_for_health "$HEALTH_TIMEOUT"; then
|
||||
error "❌ TARGET version ($TARGET_VERSION) failed health check after migrate down"
|
||||
docker compose logs metabase
|
||||
exit 1
|
||||
fi
|
||||
log "✅ DOWNGRADE successful - TARGET version ($TARGET_VERSION) is healthy after migrate down"
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "============================================"
|
||||
log "✅ TEST PASSED"
|
||||
log "============================================"
|
||||
}
|
||||
|
||||
main
|
||||
70
deps.edn
@@ -47,13 +47,15 @@
|
||||
com.github.seancorfield/honeysql {:mvn/version "2.7.1350"} ; Honey SQL 2. SQL generation from Clojure data maps
|
||||
com.github.seancorfield/next.jdbc {:mvn/version "1.3.1070"} ; Talk to JDBC DBs
|
||||
com.github.steffan-westcott/clj-otel-api {:mvn/version "0.2.8"} ; Telemetry library
|
||||
com.github.steffan-westcott/clj-otel-exporter-otlp {:mvn/version "0.2.8"} ; OTLP span exporter (gRPC and HTTP)
|
||||
com.github.steffan-westcott/clj-otel-sdk {:mvn/version "0.2.8"} ; OpenTelemetry SDK for programmatic configuration
|
||||
com.github.vertical-blank/sql-formatter {:mvn/version "2.0.5"} ; Java SQL formatting library https://github.com/vertical-blank/sql-formatter
|
||||
com.google.guava/guava {:mvn/version "33.5.0-jre"} ; dep for BigQuery, Spark, and GA. Require here rather than letting different dep versions stomp on each other — see comments on #9697
|
||||
com.h2database/h2 ^:antq/exclude ; https://metaboat.slack.com/archives/CKZEMT1MJ/p1727191010259979
|
||||
{:mvn/version "2.1.214"} ; embedded SQL database
|
||||
com.ibm.icu/icu4j {:mvn/version "78.1"} ; Used to transliterate Unicode characters into Latin characters
|
||||
com.knuddels/jtokkit {:mvn/version "1.1.0"} ; Java tokenizer library for OpenAI models, used for token counting in semantic search
|
||||
com.mchange/c3p0 {:mvn/version "0.11.2"} ; DB connection pool
|
||||
com.mchange/c3p0 {:mvn/version "0.12.0"} ; DB connection pool
|
||||
com.snowplowanalytics/snowplow-java-tracker ^:antq/exclude ; 2+ depend on apache httpclient 5.x, but saml and clj-http depend on 4.x
|
||||
{:mvn/version "1.0.1"} ; Snowplow analytics
|
||||
com.sun.mail/jakarta.mail ^:antq/exclude ; 2+ changes imports from javax.mail to jakarta.mail
|
||||
@@ -80,11 +82,11 @@
|
||||
hiccup/hiccup {:mvn/version "2.0.0"} ; HTML templating
|
||||
inflections/inflections {:mvn/version "0.15.0"} ; Clojure/Script library used for prularizing words
|
||||
instaparse/instaparse {:mvn/version "1.5.0"} ; Make your own parser
|
||||
io.github.camsaul/toucan2 {:mvn/version "1.0.570"}
|
||||
io.github.camsaul/toucan2 {:mvn/version "1.0.571"}
|
||||
io.github.eerohele/pp {#_#_:git/tag "2024-11-13.77" ; super fast pretty-printing library
|
||||
:git/sha "ffc4edc482c057cd9412c62f018a41e9140c618b"
|
||||
:git/url "https://github.com/eerohele/pp"}
|
||||
io.github.metabase/macaw {:mvn/version "0.2.33"} ; Parse native SQL queries
|
||||
io.github.metabase/macaw {:mvn/version "0.2.36"} ; Parse native SQL queries
|
||||
io.netty/netty-codec-http {:mvn/version "4.2.9.Final"} ; override of transitive dep from aws s3
|
||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector
|
||||
junegunn/grouper {:mvn/version "0.1.1"} ; Batch processing helper
|
||||
@@ -111,14 +113,17 @@
|
||||
net.sf.cssbox/cssbox {:mvn/version "5.0.2" ; HTML / CSS rendering
|
||||
:exclusions [org.slf4j/slf4j-api
|
||||
junit/junit]}
|
||||
net.thisptr/jackson-jq {:mvn/version "1.6.0"} ; Java implementation of the JQ json query language
|
||||
org.apache.commons/commons-compress {:mvn/version "1.28.0"} ; compression utils
|
||||
org.apache.commons/commons-lang3 {:mvn/version "3.19.0"} ; helper methods for working with java.lang stuff
|
||||
org.apache.logging.log4j/log4j-1.2-api {:mvn/version "2.25.3"} ; apache logging framework
|
||||
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.3"} ; add compatibility with log4j 1.2
|
||||
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.3"} ; apache logging framework
|
||||
org.apache.logging.log4j/log4j-jcl {:mvn/version "2.25.3"} ; allows the commons-logging API to work with log4j 2
|
||||
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.3"} ; java.util.logging (JUL) -> Log4j2 adapter
|
||||
net.thisptr/jackson-jq {:mvn/version "1.6.0"} ; Java implementation of the JQ json query language
|
||||
org.apache.commons/commons-compress {:mvn/version "1.28.0"} ; compression utils
|
||||
org.apache.commons/commons-lang3 {:mvn/version "3.19.0"} ; helper methods for working with java.lang stuff
|
||||
org.apache.httpcomponents.client5/httpclient5 {:mvn/version "5.4.4"} ; pinned: ClickHouse JDBC 0.9.7 needs httpcore5 5.3.4+; must pair with httpclient5 5.4.x
|
||||
org.apache.httpcomponents.core5/httpcore5 {:mvn/version "5.3.4"} ; pinned: ClickHouse JDBC 0.9.7 requires URIBuilder.optimize() added in 5.3
|
||||
org.apache.httpcomponents.core5/httpcore5-h2 {:mvn/version "5.3.4"} ; pinned: must match httpcore5 version
|
||||
org.apache.logging.log4j/log4j-1.2-api {:mvn/version "2.25.3"} ; apache logging framework
|
||||
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.3"} ; add compatibility with log4j 1.2
|
||||
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.3"} ; apache logging framework
|
||||
org.apache.logging.log4j/log4j-jcl {:mvn/version "2.25.3"} ; allows the commons-logging API to work with log4j 2
|
||||
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.3"} ; java.util.logging (JUL) -> Log4j2 adapter
|
||||
org.apache.logging.log4j/log4j-layout-template-json
|
||||
{:mvn/version "2.25.2"} ; allows the custom json logging format
|
||||
org.apache.logging.log4j/log4j-slf4j2-impl
|
||||
@@ -157,10 +162,10 @@
|
||||
org.clojure/tools.namespace {:mvn/version "1.5.0"}
|
||||
org.clojure/tools.reader {:mvn/version "1.5.2"}
|
||||
org.clojure/tools.trace {:mvn/version "0.8.0"} ; function tracing
|
||||
org.eclipse.jetty/jetty-server {:mvn/version "12.1.3"}
|
||||
org.eclipse.jetty/jetty-unixdomain-server {:mvn/version "12.1.3"}
|
||||
org.eclipse.jetty.ee9/jetty-ee9-servlet {:mvn/version "12.1.3"}
|
||||
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server {:mvn/version "12.1.3"}
|
||||
org.eclipse.jetty/jetty-server {:mvn/version "12.1.7"}
|
||||
org.eclipse.jetty/jetty-unixdomain-server {:mvn/version "12.1.7"}
|
||||
org.eclipse.jetty.ee9/jetty-ee9-servlet {:mvn/version "12.1.7"}
|
||||
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server {:mvn/version "12.1.7"}
|
||||
org.eclipse.jgit/org.eclipse.jgit {:mvn/version "7.3.0.202506031305-r"}; Java implementation of Git version control system
|
||||
org.flatland/ordered {:mvn/version "1.15.12"} ; ordered maps & sets
|
||||
org.graalvm.polyglot/js-community {:mvn/version "25.0.1" :extension "pom"} ; JavaScript engine
|
||||
@@ -327,9 +332,9 @@
|
||||
"-Dmb.jetty.join=false"
|
||||
"-Dmb.field.filter.operators.enabled=true"
|
||||
"-Dmb.api.key=test-api-key"
|
||||
;; Different port from normal `:dev` so you can run tests on a different server.
|
||||
;; TODO -- figure out how to do a random port like in the old project.clj?
|
||||
"-Dmb.jetty.port=3001"]}
|
||||
;; Port 0 tells the OS to assign a random free port, allowing parallel test runs.
|
||||
;; The actual port is read from the running Jetty server via server.instance/server-port.
|
||||
"-Dmb.jetty.port=0"]}
|
||||
|
||||
;; run the dev server with
|
||||
;; clojure -M:run
|
||||
@@ -445,9 +450,10 @@
|
||||
com.lambdaisland/glogi {:mvn/version "1.3.169"}
|
||||
io.github.metabase/hawk {:mvn/version "1.0.13"}
|
||||
org.clojars.mmb90/cljs-cache {:mvn/version "0.1.4"}
|
||||
org.clojure/clojurescript {:mvn/version "1.12.134"}
|
||||
refactor-nrepl/refactor-nrepl {:mvn/version "3.11.0"}
|
||||
;; keep it in sync with package.json
|
||||
thheller/shadow-cljs {:mvn/version "3.2.1"}}}
|
||||
thheller/shadow-cljs {:mvn/version "3.3.5"}}}
|
||||
|
||||
;; for local dev -- include the drivers locally with :dev:drivers
|
||||
:drivers
|
||||
@@ -617,6 +623,9 @@
|
||||
:implicit-dependencies
|
||||
;; too many false positives for now
|
||||
:unused-ret-vals
|
||||
;; hard to clear on for lib.util.match macros, and disabling the linter just there
|
||||
;; doesn't seem to work
|
||||
:unused-meta-on-macro
|
||||
;; Kondo lints this for us anyway, and this isn't as easy to configure.
|
||||
:wrong-arity
|
||||
;; Kondo lints this for us anyway, and this isn't as easy to configure.
|
||||
@@ -628,6 +637,29 @@
|
||||
;; and a simple reproduction repo can be found here https://github.com/metabase/snowplow-eastwood-issue
|
||||
:exclude-namespaces [metabase.analytics.snowplow]}}
|
||||
|
||||
;; Test counterpart of :eastwood. Starts with :reflection only; add more linters as we fix other eastwood
|
||||
;; issues in tests.
|
||||
;;
|
||||
;; clojure -X:dev:ee:ee-dev:drivers:drivers-dev:test:eastwood/test
|
||||
:eastwood/test
|
||||
{:exec-fn metabase.linters.eastwood/eastwood
|
||||
:exec-args {:source-paths ["test"
|
||||
"enterprise/backend/test"]
|
||||
:linters [:reflection]
|
||||
:exclude-namespaces
|
||||
[;; byte/1 dispatch value trips up Eastwood's reader
|
||||
metabase.test.http-client
|
||||
;; Cookie/.getName qualified method trips up Eastwood's analyzer
|
||||
metabase.server.middleware.settings-cache-test
|
||||
;; .cljc syntax error during Eastwood's analyze+eval phase
|
||||
metabase.lib.query-test
|
||||
;; "count not supported on this type: Symbol" in Eastwood's analyzer
|
||||
metabase.query-processor.parameters.temporal-units-test
|
||||
;; Syntax error compiling fn* in Eastwood's analyzer
|
||||
metabase.sql-tools.macaw.references-test
|
||||
;; Method too large for Eastwood's analyzer
|
||||
metabase.sql-tools.sqlglot.references-test]}}
|
||||
|
||||
;; clojure -T:whitespace-linter
|
||||
:whitespace-linter
|
||||
{:deps {com.github.camsaul/whitespace-linter {:sha "e35bc252ccf5cc74f7d543ef95ad8a3e5131f25b"}}
|
||||
|
||||
38
dev/docker/claude-code/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
FROM ubuntu:noble
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl git rlwrap bash ca-certificates gnupg ripgrep sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user (--dangerously-skip-permissions refuses to run as root)
|
||||
RUN useradd -m -s /bin/bash claude
|
||||
RUN echo 'claude ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
|
||||
|
||||
USER claude
|
||||
|
||||
# Install mise
|
||||
RUN curl https://mise.run | sh
|
||||
ENV PATH="/home/claude/.local/bin:$PATH"
|
||||
|
||||
# Copy project mise.toml to install the same tool versions
|
||||
COPY --chown=claude:claude mise.toml /home/claude/.config/mise/config.toml
|
||||
|
||||
# Trust the mise config files so there's no interactive prompt
|
||||
RUN mise trust /home/claude/.config/mise/config.toml
|
||||
|
||||
# Install all tools defined in mise.toml
|
||||
RUN mise install
|
||||
|
||||
# Put mise shims on PATH
|
||||
RUN echo 'eval "$(mise activate bash)"' >> /home/claude/.bashrc
|
||||
ENV PATH="/home/claude/.local/share/mise/shims:$PATH"
|
||||
|
||||
# Auto-accept mise trust prompts for project configs mounted at runtime
|
||||
ENV MISE_YES=1
|
||||
|
||||
# Create .claude directory so Claude Code can write session data
|
||||
RUN mkdir -p /home/claude/.claude
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["claude"]
|
||||
113
dev/src/dev/api/preview.clj
Normal file
@@ -0,0 +1,113 @@
|
||||
(ns dev.api.preview
|
||||
"Dev-only endpoints for previewing rendered pulse cards and dashboards.
|
||||
These were previously at `/api/pulse/preview_*` but have been moved here
|
||||
since they are only useful for development and testing."
|
||||
(:require
|
||||
[hiccup.core :refer [html]]
|
||||
[hiccup.page :refer [html5]]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common]
|
||||
[metabase.channel.render.core :as channel.render]
|
||||
[metabase.channel.urls :as urls]
|
||||
[metabase.query-processor :as qp]
|
||||
[metabase.query-processor.middleware.permissions :as qp.perms]
|
||||
[metabase.util.malli.schema :as ms])
|
||||
(:import
|
||||
(java.io ByteArrayInputStream)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- pulse-card-query-results
|
||||
{:arglists '([card])}
|
||||
[{query :dataset_query, card-id :id}]
|
||||
(binding [qp.perms/*card-id* card-id]
|
||||
(qp/process-query
|
||||
(qp/userland-query
|
||||
(assoc query
|
||||
:middleware {:process-viz-settings? true
|
||||
:js-int-to-string? false})
|
||||
{:executed-by api/*current-user-id*
|
||||
:context :pulse
|
||||
:card-id card-id}))))
|
||||
|
||||
(api.macros/defendpoint :get "/preview-card/:id"
|
||||
"Get HTML rendering of a Card with `id`."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(let [card (api/read-check :model/Card id)
|
||||
result (pulse-card-query-results card)]
|
||||
{:status 200
|
||||
:body (html5
|
||||
[:html
|
||||
[:body {:style "margin: 0;"}
|
||||
(channel.render/render-pulse-card-for-display (channel.render/defaulted-timezone card)
|
||||
card
|
||||
result
|
||||
{:channel.render/include-title? true, :channel.render/include-buttons? true})]])}))
|
||||
|
||||
(api.macros/defendpoint :get "/preview-dashboard/:id"
|
||||
"Get HTML rendering of a Dashboard with `id`.
|
||||
|
||||
This endpoint relies on a custom middleware defined in `metabase.channel.render.core/style-tag-nonce-middleware` to
|
||||
allow the style tag to render properly, given our Content Security Policy setup."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(api/read-check :model/Dashboard id)
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/html"}
|
||||
:body (channel.render/style-tag-from-inline-styles
|
||||
(html5
|
||||
[:head
|
||||
[:meta {:charset "utf-8"}]
|
||||
[:link {:nonce "%NONCE%" ;; this will be str/replaced by 'style-tag-nonce-middleware
|
||||
:rel "stylesheet"
|
||||
:href "https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap"}]]
|
||||
[:body [:h2 (format "Backend Artifacts Preview for Dashboard %s" id)]
|
||||
(channel.render/render-dashboard-to-html id)]))})
|
||||
|
||||
(api.macros/defendpoint :get "/preview-card-info/:id"
|
||||
"Get JSON object containing HTML rendering of a Card with `id` and other information."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(let [card (api/read-check :model/Card id)
|
||||
result (pulse-card-query-results card)
|
||||
data (:data result)
|
||||
card-type (channel.render/detect-pulse-chart-type card nil data)
|
||||
card-html (html (channel.render/render-pulse-card-for-display (channel.render/defaulted-timezone card)
|
||||
card
|
||||
result
|
||||
{:channel.render/include-title? true}))]
|
||||
{:id id
|
||||
:pulse_card_type card-type
|
||||
:pulse_card_html card-html
|
||||
:pulse_card_name (:name card)
|
||||
:pulse_card_url (urls/card-url (:id card))
|
||||
:row_count (:row_count result)
|
||||
:col_count (count (:cols (:data result)))}))
|
||||
|
||||
(def ^:private preview-card-width 400)
|
||||
|
||||
(api.macros/defendpoint :get "/preview-card-png/:id"
|
||||
"Get PNG rendering of a Card with `id`. Optionally specify `width` as a query parameter."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]
|
||||
{:keys [width]} :- [:map
|
||||
[:width {:optional true} [:maybe ms/PositiveInt]]]]
|
||||
(let [card (api/read-check :model/Card id)
|
||||
result (pulse-card-query-results card)
|
||||
width (or width preview-card-width)
|
||||
ba (channel.render/render-pulse-card-to-png (channel.render/defaulted-timezone card)
|
||||
card
|
||||
result
|
||||
width
|
||||
{:channel.render/include-title? true})]
|
||||
{:status 200, :headers {"Content-Type" "image/png"}, :body (ByteArrayInputStream. ba)}))
|
||||
|
||||
(def ^:private ^{:arglists '([handler])} style-nonce-middleware
|
||||
(metabase.api.routes.common/wrap-middleware-for-open-api-spec-generation
|
||||
(partial channel.render/style-tag-nonce-middleware "/dev/preview/preview-dashboard")))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/dev/preview` endpoints."
|
||||
(api.macros/ns-handler *ns* style-nonce-middleware))
|
||||
@@ -1,12 +1,15 @@
|
||||
(ns dev.api.routes
|
||||
(:require [dev.api.prototype]
|
||||
(:require [dev.api.preview]
|
||||
[dev.api.prototype]
|
||||
[metabase.api.util.handlers :as handlers]))
|
||||
|
||||
(comment
|
||||
dev.api.preview/keep-me
|
||||
dev.api.prototype/keep-me)
|
||||
|
||||
(def ^:private dev-routes-map
|
||||
{"/prototype" 'dev.api.prototype})
|
||||
{"/preview" 'dev.api.preview
|
||||
"/prototype" 'dev.api.prototype})
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
;; This map will be merged at the top level, so /dev/prototype is available.
|
||||
|
||||
@@ -133,7 +133,9 @@
|
||||
(let [;; Try different possible file paths
|
||||
possible-paths [(str "src/" file)
|
||||
file
|
||||
(str "test/" file)]
|
||||
(str "test/" file)
|
||||
(str "enterprise/backend/src/" file)
|
||||
(str "enterprise/backend/test/" file)]
|
||||
source-file (first (filter #(and % (.exists (java.io.File. ^String %))) possible-paths))]
|
||||
(when-not source-file
|
||||
(throw (ex-info (str "Could not find source file for: " file)
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
;; run a query with debugging enabled
|
||||
(binding [metabase.query-processor.debug/*debug* true]
|
||||
(metabase.query-processor/process-query query))"
|
||||
{:clj-kondo/config '{:linters
|
||||
{:discouraged-var {metabase.lib.util.match/replace {:level :off}}}}}
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
@@ -123,25 +125,25 @@
|
||||
(symbol (format "#_\"%s.%s\"" field-name table-name)))))
|
||||
(field-id->name-form [field-id]
|
||||
(list 'do (add-name-to-field-id field-id) field-id))]
|
||||
(lib.util.match/replace form
|
||||
(lib.util.match/replace-lite form
|
||||
[:field (id :guard pos-int?) opts]
|
||||
[:field id (add-name-to-field-id id) (cond-> opts
|
||||
(pos-int? (:source-field opts))
|
||||
(update :source-field field-id->name-form))]
|
||||
|
||||
(m :guard (every-pred map? (comp pos-int? :source-table)))
|
||||
(m :guard (and (map? m) (pos-int? (:source-table m))))
|
||||
(add-names* (update m :source-table add-table-id-name))
|
||||
|
||||
(m :guard (every-pred map? (comp pos-int? :metabase.query-processor.util.add-alias-info/source-table)))
|
||||
(m :guard (and (map? m) (pos-int? (:metabase.query-processor.util.add-alias-info/source-table m))))
|
||||
(add-names* (update m :metabase.query-processor.util.add-alias-info/source-table add-table-id-name))
|
||||
|
||||
(m :guard (every-pred map? (comp pos-int? :fk-field-id)))
|
||||
(m :guard (and (map? m) (pos-int? (:fk-field-id m))))
|
||||
(-> m
|
||||
(update :fk-field-id field-id->name-form)
|
||||
add-names*)
|
||||
|
||||
;; don't recursively replace the `do` lists above, other we'll get vectors.
|
||||
(_ :guard (every-pred list? #(= (first %) 'do)))
|
||||
(l :guard (and (seq? l) (= (first l) 'do)))
|
||||
&match)))
|
||||
x)
|
||||
->sorted-mbql-query-map))
|
||||
@@ -343,10 +345,10 @@
|
||||
e)))))
|
||||
|
||||
(defn- no-$ [x]
|
||||
(lib.util.match/replace x [::$ & args] (into [::no-$] args)))
|
||||
(lib.util.match/replace-lite x [::$ & args] (into [::no-$] args)))
|
||||
|
||||
(defn- symbolize [form]
|
||||
(lib.util.match/replace form
|
||||
(lib.util.match/replace-lite form
|
||||
[::-> x y]
|
||||
(symbol (format "%s->%s" (symbolize x) (str/replace (symbolize y) #"^\$" "")))
|
||||
|
||||
|
||||
157
dev/src/dev/debug_qp/analyze_user_query.clj
Normal file
@@ -0,0 +1,157 @@
|
||||
(ns dev.debug-qp.analyze-user-query
|
||||
"Given access to e.g. a Cloud instance with a querying issue, this script can use a login token borrowed from your
|
||||
browser to make API calls to the instance, and assemble a local mock `MetadataProvider` which contains the tables,
|
||||
fields and cards necessary to run QP preprocessing and compilation locally against the same metadata the user
|
||||
instance is seeing.
|
||||
|
||||
Of course the queries can't be executed without a connection to the user's DWH, which is usually impossible."
|
||||
(:require
|
||||
[clj-http.client :as http]
|
||||
[medley.core :as m]
|
||||
[metabase.lib.js.metadata :as lib.js.metadata]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.query-processor.compile :as qp.compile]
|
||||
[metabase.query-processor.preprocess :as qp.preprocess]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
;; XXX: INSTRUCTIONS:
|
||||
;; - Log into the instance, open DevTools, and navigate to the problem question.
|
||||
;; - Open the Network tab, find a `/api/card/1234` or similar request, right click and "Copy as cURL".
|
||||
;; - Paste that somewhere, probably into your shell to test that the `curl` call is working.
|
||||
;; - Grab its `cookie` and edit it into `my-ctx` below. (It's the `-b` arg to curl.)
|
||||
;; - Edit the `instance-url` of `my-ctx` below likewise.
|
||||
;; - Edit the `database-id` of `my-ctx` below to match the problem database on the user instance.
|
||||
;; - That can be seen in eg. `/api/card` requests and similar.
|
||||
;; - Now you can run the analysis functions below, like compile-card.
|
||||
;; - See the examples at the bottom of this file.
|
||||
(def ^:private my-ctx
|
||||
"Contains instance details, for passing to the various functions below."
|
||||
{:instance-url "https://acustomer.metabaseapp.com" ; No trailing slash.
|
||||
:cookie-from-browser "COPY ME FROM YOUR curl COMMAND"
|
||||
:database-id 123})
|
||||
|
||||
(defn- mk-headers [{:keys [cookie-from-browser instance-url] :as _ctx}]
|
||||
{"accept" "application/json"
|
||||
"accept-language" "en-US,en;q=0.9"
|
||||
"cache-control" "no-cache"
|
||||
"content-type" "application/json"
|
||||
"cookie" cookie-from-browser
|
||||
"pragma" "no-cache"
|
||||
"priority" "u=1, i"
|
||||
"referer" instance-url
|
||||
"sec-ch-ua" "\"Google Chrome\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\""
|
||||
"sec-ch-ua-mobile" "?0"
|
||||
"sec-ch-ua-platform" "\"macOS\""
|
||||
"sec-fetch-dest" "empty"
|
||||
"sec-fetch-mode" "cors"
|
||||
"sec-fetch-site" "same-origin"
|
||||
"user-agent" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"})
|
||||
|
||||
(defn- instance-fetch [{:keys [instance-url] :as ctx} path]
|
||||
(let [headers (mk-headers ctx)
|
||||
request {:method :get
|
||||
:url (str instance-url path)
|
||||
:accept :json
|
||||
:content-type :json
|
||||
:throw-exceptions false
|
||||
:headers headers}
|
||||
response (-> (http/request request)
|
||||
(select-keys [:body :headers :status])
|
||||
(update :body json/decode))]
|
||||
(if (<= 200 (:status response) 299)
|
||||
(:body response)
|
||||
(throw (ex-info "Non-200 response" response)))))
|
||||
|
||||
(defn- qm-index [objs]
|
||||
(m/index-by (comp str #(get % "id")) objs))
|
||||
|
||||
(defn- qm->metadata [raw]
|
||||
;; /query_metadata requests return a map in a different shape from what lib.js.metadata expects.
|
||||
;; We get {"databases" [{...}], "tables" [{..., "fields": [{...}]}]} but we need
|
||||
;; {"databases" {"12" {...}}
|
||||
;; "tables" {"333" {...}}
|
||||
;; "fields" {"4343" {...}}
|
||||
;; "questions" {"12" {...}}}
|
||||
(let [fields (for [table (get raw "tables")
|
||||
:when (number? (get table "id"))
|
||||
field (get table "fields")
|
||||
:when (number? (get field "id"))]
|
||||
field)]
|
||||
{"databases" (qm-index (get raw "databases"))
|
||||
"tables" (qm-index (get raw "tables"))
|
||||
"fields" (qm-index fields)}))
|
||||
|
||||
(defn- recursive-card-metadata
|
||||
"Helper for [[metadata-for]]. Prefer calling that function instead."
|
||||
[ctx main-card-id]
|
||||
(loop [metadata {}
|
||||
cards-seen #{main-card-id}
|
||||
[card-id & cards] [main-card-id]]
|
||||
(if-not card-id
|
||||
metadata
|
||||
(let [card (instance-fetch ctx (str "/api/card/" card-id))
|
||||
raw (instance-fetch ctx (str "/api/card/" card-id "/query_metadata"))
|
||||
nested-cards (->> (get raw "tables")
|
||||
(map #(get % "id"))
|
||||
(filter string?)
|
||||
(map (comp Long/parseLong #(subs % 6)))
|
||||
(remove cards-seen))]
|
||||
(recur (m/deep-merge metadata
|
||||
{"questions" {(str card-id) card}}
|
||||
(qm->metadata raw))
|
||||
(into cards-seen nested-cards)
|
||||
(concat cards nested-cards))))))
|
||||
|
||||
(defn- table-metadata
|
||||
"Helper for [[metadata-for]]. Prefer calling that function instead."
|
||||
[ctx table-id]
|
||||
(qm->metadata {"tables" [(-> (instance-fetch ctx (str "/api/table/" table-id "/query_metadata"))
|
||||
(dissoc "db"))]}))
|
||||
|
||||
;; =============================================== External API ===================================================
|
||||
(defn metadata-for
|
||||
"Given a map like `{:card [123 456], :table [789]}` returns a `MetadataProvider` with metadata for all the tables,
|
||||
cards and fields transitively required by the specified roots.
|
||||
|
||||
Generally you only need a single `{:card [123]}` for the problem card you're investigating.
|
||||
|
||||
Occasionally a broken card might reference fields by ID which are not really part of their query - then they're
|
||||
missing from the `/query_metadata` on that card and will be missed by this function unless you explicitly add
|
||||
`{:table [789]}` to the map of roots.
|
||||
|
||||
Returns a complete `MetadataProvider` ready to use in a QP call."
|
||||
[{:keys [database-id] :as ctx} kind->ids]
|
||||
(->> (for [[kind ids] kind->ids
|
||||
id ids]
|
||||
(case kind
|
||||
:card (recursive-card-metadata ctx id)
|
||||
:table (table-metadata ctx id)))
|
||||
(apply m/deep-merge)
|
||||
(lib.js.metadata/metadata-provider database-id)))
|
||||
|
||||
(defn preprocess-card
|
||||
"Given a `MetadataProvider` built by [[metadata-for]] and a card ID, tries to preprocess that card."
|
||||
[metadata-provider card-id]
|
||||
(mu/disable-enforcement
|
||||
(-> (lib.metadata/card metadata-provider card-id)
|
||||
:dataset-query
|
||||
qp.preprocess/preprocess)))
|
||||
|
||||
(defn compile-card
|
||||
"Given a `MetadataProvider` built by [[metadata-for]] and a card ID, tries to compile that card to e.g. SQL."
|
||||
[metadata-provider card-id]
|
||||
(mu/disable-enforcement
|
||||
(-> (lib.metadata/card metadata-provider card-id)
|
||||
:dataset-query
|
||||
qp.compile/compile)))
|
||||
|
||||
(comment
|
||||
;; Example calls
|
||||
(let [card-id 456
|
||||
mp (metadata-for my-ctx {:card [card-id]})]
|
||||
(compile-card mp card-id))
|
||||
|
||||
(let [card-id 456
|
||||
mp (metadata-for my-ctx {:card [card-id]})]
|
||||
(preprocess-card mp card-id)))
|
||||
@@ -7,11 +7,15 @@
|
||||
Configuration:
|
||||
- LINEAR_API_KEY env var must be set
|
||||
- Call (set-config! {:team-id \"...\" :project-id \"...\"}) before creating issues"
|
||||
(:refer-clojure :exclude [run!])
|
||||
(:require
|
||||
[cheshire.core :as json]
|
||||
[clj-http.client :as http]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.java.shell :as shell]
|
||||
[clojure.string :as str]))
|
||||
[clojure.set :as set]
|
||||
[clojure.string :as str]
|
||||
[dev.coverage :as coverage]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@@ -20,14 +24,21 @@
|
||||
(defonce ^:private config (atom {}))
|
||||
|
||||
(defn set-config!
|
||||
"Set configuration for Linear API calls.
|
||||
"Set configuration for mutation testing.
|
||||
|
||||
Required keys:
|
||||
- :team-id — Linear team ID
|
||||
- :project-id — Linear project ID (can be set after creating a project)"
|
||||
- :project-id — Linear project ID (can be set after creating a project)
|
||||
|
||||
Optional keys:
|
||||
- :base-branch — git branch to create feature branches from (default: \"master\")
|
||||
- :project-id — Linear project ID to add issues to (skips project creation in run!)"
|
||||
[m]
|
||||
(swap! config merge m))
|
||||
|
||||
(defn- base-branch []
|
||||
(get @config :base-branch "master"))
|
||||
|
||||
(defn- api-key []
|
||||
(or (System/getenv "LINEAR_API_KEY")
|
||||
(throw (ex-info "LINEAR_API_KEY environment variable is not set" {}))))
|
||||
@@ -215,14 +226,19 @@
|
||||
"Generate a branch name for mutation testing a function.
|
||||
Example: mutation-testing-lib-order-by-orderable-columns"
|
||||
[target-ns fn-name]
|
||||
(let [short-ns (last (clojure.string/split (str target-ns) #"\."))]
|
||||
(let [short-ns (last (str/split (str target-ns) #"\."))]
|
||||
(str "mutation-testing-lib-" short-ns "-" fn-name)))
|
||||
|
||||
(defn create-branch!
|
||||
"Create and checkout a new branch from master for mutation testing a function."
|
||||
"Create and checkout a new branch from the base branch for mutation testing a function.
|
||||
Fails early if there are uncommitted changes."
|
||||
[target-ns fn-name]
|
||||
(let [status (:out (sh "git" "status" "--porcelain" "-uno"))]
|
||||
(when-not (str/blank? status)
|
||||
(throw (ex-info (str "Uncommitted changes detected. Commit or stash before running.\n" status)
|
||||
{:status status}))))
|
||||
(let [branch (branch-name target-ns fn-name)]
|
||||
(sh "git" "checkout" "master")
|
||||
(sh "git" "checkout" (base-branch))
|
||||
(sh "git" "pull")
|
||||
(sh "git" "checkout" "-b" branch)
|
||||
branch))
|
||||
@@ -255,10 +271,11 @@
|
||||
(let [title (pr-title target-ns fn-name)
|
||||
body (pr-description opts)
|
||||
result (sh "gh" "pr" "create" "--draft"
|
||||
"--base" (base-branch)
|
||||
"--title" title
|
||||
"--body" body
|
||||
"--label" "no-backport")]
|
||||
(clojure.string/trim (:out result))))
|
||||
(str/trim (:out result))))
|
||||
|
||||
(defn add-pr-comment!
|
||||
"Add a general comment to a PR. `pr-url-or-number` can be a PR URL or number."
|
||||
@@ -308,12 +325,17 @@
|
||||
"-f" "start_side=RIGHT"]))]
|
||||
(apply sh args)))
|
||||
|
||||
(defn return-to-master!
|
||||
"Checkout master branch."
|
||||
(defn return-to-base!
|
||||
"Checkout the configured base branch."
|
||||
[]
|
||||
(sh "git" "checkout" "master")
|
||||
(sh "git" "checkout" (base-branch))
|
||||
nil)
|
||||
|
||||
(defn return-to-master!
|
||||
"Checkout the configured base branch. Deprecated: use return-to-base! instead."
|
||||
[]
|
||||
(return-to-base!))
|
||||
|
||||
;;; --- Report helpers ---
|
||||
|
||||
(defn report-stats
|
||||
@@ -409,6 +431,438 @@
|
||||
(create-issue! {:title (linear-issue-title target-ns fn-name)
|
||||
:description (linear-issue-description target-ns fn-name)}))
|
||||
|
||||
;;; --- Orchestration ---
|
||||
|
||||
(defn parse-namespace
|
||||
"Parse a namespace symbol into all derived paths and names.
|
||||
Auto-detects .cljc vs .clj by checking which file exists."
|
||||
[target-ns-sym]
|
||||
(let [target-ns (str target-ns-sym)
|
||||
test-ns (str target-ns "-test")
|
||||
short-name (last (str/split target-ns #"\."))
|
||||
ns-path (-> target-ns
|
||||
(str/replace "." "/")
|
||||
(str/replace "-" "_"))
|
||||
test-path-base (-> test-ns
|
||||
(str/replace "." "/")
|
||||
(str/replace "-" "_"))
|
||||
source-prefixes ["src/" "enterprise/backend/src/"]
|
||||
test-prefixes ["test/" "enterprise/backend/test/"]
|
||||
find-file (fn [prefixes base exts]
|
||||
(first (for [prefix prefixes, ext exts
|
||||
:let [path (str prefix base ext)]
|
||||
:when (.exists (io/file path))]
|
||||
{:prefix prefix :ext ext :path path})))
|
||||
source-match (or (find-file source-prefixes ns-path [".cljc" ".clj"])
|
||||
{:prefix "src/" :ext ".cljc" :path (str "src/" ns-path ".cljc")})
|
||||
test-match (or (find-file test-prefixes test-path-base [".cljc" ".clj"])
|
||||
{:prefix "test/" :ext ".cljc" :path (str "test/" test-path-base ".cljc")})]
|
||||
{:target-ns (symbol target-ns)
|
||||
:test-ns (symbol test-ns)
|
||||
:short-name short-name
|
||||
:source-path (:path source-match)
|
||||
:test-path (:path test-match)
|
||||
:report-path (str "mutation-testing-report." target-ns ".before.md")}))
|
||||
|
||||
(defn group-functions
|
||||
"Group functions from coverage results for batch processing.
|
||||
Takes the output of coverage/test-namespace.
|
||||
|
||||
Logic:
|
||||
- Public functions with surviving mutations each become a group
|
||||
- Private functions get assigned to the public function with the most test overlap
|
||||
- Functions with zero surviving mutations are skipped
|
||||
- Uncovered or unassignable private functions get their own group"
|
||||
[coverage-results]
|
||||
(let [with-survivors (into {} (filter (fn [[_ v]] (seq (:survived v))) coverage-results))
|
||||
public? (fn [fn-sym]
|
||||
(let [v (find-var fn-sym)]
|
||||
(and v (not (:private (meta v))))))
|
||||
public-fns (into {} (filter (fn [[k _]] (public? k)) with-survivors))
|
||||
private-fns (into {} (remove (fn [[k _]] (public? k)) with-survivors))
|
||||
assignments (into {}
|
||||
(for [[priv-fn priv-data] private-fns
|
||||
:let [priv-tests (set (:tests priv-data))
|
||||
best (when (seq priv-tests)
|
||||
(->> public-fns
|
||||
(map (fn [[pub-fn pub-data]]
|
||||
[pub-fn (count (set/intersection
|
||||
priv-tests
|
||||
(set (:tests pub-data))))]))
|
||||
(filter (fn [[_ n]] (pos? n)))
|
||||
(sort-by second >)
|
||||
first))]
|
||||
:when best]
|
||||
[priv-fn (first best)]))
|
||||
unassigned-private (into {} (remove (fn [[k _]] (contains? assignments k)) private-fns))
|
||||
public-groups (for [[pub-fn pub-data] public-fns
|
||||
:let [attached (keep (fn [[priv-fn assigned-to]]
|
||||
(when (= assigned-to pub-fn) priv-fn))
|
||||
assignments)
|
||||
all-fn-names (vec (cons pub-fn attached))
|
||||
all-mutations (vec (concat (:survived pub-data)
|
||||
(mapcat #(:survived (get coverage-results %))
|
||||
attached)))
|
||||
all-tests (apply set/union
|
||||
(keep #(:tests (get coverage-results %))
|
||||
all-fn-names))]]
|
||||
{:primary-fn pub-fn
|
||||
:fn-names all-fn-names
|
||||
:mutations all-mutations
|
||||
:tests (or all-tests #{})})
|
||||
private-groups (for [[priv-fn priv-data] unassigned-private]
|
||||
{:primary-fn priv-fn
|
||||
:fn-names [priv-fn]
|
||||
:mutations (vec (:survived priv-data))
|
||||
:tests (or (:tests priv-data) #{})})]
|
||||
(vec (concat public-groups private-groups))))
|
||||
|
||||
(defn build-test-prompt
|
||||
"Build a prompt for Claude to write tests that kill surviving mutations."
|
||||
[{:keys [target-ns test-ns source-path test-path fn-names mutations]}]
|
||||
(let [fn-sources (str/join "\n\n"
|
||||
(for [fn-name fn-names
|
||||
:let [fn-info (coverage/find-function-source fn-name)
|
||||
source (when fn-info (coverage/read-function-from-file fn-info))]
|
||||
:when source]
|
||||
(str ";;; " fn-name "\n" source)))
|
||||
mutation-list (str/join "\n\n"
|
||||
(map-indexed
|
||||
(fn [i {:keys [description mutation]}]
|
||||
(str "### Mutation " (inc i) ": " description "\n```clojure\n" mutation "\n```"))
|
||||
mutations))
|
||||
test-content (slurp test-path)]
|
||||
(str "You are writing mutation tests for functions in `" target-ns "`.\n"
|
||||
"\n"
|
||||
"## Source Code of Functions Under Test\n"
|
||||
"\n"
|
||||
"```clojure\n"
|
||||
fn-sources
|
||||
"\n```\n"
|
||||
"\n"
|
||||
"## Existing Test File (" test-path ")\n"
|
||||
"\n"
|
||||
"```clojure\n"
|
||||
test-content
|
||||
"\n```\n"
|
||||
"\n"
|
||||
"## Surviving Mutations to Kill\n"
|
||||
"\n"
|
||||
"Each mutation below represents a change to the source code that no existing test catches.\n"
|
||||
"Write the simplest tests that would fail if these mutations were applied.\n"
|
||||
"\n"
|
||||
mutation-list
|
||||
"\n"
|
||||
"\n"
|
||||
"## Instructions\n"
|
||||
"\n"
|
||||
"- Write the simplest tests that kill these surviving mutations\n"
|
||||
"- Follow the patterns in the existing test file (same helpers, same assertion style)\n"
|
||||
"- Never call private functions directly — test through public API\n"
|
||||
"- Insert tests near related existing tests, not at the end of the file\n"
|
||||
"- Use the Edit tool to modify the test file at: " test-path "\n"
|
||||
"- After writing tests, verify they compile by running: clj-nrepl-eval -p $(clj-nrepl-eval --discover-ports | head -1) \"(require '" test-ns " :reload)\"\n"
|
||||
"- Each test should verify one meaningful behavior — don't over-engineer\n")))
|
||||
|
||||
(defn invoke-claude!
|
||||
"Shell out to claude CLI to write tests. Returns the output string."
|
||||
[prompt]
|
||||
(let [result (shell/sh "claude" "-p"
|
||||
"--allowedTools" "Edit,Read,Bash(clj-nrepl-eval*)"
|
||||
:in prompt)]
|
||||
(when-not (zero? (:exit result))
|
||||
(throw (ex-info "Claude invocation failed" {:exit (:exit result)
|
||||
:err (:err result)})))
|
||||
(:out result)))
|
||||
|
||||
(defn- all-test-names
|
||||
"Get all test var names from a test namespace."
|
||||
[test-ns-sym]
|
||||
(require test-ns-sym :reload)
|
||||
(let [ns-obj (find-ns test-ns-sym)]
|
||||
(->> (ns-interns ns-obj)
|
||||
vals
|
||||
(filter #(:test (meta %)))
|
||||
(map #(symbol (str test-ns-sym) (name (.sym ^clojure.lang.Var %))))
|
||||
set)))
|
||||
|
||||
(defn- count-tests
|
||||
"Count test vars in a namespace."
|
||||
[test-ns-sym]
|
||||
(count (filter #(:test (meta %)) (vals (ns-interns (find-ns test-ns-sym))))))
|
||||
|
||||
(defn verify-and-retry!
|
||||
"Verify mutations are killed. Retry with Claude if needed.
|
||||
Returns {:status :success|:partial, :killed [...], :survived [...]}."
|
||||
[{:keys [fn-names test-ns test-path max-retries]
|
||||
:or {max-retries 2}}]
|
||||
(loop [retries-left max-retries]
|
||||
(let [test-names (all-test-names test-ns)
|
||||
fn-results (into {}
|
||||
(for [fn-name fn-names
|
||||
:let [result (coverage/test-mutations fn-name test-names)]
|
||||
:when result]
|
||||
[fn-name result]))
|
||||
all-killed (vec (mapcat :killed (vals fn-results)))
|
||||
all-survived (vec (mapcat :survived (vals fn-results)))]
|
||||
(if (empty? all-survived)
|
||||
{:status :success :killed all-killed :survived []}
|
||||
(if (pos? retries-left)
|
||||
(do
|
||||
(println " " (count all-survived) "mutations still survive, retrying..."
|
||||
"(" retries-left "retries left)")
|
||||
(let [retry-prompt (str "Some mutations still survive after your previous tests. "
|
||||
"Please write additional tests to kill them.\n\n"
|
||||
"## Still-Surviving Mutations\n\n"
|
||||
(str/join "\n\n"
|
||||
(for [{:keys [description mutation]} all-survived]
|
||||
(str "### " description "\n```clojure\n" mutation "\n```")))
|
||||
"\n\n## Instructions\n"
|
||||
"- The test file is at: " test-path "\n"
|
||||
"- Use the Edit tool to add more tests\n"
|
||||
"- Focus specifically on the mutations listed above\n"
|
||||
"- Do NOT duplicate existing tests\n")]
|
||||
(invoke-claude! retry-prompt))
|
||||
(recur (dec retries-left)))
|
||||
{:status :partial :killed all-killed :survived all-survived})))))
|
||||
|
||||
(defn process-group!
|
||||
"Process one group of functions: branch → tests → verify → commit → PR.
|
||||
Returns {:pr-url ... :issue ... :killed ... :not-killed ...}."
|
||||
[session group]
|
||||
(let [{:keys [target-ns test-ns test-path]} session
|
||||
{:keys [primary-fn fn-names mutations]} group
|
||||
primary-fn-name (name primary-fn)]
|
||||
;; 1. Create branch
|
||||
(println " Creating branch...")
|
||||
(create-branch! (str target-ns) primary-fn-name)
|
||||
|
||||
;; 2. Count tests before
|
||||
(require test-ns :reload)
|
||||
(let [tests-before (count-tests test-ns)]
|
||||
|
||||
;; 3. Build prompt and invoke Claude
|
||||
(println " Invoking Claude to write tests...")
|
||||
(let [prompt (build-test-prompt {:target-ns target-ns
|
||||
:test-ns test-ns
|
||||
:source-path (:source-path session)
|
||||
:test-path test-path
|
||||
:fn-names fn-names
|
||||
:mutations mutations})]
|
||||
(invoke-claude! prompt))
|
||||
|
||||
;; 4. Verify and retry
|
||||
(println " Verifying mutations killed...")
|
||||
(let [verify-result (verify-and-retry! {:fn-names fn-names
|
||||
:test-ns test-ns
|
||||
:test-path test-path
|
||||
:max-retries 2})
|
||||
killed (:killed verify-result)
|
||||
survived (:survived verify-result)
|
||||
tests-after (count-tests test-ns)
|
||||
tests-added (- tests-after tests-before)]
|
||||
|
||||
;; 5. Commit and push
|
||||
(println " Committing and pushing...")
|
||||
(commit-and-push! (str target-ns) primary-fn-name [test-path])
|
||||
|
||||
;; 6. Create Linear issue
|
||||
(println " Creating Linear issue...")
|
||||
(let [issue (create-issue-for-function! (str target-ns) primary-fn-name)
|
||||
|
||||
;; 7. Create draft PR
|
||||
_ (println " Creating draft PR...")
|
||||
pr-url (create-draft-pr!
|
||||
{:target-ns (str target-ns)
|
||||
:fn-name primary-fn-name
|
||||
:fn-names (mapv name fn-names)
|
||||
:linear-identifier (:identifier issue)
|
||||
:mutations-before (count mutations)
|
||||
:tests-added tests-added
|
||||
:killed (mapv :description killed)
|
||||
:not-killed (mapv (fn [m] {:description (:description m)
|
||||
:rationale "Could not be killed automatically"})
|
||||
survived)
|
||||
:suggested-changes []})]
|
||||
|
||||
;; 8. Return to base branch
|
||||
(return-to-base!)
|
||||
|
||||
(println " Done! PR:" pr-url)
|
||||
{:pr-url pr-url
|
||||
:issue issue
|
||||
:killed killed
|
||||
:not-killed survived
|
||||
:tests-added tests-added})))))
|
||||
|
||||
(defn print-summary!
|
||||
"Print a summary of the mutation testing run."
|
||||
[{:keys [project groups results]}]
|
||||
(println "\n========================================")
|
||||
(println " Mutation Testing Summary")
|
||||
(println "========================================")
|
||||
(println "Project:" (:name project))
|
||||
(println "Groups:" (count groups))
|
||||
(let [successful (filter :pr-url results)
|
||||
failed (filter :error results)
|
||||
total-killed (count (mapcat :killed successful))
|
||||
total-unkilled (count (mapcat :not-killed successful))
|
||||
total-tests (reduce + 0 (keep :tests-added successful))]
|
||||
(println "PRs created:" (count successful))
|
||||
(when (seq failed)
|
||||
(println "Errors:" (count failed)))
|
||||
(println "Tests added:" total-tests)
|
||||
(println "Mutations killed:" total-killed)
|
||||
(println "Mutations not killed:" total-unkilled)
|
||||
(println)
|
||||
(when (seq successful)
|
||||
(println "PRs:")
|
||||
(doseq [r successful]
|
||||
(println " " (:pr-url r))))
|
||||
(when (seq failed)
|
||||
(println "\nFailed:")
|
||||
(doseq [r failed]
|
||||
(println " " (:primary-fn r) "-" (:error r))))
|
||||
(println "========================================")))
|
||||
|
||||
(defn run-one!
|
||||
"Run mutation testing for a single function. No branching or PR creation.
|
||||
Generates a report, invokes Claude to write tests, and verifies mutations are killed.
|
||||
|
||||
Arguments:
|
||||
- fq-fn-sym — fully-qualified function symbol (e.g. 'my.ns/my-fn)
|
||||
- test-ns-sym — test namespace symbol (e.g. 'my.ns-test)
|
||||
|
||||
Options:
|
||||
- :max-retries — max Claude retries for surviving mutations (default 2)"
|
||||
([fq-fn-sym test-ns-sym] (run-one! fq-fn-sym test-ns-sym {}))
|
||||
([fq-fn-sym test-ns-sym opts]
|
||||
(let [fq-fn-name (symbol fq-fn-sym)
|
||||
target-ns (symbol (namespace fq-fn-name))
|
||||
test-ns (symbol (str test-ns-sym))
|
||||
parsed (parse-namespace target-ns)
|
||||
{:keys [source-path test-path report-path]} parsed
|
||||
max-retries (get opts :max-retries 2)]
|
||||
|
||||
;; 1. Generate report
|
||||
(println "Generating mutation testing report for" (str target-ns) "...")
|
||||
(coverage/generate-report target-ns [test-ns] report-path)
|
||||
(println "Report written to" report-path)
|
||||
|
||||
;; 2. Run coverage for the single function
|
||||
(println "Running coverage for" (str fq-fn-name) "...")
|
||||
(let [coverage-results (coverage/test-namespace target-ns [test-ns])
|
||||
fn-data (get coverage-results fq-fn-name)]
|
||||
(when-not fn-data
|
||||
(throw (ex-info (str "Function not found in coverage results: " fq-fn-name)
|
||||
{:available (keys coverage-results)})))
|
||||
(let [mutations (:survived fn-data)]
|
||||
(if (empty? mutations)
|
||||
(do (println "No surviving mutations for" (str fq-fn-name) "— nothing to do.")
|
||||
{:status :success :killed [] :survived []})
|
||||
|
||||
(do
|
||||
(println (count mutations) "surviving mutations")
|
||||
|
||||
;; 3. Invoke Claude
|
||||
(println "Invoking Claude to write tests...")
|
||||
(let [prompt (build-test-prompt {:target-ns target-ns
|
||||
:test-ns test-ns
|
||||
:source-path source-path
|
||||
:test-path test-path
|
||||
:fn-names [fq-fn-name]
|
||||
:mutations mutations})]
|
||||
(invoke-claude! prompt))
|
||||
|
||||
;; 4. Verify and retry
|
||||
(println "Verifying mutations killed...")
|
||||
(let [result (verify-and-retry! {:fn-names [fq-fn-name]
|
||||
:test-ns test-ns
|
||||
:test-path test-path
|
||||
:max-retries max-retries})]
|
||||
(println "\nResult:" (:status result))
|
||||
(println " Killed:" (count (:killed result)))
|
||||
(println " Survived:" (count (:survived result)))
|
||||
(when (seq (:survived result))
|
||||
(println "\n Surviving mutations:")
|
||||
(doseq [{:keys [description]} (:survived result)]
|
||||
(println " -" description)))
|
||||
result))))))))
|
||||
|
||||
(defn run!
|
||||
"Main entry point. Runs the full mutation testing workflow for a namespace.
|
||||
|
||||
Prerequisites:
|
||||
- nREPL running
|
||||
- LINEAR_API_KEY env var set
|
||||
- gh CLI authenticated
|
||||
- team-id configured via (set-config! {:team-id \"...\"})
|
||||
|
||||
Options (optional second arg):
|
||||
- :base-branch — git branch to create feature branches from (default: from config, or \"master\")
|
||||
- :project-id — existing Linear project ID to use (skips creating a new project)"
|
||||
([target-ns-sym] (run! target-ns-sym {}))
|
||||
([target-ns-sym opts]
|
||||
(when-let [bb (:base-branch opts)]
|
||||
(set-config! {:base-branch bb}))
|
||||
(when-let [pid (:project-id opts)]
|
||||
(set-config! {:project-id pid}))
|
||||
;; 0. Fail early if working tree is dirty
|
||||
(let [status (:out (sh "git" "status" "--porcelain" "-uno"))]
|
||||
(when-not (str/blank? status)
|
||||
(throw (ex-info (str "Uncommitted changes detected. Commit or stash before running.\n" status)
|
||||
{:status status}))))
|
||||
(let [parsed (parse-namespace target-ns-sym)
|
||||
{:keys [target-ns test-ns report-path]} parsed]
|
||||
;; 1. Generate report
|
||||
(println "Generating mutation testing report for" (str target-ns) "...")
|
||||
(coverage/generate-report target-ns [test-ns] report-path)
|
||||
(println "Report written to" report-path)
|
||||
|
||||
;; 2. Create or reuse Linear project
|
||||
(let [project (if (:project-id @config)
|
||||
(do (println "Using existing Linear project:" (:project-id @config))
|
||||
{:project-id (:project-id @config)
|
||||
:name (str "Existing project " (:project-id @config))})
|
||||
(do (println "Creating Linear project...")
|
||||
(let [p (create-project-for-namespace! (str target-ns) report-path)]
|
||||
(println "Created project:" (:name p))
|
||||
p)))]
|
||||
|
||||
;; 3. Run coverage and group functions
|
||||
(println "Running coverage analysis and grouping...")
|
||||
(let [coverage-results (coverage/test-namespace target-ns [test-ns])
|
||||
groups (group-functions coverage-results)]
|
||||
(println "Found" (count groups) "function groups to process")
|
||||
|
||||
;; 4. Process each group
|
||||
(let [results (vec
|
||||
(for [group groups]
|
||||
(do
|
||||
(println "\n--- Processing:" (:primary-fn group)
|
||||
"(" (count (:mutations group)) "surviving mutations) ---")
|
||||
(try
|
||||
(process-group! parsed group)
|
||||
(catch Exception e
|
||||
(let [log-file (str "mutation-testing-error-"
|
||||
(name (:primary-fn group))
|
||||
".log")]
|
||||
(with-open [w (io/writer log-file)]
|
||||
(.write w (str "Error processing group: " (:primary-fn group) "\n"))
|
||||
(.write w (str "Message: " (.getMessage e) "\n\n"))
|
||||
(.printStackTrace e (java.io.PrintWriter. w)))
|
||||
(println "ERROR:" (.getMessage e))
|
||||
(println " Full stack trace logged to" log-file))
|
||||
(try (return-to-base!) (catch Exception _))
|
||||
{:error (.getMessage e)
|
||||
:primary-fn (:primary-fn group)})))))]
|
||||
|
||||
;; 5. Print summary
|
||||
(print-summary! {:project project
|
||||
:groups groups
|
||||
:results results})
|
||||
results))))))
|
||||
|
||||
(comment
|
||||
;; Setup:
|
||||
;; 1. Set LINEAR_API_KEY env var before starting the REPL
|
||||
|
||||
@@ -65,7 +65,9 @@
|
||||
(def cli-spec [["-h" "--help" "Show this help text"]
|
||||
["-H" "--hot" "Enable hot reloading"] ;
|
||||
["-p" "--port PORT" "Port to run the nREPL server on"
|
||||
:default 50605
|
||||
:default (if-let [p (env/env :nrepl-port)]
|
||||
(Integer/parseInt p)
|
||||
50605)
|
||||
:parse-fn #(Integer/parseInt %)]])
|
||||
|
||||
(defn -main
|
||||
|
||||
@@ -87,6 +87,7 @@ Metabase's reference documentation.
|
||||
|
||||
- [The SQL editor](./questions/native-editor/writing-sql.md)
|
||||
- [SQL parameters](./questions/native-editor/sql-parameters.md)
|
||||
- [Table variables](./questions/native-editor/table-variables.md)
|
||||
- [Referencing models and saved questions](./questions/native-editor/referencing-saved-questions-in-queries.md)
|
||||
- [Snippets](./questions/native-editor/snippets.md)
|
||||
- [Snippet folder permissions](./permissions/snippets.md)
|
||||
|
||||
@@ -30,13 +30,36 @@ For now, Metabot is only available for Metabase Cloud. Before you can set up Met
|
||||
|
||||
Once you've added Metabot AI in the Metabase store, you can log into your Metabase and configure it in _Admin > AI_.
|
||||
|
||||
## Metabot options
|
||||
|
||||
You can set options for Metabot, both for your internal Metabase, and (separately) for embedded Metabase contexts. That way you can, say, use Metabot in your Metabase, while not giving access to Metabot in your embedded Metabase.
|
||||
|
||||
- [Enable Metabot](#enable-metabot)
|
||||
- [Verified content](#verified-content)
|
||||
- [Collection Metabot can use](#collection-metabot-can-use)
|
||||
|
||||
## Enable Metabot
|
||||
|
||||
Toggle [Metabot](./metabot.md) on or off for your Metabase or embedded contexts. Both Metabot and Embedded Metabot are enabled by default.
|
||||
|
||||
### Internal Metabot
|
||||
|
||||
When enabled, Metabot is available to help people create questions, analyze data, and answer questions about your data. When disabled, the Metabot icon and keyboard shortcuts are hidden.
|
||||
|
||||
### Embedded Metabot
|
||||
|
||||
The toggle will turn embedded Metabot on or off. The toggle affects both full-app embeds and modular embeds.
|
||||
|
||||
- [Full-app embedding](../embedding/full-app-embedding.md): The Metabot icon and keyboard shortcuts are only available when Metabot is enabled. Turning off Embedded Metabot will hide these icons and disable the keyboard shortcuts.
|
||||
- [Modular embedding](../embedding/modular-embedding.md) The toggle doesn't add Metabot anywhere; you have to explicitly include a chat component (like the SDK's [`MetabotQuestion`](../embedding/sdk/ai-chat.md)) in your application. If, however, you've added a component, and you turn off the Embedded Metabot toggle, your chat component will stop working, so you should also remove or hide the component in your application.
|
||||
|
||||
## Verified content
|
||||
|
||||
Admins on Pro and Enterprise plans can tell Metabot to only work with [models](../data-modeling/models.md) and [metrics](../data-modeling/metrics.md) that have been [verified](../exploration-and-organization/content-verification.md).
|
||||
|
||||
Restricting Metabot to verified models and metrics (and only models and metrics) helps Metabot produce more reliable answers, since you know someone has at least vetted the data Metabot can use.
|
||||
|
||||
## Collection for natural language querying
|
||||
## Collection Metabot can use
|
||||
|
||||
Select a collection (including its subcollections) to limit which collections Metabot searches during [AI exploration](../ai/metabot.md#ai-exploration).
|
||||
|
||||
@@ -44,18 +67,6 @@ This setting only affects conversations started from **+ New → AI exploration*
|
||||
|
||||
People can still @-mention items outside of this collection when prompting in AI exploration. Metabot can also see the person's current context (for example, Metabot will know about the dashboard they're currently viewing, even if the dashboard is outside the selected collection).
|
||||
|
||||
## When embedding Metabot, you can pick a collection for Metabot to have access to
|
||||
|
||||
When embedding Metabot in your app, you can select a collection for Metabot:
|
||||
|
||||
2. Click **Embedded Metabot**.
|
||||
3. In the **Collection Embedded Metabot can use** section, click **Pick a collection**.
|
||||
4. Select the collection that contains the models and metrics you want Metabot to use.
|
||||
|
||||
Metabot will use the models and metrics in that collection to help answer questions and generate queries. You can change this collection at any time. To give Metabot access to all collections, you can set the collection to the root collection, called "Our Analytics" (the default).
|
||||
|
||||
Alternatively (or additionally), you can restrict Metabot to [verified content](#verified-content).
|
||||
|
||||
## Tips for making the most of Metabot
|
||||
|
||||
The best thing you can do to improve Metabot's performance is to prep your data like you would for onboarding a new (human) hire to your data. In practice, this means you should:
|
||||
|
||||