Compare commits

...
Author SHA1 Message Date
t8y2 a93ea17aa4 chore(release): prepare v0.6.47 2026-10-17 22:36:14 +08:00
lxk955 00cbb87c5d feat(d1): support https proxy and custom ca for d1 and turso 2026-10-17 20:18:34 +08:00
lxk955 8a31da122c fix(dameng): include column comments in table ddl 2026-10-17 20:16:07 +08:00
lxk955 b7cddbb253 feat(export): configurable query result export file name 2026-10-17 20:12:35 +08:00
Xsyl.way 50f4ec9f6b fix(grid): disambiguate duplicate column names in headers 2026-10-17 20:11:29 +08:00
lxk955 7068ffae92 feat(objects): add setting to disable auto opening detail 2026-10-17 20:09:55 +08:00
renovate[bot] f4c6804398 fix(deps): update hikaricp to 7.1.0 2026-10-17 20:09:44 +08:00
Jarvis 0975e1c1bc fix(generate): honor clock time bounds in data generation 2026-10-17 20:09:33 +08:00
Xsyl.way 7abdbf3eb0 feat(settings): allow disabling click to pin result tabs 2026-10-17 20:09:22 +08:00
Jarvis 268829afb2 fix(csv): count decimal significant digits across separator 2026-10-17 20:09:11 +08:00
Saif Alaqqad 63df21a9ce feat(editor): add shortcuts to open toolbar dropdowns 2026-10-17 19:27:53 +08:00
Xsyl.way 6627e8e594 fix(agent): treat null agent result vectors as empty 2026-10-17 19:27:40 +08:00
lxk955 9174c6e265 feat(ai): support opencode 2 cli without pure flag 2026-10-17 17:46:06 +08:00
Jarvis 0f745f73d0 docs(editor): explain double click string and word selection 2026-10-17 17:44:46 +08:00
Xsyl.way a504853149 feat(diff): add continue on error option to schema deploy 2026-10-17 17:44:36 +08:00
Jinpy 8c45af6bdd fix(plugins): release dock terminal plugin connections on close 2026-10-17 17:44:25 +08:00
renovate[bot] 6285f37915 fix(deps): update yashandb jdbc driver to 1.10.7 2026-10-17 17:44:15 +08:00
lxk955 5fb7a54e59 feat(firebird): keep routine and trigger blocks together 2026-10-17 17:43:40 +08:00
dienaso 8f22a9f8cd feat(sidebar): show active connection host and port 2026-10-17 17:43:29 +08:00
Terry Yao edf56588a2 fix(grid): use destructive color for delete row toolbar 2026-10-17 17:43:18 +08:00
zipg d662c6a39e fix(plugin): align dev host asset limit with runtime 2026-10-17 17:43:07 +08:00
ZhongTai ab92129752 fix(schema): list databases and tables on mycat proxies 2026-10-17 15:59:04 +08:00
Jarvis e2918951bb docs: sync pnpm version across contribution guides 2026-10-17 15:33:15 +08:00
zipg 44905e021b fix(editor): complete mysql routine params and local variables 2026-10-17 15:18:52 +08:00
Guoyu Su 1a88b640f6 fix(redis): honor configured cluster connection timeout 2026-10-17 15:16:50 +08:00
efeire ead985d0b9 fix(sidebar): show oceanbase usernames without escaping at 2026-10-17 15:15:32 +08:00
hanxuanyu 34429cf8d6 fix(tabs): use query file icon for query tabs 2026-10-17 15:15:20 +08:00
renovate[bot] 89eabbfb5f fix(deps): update hana jdbc driver to 2.30.11 2026-10-17 15:15:09 +08:00
lxk955 05e0539a1e fix(dameng): skip identity insert for auto increment columns 2026-10-17 15:14:50 +08:00
弈秋 d12bafae30 fix(transfer): map oracle family booleans to number one 2026-10-17 15:14:38 +08:00
lxk955 dee61595f6 fix(mcp): publish properties in tool input schemas 2026-10-17 15:14:03 +08:00
弈秋 c7004df282 fix(sqlserver-legacy): read back table comments 2026-10-17 15:13:53 +08:00
lxk955 bd700c8b67 fix(gbase8s): resolve extended column types from sysxtdtypes 2026-10-17 15:13:42 +08:00
zipg d8943706c0 fix(sql): keep semicolonless statements on separate lines 2026-10-17 15:13:31 +08:00
Guoyu Su 99eca47cc3 fix(mysql): rebuild edited indexes with alter table 2026-10-17 13:48:59 +08:00
lxk955 abe260010c fix(plugins): normalize marketplace release url per host 2026-10-17 13:48:44 +08:00
lxk955 f382082c76 feat(grid): copy sql deletes and inserts for selected rows 2026-10-17 13:37:49 +08:00
lxk955 39df0f53d1 fix(sql): honor newline before semicolon in dbx layout 2026-10-17 13:24:20 +08:00
lxk955 5fd7467b7c fix(tabs): isolate tab collision numbering by mode 2026-10-17 13:24:05 +08:00
二丫讲梵 adbe7e7ab1 feat(grid): add toggle for result sql footer 2026-10-17 13:23:51 +08:00
二丫讲梵 a10bd55a73 fix(mysql): disable next page via read only probe 2026-10-17 13:23:39 +08:00
dienaso 25539c6e24 fix(transfer): drop opengauss storage options for pg targets 2026-10-17 13:23:25 +08:00
lxk955 1065bf89ac fix(structure): enable and disable triggers for sql server 2026-10-17 12:06:18 +08:00
Jeff c86abb1ad4 fix(postgres): log underlying io error on connection failure 2026-10-17 11:37:32 +08:00
lxk955 35d06151fa fix(postgres): avoid information schema columns join 2026-10-17 11:29:51 +08:00
ericbyz f0d14ebfa6 fix(grid): search full cell values past display truncation 2026-10-17 11:17:00 +08:00
lxk955 9250ef2dbc fix(sqlserver): follow tds routing redirects in native driver 2026-10-17 10:59:21 +08:00
lxk955 f3872d9c84 fix(oscar): preserve comments when duplicating tables 2026-10-17 10:59:03 +08:00
lxk955 68cfe5b679 fix(grid): support cross database foreign key navigation 2026-10-17 10:58:51 +08:00
t8y2 538b8183cd fix(docs): accept grouped connection profiles in support matrix check 2026-10-17 09:21:15 +08:00
github-actions[bot] cbdf5c9cdc chore(jdbc): bump plugin version [skip ci] 2026-10-16 17:47:39 +00:00
t8y2 8103a24954 chore(release): prepare v0.6.46 2026-10-17 01:46:39 +08:00
t8y2 1ea8d808b4 fix: silence unused driver_profile after gbase8s split 2026-10-17 01:46:06 +08:00
github-actions[bot] 4d59f4a20c chore(packages): release 0.4.122 [skip node-packages-release] 2026-10-16 16:53:29 +00:00
t8y2 614fcc3c4d fix: export McpKeychainAccess type and format dialect capabilities 2026-10-17 00:47:35 +08:00
github-actions[bot] a83902d7e2 chore: bump module versions [skip ci] 2026-10-16 16:38:25 +00:00
t8y2 a7d6b80e2c feat(gbase8s): split GBase 8s into its own dbType
Closes #12486
2026-10-16 21:33:43 +08:00
t8y2 854077dee8 test: remove source-text assertion specs 2026-10-16 21:14:10 +08:00
t8y2 8379680404 feat(mcp): replace the authorization stepper with direct section tabs 2026-10-16 20:16:17 +08:00
github-actions[bot] cb05362a85 chore(packages): release 0.4.121 [skip node-packages-release] 2026-10-16 11:33:59 +00:00
HighBigSky 03204b15fe feat(grid): copy a column comment from the result grid 2026-10-16 19:09:42 +08:00
t8y2 2b1842ecac fix(i18n): format GBase 8c driver hint in five locales 2026-10-16 18:47:00 +08:00
t8y2 5cecc55613 feat(mcp): detect and authorize macOS Keychain access for dbx-mcp 2026-10-16 18:42:35 +08:00
t8y2 c8a16708ca feat(connection): group GBase 8c into the GBase picker entry
Closes #12483
2026-10-16 18:40:54 +08:00
t8y2 ff6c68c3e9 feat(gbase8c): bundle vendor JDBC driver in the agent
Source: gbase.cn GBase-JDBC-x86_64.zip / GBase-JDBC-aarch64.zip,
gbase8c-jdbc-3.0.0B02.jar, sha256
fe115f05124f6b9f37e49c829a0def28f7d09a6d871dd62a0c9f856b0aaa7e91.
2026-10-16 18:17:44 +08:00
xiSage 042b02d953 feat(gbase8c): add GBase 8c connection type via agent and vendor JDBC 2026-10-16 18:05:53 +08:00
github-actions[bot] f8734ce1a1 chore(docs): refresh contributor activity 2026-10-16 09:48:49 +00:00
t8y2 1c141a9c78 fix(docs): restore pinned deps and pnpm 10 lockfile format
Renovate rewrote docs/pnpm-lock.yaml with its bundled pnpm, downgrading
the patchedDependencies block to the pre-10.34 scalar format, which fails
the CI frozen install with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH, and also
bumped fumadocs/lucide-react past the versions pinned after the
2026-10-14 regression. Restore both files to the last known good state
(verified: pnpm 10.34.7 frozen install + full docs build pass) and stop
renovate from managing docs npm deps until its pnpm writes the 10.x
lockfile format.
2026-10-16 17:43:41 +08:00
GGbond 3015313d23 fix(settings): keep footer buttons clickable at high ui scale 2026-10-16 17:41:10 +08:00
弈秋 4e449037e6 fix(redis): let fuzzy search cover keyspace aware scan budget 2026-10-16 17:40:57 +08:00
t8y2 46280b7e87 fix(deps): update lucide vue to 1.58.0 2026-10-16 17:40:01 +08:00
HighBigSky 4722a49ab9 feat(connection): auto disconnect idle connections 2026-10-16 17:36:42 +08:00
二丫讲梵 5db11475a5 feat(objects): add compact browser view and column controls 2026-10-16 17:35:27 +08:00
lxk955 e3af7bc3a3 fix(duckdb): detect and export computed column types 2026-10-16 17:35:14 +08:00
ordinary being 13fd1003f1 fix(grid): copy text like mysql blobs as decoded text 2026-10-16 17:34:59 +08:00
Mo-Moore 741b10b435 fix(grid): speed up join header comments and show type lengths 2026-10-16 17:34:46 +08:00
github-actions[bot] 9511ae838a chore(docs): refresh contributor activity 2026-10-16 09:31:31 +00:00
t8y2 01d95dcdee test(mongodb): use placeholder Atlas host in SRV URL fixtures 2026-10-16 16:56:46 +08:00
圆头圆脑 ca6d0c22e7 feat(rocketmq): support proxy grpc sending with optional tls 2026-10-16 16:44:46 +08:00
t8y2 78086e63e4 fix(desktop): reset corrupt persisted main window size on restore 2026-10-16 16:43:16 +08:00
t8y2 4fcb0a120a chore(docs): refresh docs export bundle for token font order 2026-10-16 16:13:18 +08:00
t8y2 f35d2a9997 fix(mcp): verify keychain authorization persists for background use 2026-10-16 16:08:54 +08:00
Sowevo 49f3ded4dc fix(transfer): map datetime to timestamp for h2 targets 2026-10-16 15:39:53 +08:00
luo jiyin 4d59319d17 perf(agent): share rpc response wait across result types 2026-10-16 15:08:26 +08:00
_Mr丶s ゛😁® bcb2f7a624 feat(format): default to dbx layout and align fields 2026-10-16 15:07:18 +08:00
弈秋 ce113aabf1 feat(grid): let users pick how boolean cells read 2026-10-16 15:07:06 +08:00
efeire 13e1823617 fix(oceanbase-oracle): return dbms output text 2026-10-16 15:06:52 +08:00
lxk955 8ec7667322 fix(browser): preserve database browser scroll position 2026-10-16 15:06:40 +08:00
Xsyl.way 8dfe536100 fix(redis): bound visible database picker metadata waits 2026-10-16 15:06:27 +08:00
lxk955 a51013073f fix(grid): continue load-all loop after jumped page reload 2026-10-16 15:06:16 +08:00
Xsyl.way 6267839596 fix(grid): auto-format json when opening cell detail 2026-10-16 15:06:06 +08:00
Abeautifulsnow e029e1a1c6 feat(grid): add row on arrow down and revert with escape 2026-10-16 15:05:55 +08:00
renovate[bot] a3c282bb49 fix(deps): update teradata jdbc driver to 20.00.00.62 2026-10-16 15:05:45 +08:00
t8y2 f441d781ec feat(transaction): configurable manual transaction idle timeout
Add a per-connection manual_transaction_timeout_secs (default 300s,
30-86400s) honored by every idle-rollback path. When a session that may
hold uncommitted work expires, stop and show the rollback notice instead
of replaying the statement into a fresh transaction; proven read-only
sessions still restart silently. Error and notice texts no longer
hard-code five minutes.
2026-10-16 13:52:24 +08:00
HARRISON 3de078190e fix(databend): save grid edits without an explicit transaction 2026-10-16 13:26:23 +08:00
_Mr丶s ゛😁® 8334249d5d feat(snippets): add scheduled auto upload 2026-10-16 13:26:12 +08:00
_Mr丶s ゛😁® a85f6528f8 feat(ai): add magpie config import entry 2026-10-16 13:26:01 +08:00
renovate[bot] 2c9789fafc fix(deps): update npm minor and patch dependencies 2026-10-16 13:25:50 +08:00
Loc Nguyen c2b7e427af fix(mongodb): accept mongosh number type constructors 2026-10-16 12:01:32 +08:00
弈秋 fd79509106 fix(jdbc): tolerate informix style sqlexception from schemas 2026-10-16 12:01:21 +08:00
Loc Nguyen c345d1694a feat(mongodb): offer srv url for atlas cluster hosts 2026-10-16 12:01:10 +08:00
Mo-Moore 5136322a74 fix(sql): keep consecutive case expressions from splitting procedures 2026-10-16 12:00:59 +08:00
弈秋 2ea94eaad0 fix(plugins): scroll marketplace detail and collapse history 2026-10-16 12:00:34 +08:00
Andrew 7510e91d9a fix(gaussdb): hide system columns from index metadata 2026-10-16 12:00:23 +08:00
弈秋 d9f57be531 feat(grid): auto-fold super long single-line cell values 2026-10-16 12:00:11 +08:00
dienaso 58671d9cc5 fix(grid): reload from first row when load-all jumps pages 2026-10-16 12:00:00 +08:00
onenewcode 0264cfbb03 fix(grid): edit keyless postgres tables via ctid row locator 2026-10-16 09:57:12 +08:00
lxk955 668bf6f02c feat(backup): add permanent retention for scheduled backups 2026-10-16 09:57:01 +08:00
efeire 166a0ba226 fix(oracle): repair oceanbase constraint preflight and readback 2026-10-16 09:56:49 +08:00
Jeff 0204dff904 fix(oceanbase-oracle): keep sequence queries out of rownum wrapper 2026-10-16 09:56:36 +08:00
二丫讲梵 7e683635df fix(grid): preselect first order by suggestion 2026-10-16 09:56:25 +08:00
二丫讲梵 948f979869 feat(mongodb): add collection clear actions 2026-10-16 09:18:55 +08:00
二丫讲梵 4e6c9b703d feat(search): ignore underscores in object browser search 2026-10-16 08:59:09 +08:00
二丫讲梵 06bafe46b5 fix(grid): prioritize selected-column export actions 2026-10-16 08:58:55 +08:00
github-actions[bot] 643f621241 chore(packages): release 0.4.120 [skip node-packages-release] 2026-10-15 17:01:37 +00:00
t8y2 13b6acafed chore(release): prepare v0.6.45 2026-10-16 00:51:30 +08:00
github-actions[bot] a8bae14443 chore: bump module versions [skip ci] 2026-10-15 16:45:09 +00:00
zipg b58ae49e46 feat(tabs): default to compact tab titles via migration 2026-10-16 00:03:40 +08:00
t8y2 e302e2003b fix(grid): use postgres label key in filter order dialog 2026-10-16 00:02:33 +08:00
renovate[bot] d60ea853fa fix(deps): update mysql connector j to 9.7.0 2026-10-16 00:00:58 +08:00
二丫讲梵 d2841a5029 fix(grid): make cell detail layout responsive 2026-10-16 00:00:43 +08:00
二丫讲梵 6565b1b94f feat(grid): link column filter values 2026-10-16 00:00:32 +08:00
zipg c9d93b6a4e feat(grid): customize filter operator order 2026-10-16 00:00:20 +08:00
Andrew aae04246c4 feat(gaussdb): apply query dop hint to count and page sql 2026-10-15 23:00:55 +08:00
zipg 8be7a5b131 fix(hana): keep nested case sqlscript procedures together 2026-10-15 22:06:30 +08:00
dbin0123 4c4341db96 feat(grid): add inline base64 binary preview with format picker 2026-10-15 22:05:37 +08:00
Jeff b950ff8f2e fix(grid): add primary key tie breaker to database sorts 2026-10-15 22:05:21 +08:00
efeire 16a3806ace fix(settings): confirm unsaved changes before closing 2026-10-15 22:05:06 +08:00
renovate[bot] 92e6ad0b5e fix(deps): update kylin jdbc driver to 5.0.5 2026-10-15 22:04:52 +08:00
t8y2 0f31541fa8 test(structure): mock connection state revision in preset spec 2026-10-15 21:20:25 +08:00
efeire 61d3c5b3f6 feat(oracle): add constraint and index editing 2026-10-15 20:29:10 +08:00
hanxuanyu 583fb28a42 fix(data-grid): treat untouched new rows as placeholders 2026-10-15 20:28:11 +08:00
lxk955 5f7b941e5a feat(tabs): add save all tabs to tab context menu 2026-10-15 20:27:58 +08:00
Guoyu Su 1e67df5566 fix: preserve deep links received before app setup 2026-10-15 20:27:45 +08:00
Xsyl.way 326ee4f96d perf(gaussdb): skip doomed modern metadata tier 2026-10-15 20:27:31 +08:00
弈秋 4e618f3454 fix(grid): make ctrl-c copy from the sql preview panel 2026-10-15 20:27:16 +08:00
lxk955 3ef9639dbf fix(duckdb): save grid batch edits without a transaction 2026-10-15 20:27:02 +08:00
renovate[bot] c915ec76c6 fix(deps): update mssql jdbc driver to 13.6.1 2026-10-15 20:26:48 +08:00
daken b79b79d6d7 fix(tabs): keep overflow arrow stable and follow title clipping 2026-10-15 19:40:05 +08:00
二丫讲梵 b4c011e553 fix(nacos): improve config diff comment readability 2026-10-15 19:29:59 +08:00
Xsyl.way 652c72b9e2 fix(postgres): resolve empty-schema metadata under gaussdb a mode 2026-10-15 19:29:48 +08:00
Xsyl.way 15d02fa1c1 fix(grid): keep header index badges stable on refresh 2026-10-15 19:29:36 +08:00
Jeff 50f5d1698d fix(export): write postgres arrays as array literals 2026-10-15 19:29:26 +08:00
高桥凉介 f6c49e3183 fix(export): strip mysql object definers from database exports 2026-10-15 19:29:14 +08:00
renovate[bot] ae798d5862 fix(deps): update intersystems jdbc driver to 3.11.0 2026-10-15 19:28:40 +08:00
Xsyl.way 12dfcb424b fix(connection): keep equals readable in copied connection urls 2026-10-15 19:28:27 +08:00
Abeautifulsnow 4a5d1c78c1 feat(mysql): merge batched grid update keys into in clauses 2026-10-15 19:28:14 +08:00
lxk955 3a3f4199a1 feat(structure): insert preset fields in table edit mode 2026-10-15 19:28:00 +08:00
zipg 318c8221d1 fix(sundb): resolve mixed-case table metadata via driver retry 2026-10-15 19:27:47 +08:00
Wangnan 0eeebba43e fix(connection): keep connection dialog tab list position stable 2026-10-15 17:40:48 +08:00
renovate[bot] e8d1f4cea2 fix(deps): update gson to 2.14.0 2026-10-15 17:15:26 +08:00
李子凡 d0447bc264 fix(sqlserver): keep long multiline literals over 4000 chars 2026-10-15 17:14:39 +08:00
李子凡 fc5fe07d25 fix(kingbase): return time columns as time of day 2026-10-15 17:14:27 +08:00
弈秋 a313b99590 fix(grid): record structured order by in sort history 2026-10-15 17:14:16 +08:00
Xsyl.way 9d38961a61 fix(dameng): load and edit triggers in table structure 2026-10-15 17:14:06 +08:00
二丫讲梵 c0c4a470ab feat(sql-file): support selecting file encoding for execution 2026-10-15 17:13:55 +08:00
zipg d51926a7e3 feat(redis): configure value preview limit (#12089)
* feat(redis): configure value preview limit

* docs(testing): record Redis preview limit verification

* refactor(redis): move preview limit to connection advanced settings

* docs(testing): update Redis advanced setting screenshot

* refactor(redis): place preview limit at bottom of advanced settings
2026-10-15 16:43:57 +08:00
zipg 74b825f109 improve(grid): adapt split toolbar collapse to measured overflow 2026-10-15 14:46:15 +08:00
ezreal 041eb78f4b fix(plugins): coordinate sidecar cleanup across hosts 2026-10-15 14:46:02 +08:00
dadong 9a525d6c9c fix(icons): replace codebuddy provider icon with official logo 2026-10-15 14:45:49 +08:00
Woohyuck Jeong ba57a3cb4c fix(data-grid): guard temporal slices against utf8 boundaries 2026-10-15 14:03:06 +08:00
t8y2 3668b0a481 fix(schema): align auto increment test with compatible profiles 2026-10-15 13:33:33 +08:00
Conastin 160e57bbf4 fix(grid): count influxdb points across field columns 2026-10-15 13:14:04 +08:00
zipg 846081d588 fix(grid): make total row count action text-only 2026-10-15 13:13:45 +08:00
t8y2 d5e34a3eea feat(ai): add markdown claim link for astraflow credit 2026-10-15 13:03:11 +08:00
renovate[bot] ba4496319a fix(deps): update google-cloud-spanner-jdbc to 2.46.0 2026-10-15 12:53:39 +08:00
Jeff ddea4a44d4 fix(schema-diff): rewrite oracle table ddl refs to target schema 2026-10-15 12:53:03 +08:00
HARRISON e08a59537b fix(ui): add submenu pointer grace area for diagonal hover 2026-10-15 12:52:31 +08:00
Loc Nguyen b7553e6489 feat(mongodb): support createview and user role shell helpers 2026-10-15 12:52:19 +08:00
t8y2 3af7e2c55d feat(transfer): add per-task operation timeout 2026-10-15 12:39:48 +08:00
t8y2 5d341d3afd feat(table): open copy dialog from clone action 2026-10-15 12:29:21 +08:00
t8y2 7815319a7a feat(mysql): support AUTO_INCREMENT for compatible profiles 2026-10-15 12:10:32 +08:00
t8y2 ad6dd5d9d5 fix(grid): trigger infinite scroll after scrollbar drag 2026-10-15 12:00:21 +08:00
t8y2 7e6dc57e17 fix(query): use selected sidebar context from toolbar
Closes #11791
2026-10-15 11:36:01 +08:00
二丫讲梵 b07f043e7e feat(sidebar): show comments and object sizes together 2026-10-15 11:28:08 +08:00
Unclechan97 da9040ede5 fix(grid): stop transpose cell clicks from scrolling field to top 2026-10-15 10:43:13 +08:00
Guoyu Su 3def78fc5a fix(connection): forget transient password on user close 2026-10-15 10:35:15 +08:00
二丫讲梵 40c51b605c feat(query): add persistent default database queries 2026-10-15 10:35:04 +08:00
Guoyu Su 87aa9f4d29 fix(web): set cache headers for directory static assets 2026-10-15 10:34:52 +08:00
二丫讲梵 b0f36c88a1 fix(ui): unify auto-refresh controls 2026-10-15 10:34:41 +08:00
二丫讲梵 32b12cf708 feat(grid): resize pending sql preview panel 2026-10-15 10:34:28 +08:00
二丫讲梵 9752940fba feat(grid): add exact column lookup mode 2026-10-15 10:34:10 +08:00
Jeff 131db14d62 fix(oracle): bind out parameters when executing procedures 2026-10-15 10:33:56 +08:00
Jeff ab1cbf1a0a fix(mysql): page duplicate column results without derived table 2026-10-15 10:33:45 +08:00
二丫讲梵 e64a7716fe fix(etcd): hide generic query sidebar actions 2026-10-15 10:33:33 +08:00
renovate[bot] adf1bb0be7 fix(deps): update databricks jdbc driver to 3.4.3 2026-10-15 10:33:21 +08:00
高桥凉介 ddb8e1ad7f fix(search): budget table comments and cover tdengine 2026-10-15 10:32:56 +08:00
t8y2 12139b55df fix(docs): regenerate lockfile and repin fumadocs and lucide 2026-10-15 02:16:24 +08:00
github-actions[bot] c63a177683 chore(packages): release 0.4.119 [skip node-packages-release] 2026-10-14 17:03:53 +00:00
github-actions[bot] 7a271c3549 chore: bump module versions [skip ci] 2026-10-14 16:42:51 +00:00
t8y2 dac23ff1e6 chore(release): prepare v0.6.44 2026-10-14 23:33:34 +08:00
t8y2 25f9b4b1ef fix(deps): re-pin tauri npm packages and stop renovate touching them 2026-10-14 23:33:16 +08:00
t8y2 c1c18fd5d4 fix(updater): clean runtimes before forced Windows exit 2026-10-14 22:40:45 +08:00
Jeff 63c03139b6 fix(grid): save mysql virtual unique key under loaded scope 2026-10-14 21:25:52 +08:00
Jeff 1c33dcf5aa fix(postgres): defer export index ddl until after functions 2026-10-14 21:20:58 +08:00
renovate[bot] 4bcdd5b7e8 fix(deps): update exasol jdbc driver to 26.2.10 2026-10-14 21:20:38 +08:00
renovate[bot] 80f1f4e445 fix(deps): update npm minor and patch dependencies 2026-10-14 20:47:32 +08:00
Abeautifulsnow 5a67c5dbd6 fix(ai): report ai sql action results and errors in pane 2026-10-14 20:46:20 +08:00
Guoyu Su f201de8f40 fix(ssh): resolve homebrew proxycommand helpers on macos 2026-10-14 20:46:10 +08:00
弈秋 b9de5e8a71 fix(export): quote standard insert identifiers per engine family 2026-10-14 20:46:00 +08:00
t8y2 72d72a15b7 fix(snapshot): downscale oversized grid exports 2026-10-14 20:31:42 +08:00
guoxdxdxd 7696285288 fix(sqlserver): honor encrypt=false for instances without SSL 2026-10-14 20:19:27 +08:00
ZhengJin a2bcbb5725 fix(jdbc): stop plugin processes before replacing jdbc directory 2026-10-14 19:32:58 +08:00
TL 89a29a952c feat(mqtt): add expand and collapse all for message payloads 2026-10-14 19:07:01 +08:00
efeire c498642826 feat(oracle): plan-governed object rename and package save-as 2026-10-14 18:48:54 +08:00
弈秋 52afd5e969 feat(mcp): declare tool annotations on all static tools 2026-10-14 18:48:46 +08:00
Rendegou 4b6896be3c fix(db2): preserve identifier and schema case in grid edits 2026-10-14 18:48:38 +08:00
Rendegou 845a3e7faf fix(settings): clarify default manual transaction setting scope copy 2026-10-14 18:48:30 +08:00
高桥凉介 46c0c939aa feat(search): match table comments in structure search 2026-10-14 17:33:47 +08:00
弈秋 42b22218a1 fix(desktop): grant clipboard-manager allow-read-image permission 2026-10-14 16:37:09 +08:00
zipg cfdcd9041b perf(dameng): defer cold query metadata until after execution 2026-10-14 16:25:44 +08:00
Loc Nguyen 3e9974b226 perf(mongodb): skip total count for explicit limit queries 2026-10-14 16:08:03 +08:00
弈秋 7a47efa112 fix(sqlserver): make display DDL work on SQL Server 2005 2026-10-14 16:06:26 +08:00
弈秋 616687e3de feat(completion): suggest dameng sequences in oracle completion 2026-10-14 16:06:12 +08:00
lxk955 56bc672173 refactor(query): extract shared result tab popover content 2026-10-14 15:44:23 +08:00
Diego Fabricio bdba3542bb fix(tab-bar): add spacing between open tabs button and navigation 2026-10-14 15:44:09 +08:00
HARRISON 4b7fc6d645 chore(lint): resolve vue binding shadowing and cap lint warnings 2026-10-14 15:26:26 +08:00
t8y2 6ea0ee5410 fix(lint): move jdbc progress sticky value out of computed 2026-10-14 15:25:07 +08:00
dadong 731cca9c29 fix(i18n): unify CodeBuddy Code CLI naming 2026-10-14 15:03:36 +08:00
t8y2 41ec800910 fix(lint): keep caught variable in scope after catch refactor 2026-10-14 14:51:49 +08:00
t8y2 4e04c557d6 fix(lint): clear warnings introduced by merged features 2026-10-14 14:33:20 +08:00
t8y2 72c03a4820 fix(ci): align win7 sccache spec with conditional gha fallback 2026-10-14 14:15:00 +08:00
HARRISON 184b55c89e fix(sql): return to result view after notices precede results 2026-10-14 14:07:38 +08:00
Abeautifulsnow c5d4e936d5 fix(oracle): prove built-in function queries read-only 2026-10-14 14:07:20 +08:00
zipg 15bf5c5844 fix(editor): prevent IME text insertion in Vim normal mode 2026-10-14 14:07:01 +08:00
Guoyu Su 5d5e9e8cc1 fix(vector): preserve fields by response origin 2026-10-14 14:06:46 +08:00
Loc Nguyen e18c60018c fix(oracle): add trigger operations in structure edit 2026-10-14 14:06:30 +08:00
Abeautifulsnow 62c8720316 fix(ai): persist conversation mode per conversation 2026-10-14 14:06:05 +08:00
弈秋 4c65040b4b fix(export): replay PostGIS cell SRID as ST_GeomFromEWKT 2026-10-14 14:05:47 +08:00
Jeff 240039f48d fix(grid): keep unpaginated query results on a single page 2026-10-14 14:05:31 +08:00
zipg 95d3e80004 fix(hana): expose table rename and column comments 2026-10-14 13:24:49 +08:00
t8y2 1d88de5535 fix(ci): prefer s3 sccache on windows and keep rust caches warm 2026-10-14 13:18:21 +08:00
二丫讲梵 e91b460ce9 feat(structure): copy fields across connections and databases 2026-10-14 13:15:54 +08:00
_Mr丶s ゛😁® 92814874ed fix(ai): preserve distinct imported cc-switch profiles 2026-10-14 13:04:55 +08:00
Jeff 270a61cd1e fix(query): keep multi-database merged column totals exact 2026-10-14 12:49:00 +08:00
Pongsakorn Weraburudpongsatorn e16c72aafc feat(i18n): add Thai localization 2026-10-14 12:21:22 +08:00
efeire 1aee1e8cb8 feat(oracle): reuse user and privilege admin pages 2026-10-14 12:14:45 +08:00
弈秋 1e039bfae2 fix(desktop): make regex generator values match the pattern 2026-10-14 11:30:00 +08:00
zipg eba6185a5f fix(query): keep result actions during slow execution 2026-10-14 11:29:56 +08:00
bamboo 837926cc0b feat(starrocks): add StarRocks engine support 2026-10-14 11:09:41 +08:00
弈秋 8381fa4f44 feat(completion): render icons for sequence and variable items 2026-10-14 11:09:37 +08:00
t8y2 0db01f7f28 fix(deps): pin tauri cli to 2.11.4 matching bundler macros 2026-10-14 08:45:29 +08:00
t8y2 26b895baa6 fix(deps): pin tauri npm packages to crate minor versions 2026-10-14 01:25:01 +08:00
t8y2 354b451605 fix(docs): pin fumadocs and lucide-react to compatible versions 2026-10-14 01:17:15 +08:00
t8y2 1c15f92f54 fix(docs): regenerate lockfile with pnpm 10.34.7 patched-dependencies format 2026-10-14 01:13:26 +08:00
github-actions[bot] c7d2986c16 chore(packages): release 0.4.118 [skip node-packages-release] 2026-10-13 16:34:44 +00:00
t8y2 41045f422b chore(release): prepare v0.6.43 2026-10-14 00:17:02 +08:00
github-actions[bot] bd86923b2f chore: bump module versions [skip ci] 2026-10-13 16:14:50 +00:00
lxk955 de1824274c feat(completion): suggest sequences in OceanBase-Oracle SQL 2026-10-13 23:48:28 +08:00
弈秋 e78244f4d3 fix(sql): keep WITH FUNCTION inline declarations with their main query 2026-10-13 23:48:19 +08:00
_Mr丶s ゛😁® 6fd96a5e50 feat(settings): show the last successful WebDAV sync time 2026-10-13 23:20:49 +08:00
zipg 3ae71e5529 improve(ui): highlight only the active horizontal tab 2026-10-13 23:20:40 +08:00
Jeff 4f3e9cfc13 fix(grid): keep selection SUM and AVG exact for BIGINT and DECIMAL 2026-10-13 23:20:31 +08:00
efeire e78da02f0d fix(export): keep LOB snapshot formatting and Oracle SQL raw values 2026-10-13 23:18:43 +08:00
t8y2 e250ed3001 fix(desktop): keep macOS icon independent from app theme 2026-10-13 22:32:20 +08:00
t8y2 37e49e75d1 fix(desktop): restore maximized windows within active monitor 2026-10-13 19:32:06 +08:00
renovate[bot] 188b94ece2 fix(deps): update npm minor/patch 2026-10-13 18:13:14 +08:00
lxk955 6b1d4fa64c feat(sidebar): add shortcut to open the table structure editor 2026-10-13 18:10:17 +08:00
efeire ece909f2e1 fix(oracle): preserve column parameters and OceanBase table clones 2026-10-13 18:07:58 +08:00
zipg fd3be84b33 feat(hana): enable table rename and structure editing 2026-10-13 17:56:29 +08:00
二丫讲梵 a86520234e fix(redis): unify value search highlighting and add replace controls 2026-10-13 17:19:40 +08:00
二丫讲梵 4b60c0440e feat(grid): add global first-column freezing 2026-10-13 17:19:32 +08:00
二丫讲梵 373c3c1257 fix(grid): preserve column search while the lookup panel is open 2026-10-13 17:19:23 +08:00
efeire 9cade98737 fix(sql): harden SQL recognition, duplicate commits, and batch execution edges 2026-10-13 17:19:14 +08:00
Andrew 79acc066e1 fix(query): reuse the Gaussdb quote helper for derived count aliases 2026-10-13 17:19:06 +08:00
zipg e1af334559 fix(ui): prevent production watermark overlap in short panes 2026-10-13 17:18:57 +08:00
t8y2 0776bb0065 test(data): align the Iris pagination expectation with quoted identifiers 2026-10-13 17:17:03 +08:00
t8y2 47b7b492d3 fix(data): harden transfer and comparison workflows 2026-10-13 16:15:53 +08:00
_Mr丶s ゛😁® aaa8e15791 feat(ai): add the AI config import plugin entry (CC-SWITCH) 2026-10-13 16:03:46 +08:00
ordinary being 2fbeffeb76 fix(transfer): reseed Oracle identity columns after copying explicit ids 2026-10-13 15:29:47 +08:00
zipg 773916e18d fix(hana): keep SQLScript procedure bodies together 2026-10-13 15:29:38 +08:00
HARRISON 63416a3cde fix(trino): keep the WITH clause in table DDL 2026-10-13 15:17:20 +08:00
HARRISON 9240b38feb fix(import): accept thousands separators in numeric targets 2026-10-13 15:17:12 +08:00
HARRISON 0c62340657 fix(sundb): stop quoting simple identifiers in generated SQL 2026-10-13 15:06:09 +08:00
zipg d5c8d5574a feat(sidebar): configure production badge scope and sticky context 2026-10-13 15:06:00 +08:00
t8y2 becccf108f chore(deps): stop renovate batches from re-raising pinned regressions 2026-10-13 14:41:58 +08:00
t8y2 3cbafbf91f fix(ci): gate the accelerator-keys helper to windows and tests 2026-10-13 14:38:57 +08:00
zipgandzipg 820f7b1c83 fix(ui): inherit icons for context submenu actions (#11867)
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-10-13 14:35:57 +08:00
zipgandzipg 78b1de5141 fix(oracle): keep copied connection URL credentials readable (#12020)
复制"连接 URL(含密码)"时,Oracle 连接的密码会走 encodeURIComponent,
`^` 被写成 `%5E`,粘贴到其他软件后与用户设置的密码不一致(#10057)。

Oracle 没有 URL 客户端,这个字符串只是 DBX 自己的交换格式,Oracle 从不做
percent 解码(与 #11612 里 JDBC 项保持字面量的原因相同)。因此 Oracle 的
标准 URL 改用 encodeOracleUrlCredential:保留 RFC 3986 的 unreserved 与
sub-delim,加上 `^`;`%`、userinfo/路径分隔符与空白仍然转义,保证复制出来
的文本导入后还是同一个凭据。

其它方言的 URL 由驱动消费,保持原有全量编码不变。

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-10-13 14:35:03 +08:00
HARRISON 051cab88a3 fix(neo4j): connect to Memgraph via the direct-connection fallback 2026-10-13 13:12:30 +08:00
_Mr丶s ゛😁® 28973c8803 feat(mqtt): copy payload or topic and delete messages locally 2026-10-13 13:12:22 +08:00
Loc Nguyen f1f1a49caf perf(mongodb): count find totals concurrently with the find 2026-10-13 13:05:39 +08:00
HARRISON 698121569b fix(ssh): fall back to the default identity file when key auth has none 2026-10-13 13:05:31 +08:00
_Mr丶s ゛😁® ca55161581 fix(mqtt): refine message card spacing and timestamps 2026-10-13 13:05:23 +08:00
t8y2 1fb8f150fc style: apply oxfmt across drifted files 2026-10-13 12:52:46 +08:00
HARRISON 168fc4c63a fix(dameng): degrade DBMS_OUTPUT setup when the account lacks permission 2026-10-13 12:41:10 +08:00
t8y2 aa4cd1b945 fix(deps): adapt to xmldom onError and pin view/dayjs/happy-dom regressions 2026-10-13 12:40:32 +08:00
lxk955 c47e8a0dd4 feat(grid): export formatted data with column formatters 2026-10-13 12:03:46 +08:00
t8y2 01e513cbfd fix(consul): use named api imports to satisfy vue-tsc template checks 2026-10-13 11:51:47 +08:00
t8y2 cac9eb9eeb fix(grid): keep reserved Mongo values intact in formatted exports 2026-10-13 11:51:47 +08:00
lxk955 44896db4ff feat(grid): support exporting formatted data with column formatters (#11812) 2026-10-13 11:51:47 +08:00
t8y2 cd27000fef fix(migration): preserve portable keys across devices
Closes #11957
2026-10-13 11:38:15 +08:00
Abeautifulsnow a2c8c22a42 feat(shortcuts): search keyboard shortcuts by recorded key combination 2026-10-13 10:54:29 +08:00
renovate[bot] 49db1d61cb chore(deps): update node.js to v22.23.3 2026-10-13 10:54:16 +08:00
t8y2 09ed0b16a8 fix(frontend): satisfy vue-tsc 3.3.13 template typechecks 2026-10-13 10:44:24 +08:00
Loc Nguyen 31a0a7f893 fix(mongodb): keep Int32 and Double types when editing numbers 2026-10-13 10:31:36 +08:00
lxk955 29ed4c47af fix(sql): stop treating backslash as quote escape in Oracle-like dialects 2026-10-13 10:31:28 +08:00
_Mr丶s ゛😁® 2d269e5714 feat(mqtt): make the subscription dialog draggable 2026-10-13 10:31:20 +08:00
renovate[bot] 42623f8723 fix(deps): update maven resolver to v2.0.24 2026-10-13 10:31:13 +08:00
renovate[bot] 072181692d fix(deps): update slf4j monorepo 2026-10-13 10:31:05 +08:00
renovate[bot] ff129e1c53 fix(deps): update npm minor/patch 2026-10-13 09:56:24 +08:00
kkwang.xx 55019721a1 feat(structure): refresh the open table structure with F5 2026-10-13 09:45:24 +08:00
lxk955 4eb9368cc7 fix(editor): ensure custom snippets pop up when preceding SQL lacks semicolon 2026-10-13 09:25:33 +08:00
lxk955 26a61b8d69 fix(editor): prevent F7 from triggering browser caret browsing 2026-10-13 08:59:21 +08:00
lxk955 2c6e0c8145 feat(tabs): reopen closed tabs with shortcut and context menu 2026-10-13 08:59:13 +08:00
lxk955 dab91681c9 feat(grid): increment ID zero-padding, prefix and suffix options 2026-10-13 08:59:05 +08:00
lxk955 6452f18c36 feat(grid): JSON value editor node folding with consistent SVG markers 2026-10-13 08:58:57 +08:00
renovate[bot] c7694ff2e7 chore(deps): update gradle to v9.8.1 2026-10-13 08:58:50 +08:00
github-actions[bot] 9f6e9af634 chore(packages): release 0.4.117 [skip node-packages-release] 2026-10-12 16:31:46 +00:00
github-actions[bot] 0be46fc400 chore(jdbc): bump plugin version [skip ci] 2026-10-12 16:14:59 +00:00
t8y2 d9c464d7d5 chore(release): prepare v0.6.42 2026-10-13 00:14:10 +08:00
github-actions[bot] 287a21457e chore: bump module versions [skip ci] 2026-10-12 16:09:24 +00:00
renovate[bot] 42eafa6d6b fix(deps): update kafka-clients to v3.9.2 2026-10-13 00:03:05 +08:00
Abeautifulsnow 95f4511b0d feat(plugins): confirm dependent connections before plugin uninstall 2026-10-12 23:38:31 +08:00
lxk955 cd77b06a59 fix(oracle): copy and snapshot temporal cells in display format 2026-10-12 23:38:22 +08:00
HARRISON f5f58d4ae5 feat(grid): add a custom filter entry to the cell context menu 2026-10-12 23:19:33 +08:00
二丫讲梵 5ae344409a fix(sql-file): show DDL files by default 2026-10-12 23:19:23 +08:00
zipg e3dfe324e8 fix(import): lift the stale xlsx/xlsm size limit via incremental reads 2026-10-12 23:19:13 +08:00
Jeff 0feb065e48 fix(jdbc): keep Oracle PL/SQL units whole on generic JDBC connect 2026-10-12 23:19:05 +08:00
renovate[bot] 838c115a2c fix(deps): update iotdb-jdbc to v2.0.11 2026-10-12 23:18:56 +08:00
renovate[bot] 61939e5e46 fix(deps): update kylin-jdbc to v5.0.4 2026-10-12 23:18:47 +08:00
renovate[bot] bcbc64c635 chore(deps): update taiki-e/install-action digest to a3324fb 2026-10-12 22:43:42 +08:00
renovate[bot] c65a627045 chore(deps): update nacos-server docker tag to v2.5.4 2026-10-12 22:43:34 +08:00
renovate[bot] 440625949b chore(deps): update rnacos docker tag to v0.8.7 2026-10-12 22:43:26 +08:00
renovate[bot] f70a9b2937 fix(deps): update jackcess-encrypt to v5.1.6 2026-10-12 22:39:51 +08:00
_Mr丶s ゛😁® a1824c9079 fix(desktop): keep the macOS app icon dark in dark theme 2026-10-12 22:30:02 +08:00
_Mr丶s ゛😁® d022f068cd fix(sidebar): hide the MQTT console leaf expander 2026-10-12 22:29:53 +08:00
renovate[bot] 85fc95bac8 fix(deps): update oceanbase-client to v2.4.19 2026-10-12 22:29:44 +08:00
renovate[bot] b115002d07 fix(deps): update terajdbc to v20.00.00.60 2026-10-12 22:29:36 +08:00
renovate[bot] 3deb4bda7b fix(deps): update ucanaccess to v5.1.8 2026-10-12 22:29:17 +08:00
zipg e797340466 feat(web): keep saved connection credentials on the server 2026-10-12 21:50:56 +08:00
Andrew fdfdd25d23 feat(gaussdb): enable manual transaction management 2026-10-12 21:50:46 +08:00
Xsyl.way 4740942e8b fix(dameng): degrade trigger metadata to unavailable on permission errors 2026-10-12 21:28:52 +08:00
ordinary being d219f2b8ba fix(transfer): preserve explicit auto-increment as native identity 2026-10-12 21:16:09 +08:00
HARRISON 18980ecdc2 fix(editor): convert editor text from the keyword-case toggle 2026-10-12 21:14:31 +08:00
_Mr丶s ゛😁® fad22512cd feat(mqtt): filter the message list by direction 2026-10-12 21:14:25 +08:00
renovate[bot] 083e05d412 fix(deps): update databend-jdbc to v0.4.8 2026-10-12 21:12:53 +08:00
renovate[bot] 88ea213c35 fix(deps): update exasol-jdbc to v26.2.9 2026-10-12 21:12:48 +08:00
renovate[bot] 85b18a94d6 fix(deps): update informix jdbc to v4.50.14 2026-10-12 21:12:44 +08:00
renovate[bot] 7670e04879 fix(deps): update jackson-databind to v2.22.3 2026-10-12 21:12:39 +08:00
弈秋 fc84e09738 feat(dameng): native dexp/dimp (.dmp) fast export/import 2026-10-12 20:26:19 +08:00
_Mr丶s ゛😁® 177b9a3b39 fix(mqtt): preserve console history across disconnects 2026-10-12 19:50:43 +08:00
Jeff 4a4a7603b5 fix(sql): sort Oracle duplicate columns without derived tables 2026-10-12 19:32:53 +08:00
Jeff 6a1778eaf3 fix(jdbc): cancel running statements when queries stop 2026-10-12 19:32:34 +08:00
二丫讲梵 72a86b56ac feat(sql-file): support folder management 2026-10-12 19:31:52 +08:00
renovate[bot] 9dd7d1f044 fix(deps): update dependency com.highgo:hgdbjdbc to v6.2.5 2026-10-12 19:08:47 +08:00
Jeff cdb4ab5c80 fix(transfer): split reused Oracle table DDL and transferred trigger DDL 2026-10-12 18:28:02 +08:00
t8y2 97e3045a7c fix(ci): allow two concurrent contributor PR runs split by PR parity 2026-10-12 18:23:37 +08:00
Abeautifulsnow f43a99b4fe fix(ai): list @ table mentions from every selected database 2026-10-12 17:50:20 +08:00
HARRISON ccac7ba14f feat(sidebar): show the production badge on object rows 2026-10-12 16:18:48 +08:00
Elias d7df7fe3c0 feat(xugu): enable schema-scoped field lineage 2026-10-12 16:17:01 +08:00
lxk955 2bf6af1ff0 fix(grid): enable table data editing and add-row for Cache / IRIS 2026-10-12 15:53:50 +08:00
Jeff 860d901115 fix(export): include Oracle sequences in database export 2026-10-12 15:22:12 +08:00
lxk955 524a93203b fix(sql): complete common MySQL and Doris date and utility functions 2026-10-12 14:39:51 +08:00
ordinary being a6b26ff1a6 fix(objects): open database schema overview rows under clicked schema 2026-10-12 14:39:38 +08:00
lxk955 6f6cf2f57c fix(sql): route shortcut new query to selected sidebar connection 2026-10-12 14:39:15 +08:00
Abeautifulsnow 6b293e9eaa fix(editor): normalize shortcut search separator spaces 2026-10-12 14:38:52 +08:00
t8y2 a7dac3434d fix(sql-data): rename stale test literal to primary_key_mode 2026-10-12 14:36:57 +08:00
efeire 653bf84829 fix(grid): guard OceanBase ROWID query editing boundaries 2026-10-12 13:02:52 +08:00
efeire ba1d5ec943 fix(oceanbase): preserve full LOB snapshot read write and export semantics 2026-10-12 12:24:34 +08:00
_Mr丶s ゛😁® 994f520df1 feat(export): unify primary key modes for SQL copy and export 2026-10-12 12:22:15 +08:00
efeire 380f6a548b fix(oceanbase): preserve object paging and completion metadata identity 2026-10-12 12:04:08 +08:00
yangzidemuou a88f8dabe7 feat(redis): choose head or tail when pushing into lists 2026-10-12 12:02:32 +08:00
Yifeng Xu 885ccf43ba feat(tabs): 为标签页添加自定义标题后缀功能 (#11708)
* feat(tabs): 为标签页添加自定义标题后缀功能

* feat(tabs): 添加清除标签后缀功能并更新相关国际化文本
2026-10-12 10:06:33 +08:00
t8y2 7cbef302ea chore(release): prepare v0.6.41 2026-10-12 08:51:15 +08:00
github-actions[bot] 76afdb2e89 chore(packages): release 0.4.116 [skip node-packages-release] 2026-10-12 00:42:23 +00:00
github-actions[bot] ea8c2da594 chore: bump module versions [skip ci] 2026-10-12 00:27:27 +00:00
Guoyu Su 1b958c4b3f fix(redis): support Valkey cluster logical databases 2026-10-12 08:23:02 +08:00
github-actions[bot] 0c9330e4e6 chore(docs): refresh contributor activity 2026-10-11 18:12:48 +00:00
lxk955 da281de1d1 fix(query): prevent hidden ROWID in Oracle grouped queries 2026-10-11 23:53:52 +08:00
二丫讲梵 f73114a381 feat(plugins): add marketplace details and workbench launch 2026-10-11 23:31:22 +08:00
ordinary being b3fc86ac3b fix(oceanbase-oracle): cover sorting without an ORDER BY clause 2026-10-11 23:09:08 +08:00
lxk955 018fea60fa fix(jdbc): include SQL Server builtin schemas with legacy fallback 2026-10-11 21:36:49 +08:00
dienaso 29cab5c0b8 fix(transfer): retarget SQL Server DDL schemas and comment owners 2026-10-11 21:32:30 +08:00
Hang 169fc05087 fix(sql): preserve DBLinks and SQL function templates 2026-10-11 21:25:46 +08:00
onenewcode 69a1a4f2d4 fix(sql): align DuckDB multi-statement string splitting 2026-10-11 20:52:06 +08:00
t8y2 06c3bbaa7c feat(ai): add PackyCode partner preset 2026-10-11 20:26:56 +08:00
t8y2 0d12877d89 docs(sponsors): add PackyCode readme entries and website cards 2026-10-11 20:26:54 +08:00
ordinary being 8aca3975d1 fix(oceanbase-oracle): refuse sort rewrite when ORDER BY is hidden 2026-10-11 19:37:34 +08:00
onenewcode bc6dcf8395 fix(export): use table name for single-table exports 2026-10-11 19:37:30 +08:00
lxk955 f5806ba961 fix(sql): resolve GROUP BY/ORDER BY aliases in semicolon-terminated SQL 2026-10-11 19:37:25 +08:00
弈秋 ed3ed28d2b feat(structure): allow naming a newly created primary key 2026-10-11 19:37:20 +08:00
ordinary being 36aa8425e6 fix(safety): scan Oracle alternative-quoted strings in all safety scanners 2026-10-11 18:49:24 +08:00
二丫讲梵 9c069c9cfe fix(tabs): align side tab connection colors 2026-10-11 18:19:56 +08:00
HARRISON b612dec12c fix(editor): explain why Format SQL did nothing 2026-10-11 18:19:51 +08:00
t8y2 c91342ac9d feat(ui): add scroll shortcuts, empty-column hiding and error mapping 2026-10-11 17:59:52 +08:00
lxk955 ad643fcf78 fix(backup): close and hide detached connection popover on scroll 2026-10-11 17:34:57 +08:00
lxk955 f85fe496e6 fix(structure): speed up index editor rendering and column lookups 2026-10-11 17:34:52 +08:00
高桥凉介 03db4cad8c feat(grid): add current datetime generation with milliseconds 2026-10-11 16:46:58 +08:00
lxk955 9f41cdc3f5 fix(postgres): support altering column identity in the structure editor 2026-10-11 15:39:15 +08:00
t8y2 6cd2dc1c9d fix(linux): make transparent floating windows opt-in 2026-10-11 15:34:07 +08:00
ordinary being 74aaa4f643 fix(safety): honour PostgreSQL E'...' escape strings in the risk scanner 2026-10-11 15:30:14 +08:00
一克猫 e7d9bd4ea8 feat(search): add database structure search by column name and comment 2026-10-11 15:24:19 +08:00
弈秋 7b1c29f3cf feat(structure): sort unpicked columns alphabetically in index column picker 2026-10-11 15:11:22 +08:00
Abeautifulsnow ac0cecd1d1 feat(sql-file): export and copy failed statement details 2026-10-11 14:41:46 +08:00
Jeff cdcefb97d5 fix(core): keep the existing pool on concurrent pool creation 2026-10-11 14:22:02 +08:00
Rendegou 3edafe58dd fix(redis): complete collection searches and rank exact hash fields 2026-10-11 14:21:35 +08:00
t8y2 56ec883fb7 fix(core): skip connection identity probe in snapshot test history 2026-10-11 13:53:16 +08:00
t8y2 a7090cea7e fix(postgres): align notice buffer typing with the streaming refactor 2026-10-11 13:53:16 +08:00
lxk955 03a2cd1bd7 fix(grid): preview text stored in Oracle BLOB cells 2026-10-11 13:34:50 +08:00
lxk955 2826bb5fc5 fix(firebird): fix table trigger loading and source viewing 2026-10-11 13:34:23 +08:00
lxk955 63adea29c1 feat(tabs): optimize result run tabs layout and middle click close 2026-10-11 13:33:47 +08:00
弈秋 739e805b83 feat(sidebar): open the first search match with Enter 2026-10-11 13:33:18 +08:00
弈秋 c7fa862792 feat(grid): hint when copying a partially loaded result 2026-10-11 13:32:42 +08:00
弈秋 58e2d2978f feat(editor): support excluding completion items 2026-10-11 13:32:13 +08:00
zipgandzipg 4f56ad408d fix(postgres): preserve unconvertible legacy text on reads (#11647)
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-10-11 12:48:06 +08:00
zipg 8e52e61c39 fix(grid): 复制页签时保留独立筛选与排序规则 (#11628) 2026-10-11 12:48:02 +08:00
zipg c80ef798e5 fix(mcp): 显示并支持编辑隐藏的权限例外 (#11618) 2026-10-11 12:47:58 +08:00
zipgandzipg 528ad2f62b fix(connection): preserve Oracle JDBC credential characters (#11612)
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-10-11 12:47:55 +08:00
zipgandzipg 33adceae7d feat(ui): 分组右键菜单及自定义一级操作和排序 (#11564)
* feat(ui): add customizable grouped context menus

* docs: add redacted grouped menu screenshots

* fix(ui): decouple menu preferences and support i18n autofill

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-10-11 12:47:51 +08:00
zipgandzipg 06de88a367 fix(grid): preserve view state across result tab switches (#11521)
Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-10-11 12:47:46 +08:00
一克猫 4f666d9f8f fix(toolbar): hide update button when check updates is toggled off 2026-10-11 11:56:10 +08:00
lxk955 7dea1ede4f feat(grid): support fetching filter distinct values from current page 2026-10-11 11:49:15 +08:00
Yifeng Xu ead4190584 fix(grid): brighten the current-row background for visibility 2026-10-11 11:48:26 +08:00
t8y2 21d6a5f719 docs(sponsors): add Taiyi Enterprise Edition sponsor 2026-10-11 11:47:39 +08:00
Abeautifulsnow aab0526f52 fix(shortcuts): scope conflict badges to search results 2026-10-11 11:46:41 +08:00
t8y2 41b771a185 docs(sponsors): add Taiyi Enterprise Edition sponsor 2026-10-11 11:44:19 +08:00
github-actions[bot] 66e020f615 chore(packages): release 0.4.115 [skip node-packages-release] 2026-10-11 02:15:09 +00:00
github-actions[bot] 38ca03ef57 chore: bump module versions [skip ci] 2026-10-11 02:03:27 +00:00
github-actions[bot] b2eb0c5f17 chore(jdbc): bump plugin version [skip ci] 2026-10-11 01:53:40 +00:00
t8y2 f39cd023f6 chore(release): prepare v0.6.40 2026-10-11 09:52:52 +08:00
t8y2 12b16cb026 feat(query): stream PostgreSQL notices and capture Kingbase messages
Refs: #2725
2026-10-11 09:36:27 +08:00
ordinary being 1f4e31deef fix(pagination): avoid a second LIMIT when the lexical scan misses it 2026-10-11 09:29:51 +08:00
弈秋 c3f1af5157 fix(oracle): keep JDBC credentials verbatim and hide unsafe ones 2026-10-11 09:29:18 +08:00
lxk955 dc3f6d0fa9 feat(sql): expand nested CTE columns from prior CTE references 2026-10-11 09:28:48 +08:00
Jeff c8816364f7 fix(export): read query result exports through the open manual transaction 2026-10-11 09:16:06 +08:00
t8y2 f468b99f76 fix(sponsors): update HiAPI top-up bonus to 10% on all top-ups 2026-10-11 09:15:29 +08:00
lxk955 a93f118d03 fix(kingbase): display json column type instead of jsonb 2026-10-11 09:15:18 +08:00
lxk955 adedde8e46 fix(export): export decimal columns as numeric in full XLSX export 2026-10-11 09:14:50 +08:00
lxk955 d84539fc42 feat(sidebar): collapse child nodes recursively when folding a parent 2026-10-11 09:14:17 +08:00
弈秋 0453ac83e5 fix(sql): keep MyBatis XML entity semicolons from splitting statements 2026-10-11 09:13:50 +08:00
Jeff a486628e18 fix(postgres): keep hidden key column hidden after sorting 2026-10-11 09:13:18 +08:00
lxk955 6894bb026e fix(editor): apply configured font to the autocomplete popup 2026-10-11 09:12:52 +08:00
t8y2 34fbe3510b fix(ci): restore job condition lines broken in ci.yml 2026-10-11 08:58:17 +08:00
t8y2 972519ba2d chore(ci): run full validation jobs on draft pull requests 2026-10-11 08:54:55 +08:00
dienaso 42faf6ad34 fix(mongo): accept single document shell inserts on legacy agent 2026-10-11 08:49:08 +08:00
弈秋 67847fed2a feat(shortcuts): add configurable Close All Tabs action 2026-10-11 08:48:41 +08:00
弈秋 5cfdf10e5c feat(grid): add values-only JSON array copy 2026-10-11 08:48:13 +08:00
lxk955 124ae27c0b fix(import): restore dimension column equality in xlsx streaming 2026-10-11 08:47:37 +08:00
弈秋 0f87ce0b6a feat(export): add two-row name/comment header mode for XLSX 2026-10-11 08:47:09 +08:00
lxk955 25feadbe37 fix(sql): map uniqueidentifier to uuid for sqlserver-to-postgresql 2026-10-11 08:46:36 +08:00
lxk955 c744598425 feat(import): allow empty string as null for Excel import 2026-10-11 08:46:09 +08:00
lxk955 39505896a6 fix(mysql): resolve ProxySQL hostgroup lock via literal floor setup 2026-10-11 08:45:37 +08:00
lxk955 035fecd17b feat(redis): support serialized JSON string presentation 2026-10-11 08:45:10 +08:00
xiaohuowa b799cf38df feat(sql): add multi-row INSERT template generation for tables 2026-10-11 08:23:19 +08:00
KeaneQy a21880ea13 fix(mcp): serve HTTP transport statelessly for all protocol versions 2026-10-11 08:11:32 +08:00
dienaso d918125a04 fix(mongodb): accept the copied EJSON.deserialize insert form 2026-10-11 08:10:56 +08:00
zipg baf5cf101f fix(ui): close the top dialog instead of the tab on Cmd+W 2026-10-11 08:10:22 +08:00
lxk955 5f6e983364 fix(diff): use pencil icon for renaming compare configs 2026-10-11 08:09:48 +08:00
lxk955 bb408d7f0e fix(sidebar): allow empty database context for hana pagination and filters 2026-10-11 08:09:05 +08:00
DengQingNian 13601ebe1d feat(schema-diff): add MySQL ignore foreign key checks deploy option 2026-10-10 21:10:58 +08:00
二丫讲梵 5eaccd3062 feat(shortcuts): support modifier double taps for search 2026-10-10 20:49:35 +08:00
二丫讲梵 56ea5714af feat(sidebar): pin table search below the database header 2026-10-10 20:44:40 +08:00
lxk955 8786c76ff4 fix(import): recover preview reload after invalid row range 2026-10-10 20:40:15 +08:00
Abeautifulsnow dfe7510ca0 fix(objects): keep the object count for single-kind scopes 2026-10-10 20:35:32 +08:00
zipg 507b6d355f fix(clipboard): keep @-prefixed non-ASCII text verbatim 2026-10-10 20:34:47 +08:00
Jeff 19c2d6d054 fix(sqlserver): filter comment reads by extended property class 2026-10-10 20:10:28 +08:00
github-actions[bot] 55942342d5 chore(packages): release 0.4.114 [skip node-packages-release] 2026-10-10 10:48:28 +00:00
t8y2 7fee71e702 fix(ci): un-draft agent release when resuming publish 2026-10-10 17:58:08 +08:00
t8y2 11974accdb fix(ci): serialize contributor PR runs with repository owner exemption 2026-10-10 17:34:38 +08:00
github-actions[bot] 6dd4d40234 chore: bump module versions [skip ci] 2026-10-10 09:22:13 +00:00
t8y2 9a49b5306c fix(ci): scope PR checks and reduce runner queue contention 2026-10-10 17:18:34 +08:00
github-actions[bot] 6254399add chore(packages): release 0.4.113 [skip node-packages-release] 2026-10-10 09:11:07 +00:00
t8y2 f6f88de508 fix(ci): reduce redundant Actions runs and disable PR notifications 2026-10-10 16:41:47 +08:00
t8y2 9f4205be32 chore(release): prepare v0.6.39 2026-10-10 15:32:57 +08:00
zipgandzipg 9262df465a fix(structure): restore horizontal scrollbar after tab switch (#11582)
* fix(structure): restore horizontal scrollbar after tab switch

* test(structure): cover DDL and foreign key scrollbar return

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
2026-10-10 15:23:49 +08:00
efeire 66187eef70 fix(oceanbase): keep wildcard pagination on the same result cursor 2026-10-10 15:17:03 +08:00
_Mr丶s ゛😁® 8d61a7e944 feat(sync): extend auto-upload interval range and improve hints 2026-10-10 15:15:48 +08:00
efeire 7653e860dd fix(oracle): restrict DBLink detection to object references 2026-10-10 15:15:09 +08:00
efeire 6748009181 fix(oceanbase-oracle): keep char units and negative NUMBER scale 2026-10-10 14:37:30 +08:00
Jeff e7787b8bb8 fix(sqlserver): read and write comments via the legacy driver 2026-10-10 14:36:54 +08:00
efeire 2532e0ff59 feat(oceanbase-oracle): allow adding primary keys to keyless tables 2026-10-10 14:36:21 +08:00
efeire f899004140 ci: configure Docker Hub mirror cache before live test jobs 2026-10-10 14:35:04 +08:00
t8y2 8f418c59a5 chore(ci): skip heavy validation jobs on draft pull requests 2026-10-10 13:57:46 +08:00
t8y2 2540c5211c chore(ci): temporarily disable pull-request-labels trigger 2026-10-10 13:44:04 +08:00
t8y2 e725226f4e fix(ci): skip non-file entries when uploading agent release assets 2026-10-10 13:24:19 +08:00
t8y2 7c15e38b43 fix(ci): upload only missing agent release assets in batches 2026-10-10 12:11:46 +08:00
_Mr丶s ゛😁® 3825120b57 fix(backup): show app name on macOS backup notifications 2026-10-10 12:05:01 +08:00
lxk955 fa7c9ef49e fix(import): honor custom title rows in Excel streaming import 2026-10-10 11:23:43 +08:00
Abeautifulsnow 20fc49d7db fix(connection): block passive reconnect for user-closed connections 2026-10-10 11:14:10 +08:00
jearhe 36f69f4da9 fix(sql): inherit active context for new SQL editors 2026-10-10 11:09:03 +08:00
t8y2 6eb8242cf7 chore(ci): temporarily disable ui-preview auto trigger 2026-10-10 11:06:12 +08:00
t8y2 29d7d0f62a feat(ai): add HiAPI partner preset and AstraFlow bonus badge
Also point the AstraFlow visit link at the 50 CNY credit landing page.
2026-10-10 10:54:58 +08:00
t8y2 2684e0f316 docs(sponsors): add HiAPI readme entries and website cards 2026-10-10 10:54:57 +08:00
lxk955 68d05ea27c feat(editor): include column comments in batch column insert 2026-10-10 09:51:50 +08:00
lxk955 4f0ce82f4a feat(sql): complete unpaired keywords before existing ones 2026-10-10 09:48:41 +08:00
lxk955 e1e0614199 fix(mongo): preserve custom _id on document insert and copy 2026-10-10 09:46:56 +08:00
lxk955 eda4d3e2d5 fix(oracle): support sequence and pseudo-column completion 2026-10-10 09:46:46 +08:00
lxk955 e6ae58e989 fix(grid): prevent premature termination of multi-chunk load-all 2026-10-10 09:46:34 +08:00
Cristian 786ccad3c4 fix(linux): draw rounded corners, shadow and resize handles 2026-10-10 09:19:59 +08:00
zipg 2982646dee feat(firebird): add catalog object manager 2026-10-10 08:17:21 +08:00
Abeautifulsnow 6c57b0ca9b feat(formatter): add selectable classic layout style 2026-10-10 07:23:35 +08:00
t8y2 b281fbd5fa style(transfer): rustfmt the demo-mode validation gate 2026-10-10 05:00:27 +08:00
t8y2 d926a5cc00 fix(transfer): skip connection validation for demo-mode web transfers 2026-10-10 03:53:51 +08:00
github-actions[bot] 53914d2af2 chore(docs): refresh contributor activity 2026-10-09 18:33:18 +00:00
lxk955 84758d60b3 feat(connection): allow configuring connection idle timeout 2026-10-10 00:14:22 +08:00
Jeff 1f7623e7f5 fix(oracle): resolve current schema on the manual transaction session 2026-10-10 00:14:06 +08:00
lxk955 54b302683a fix(metadata): resolve visible schema for query DDL and loads 2026-10-10 00:13:37 +08:00
lxk955 26a579ba7c fix(kyuubi): use engine identifier quote for connect database 2026-10-10 00:13:14 +08:00
lxk955 28b12df340 feat(sql): respect formatter identifier casing in autocomplete 2026-10-10 00:12:54 +08:00
Jeff 37ea6d4fc8 fix(transfer): clear FK-linked target children first on overwrite 2026-10-10 00:12:38 +08:00
二丫讲梵 0d2293525e fix(sql): quote batch-selected MySQL columns 2026-10-10 00:12:25 +08:00
zipg 5c6030f413 fix(updates): keep MCP update button hidden after auto-update 2026-10-10 00:12:09 +08:00
xiaohuowa 1b703526ea fix(sql): truncate insert template placeholders to column length 2026-10-09 23:10:04 +08:00
Jeff 4af7777f49 fix(oracle): persist the Show Database Links opt-out 2026-10-09 22:41:35 +08:00
Elias 0c95c816b5 feat(xugu): support native Xugu-to-Xugu table transfer 2026-10-09 22:40:01 +08:00
lxk955 14eb349212 fix(import): carry over DataGrip username and warn on fallback 2026-10-09 22:26:05 +08:00
Terry Yao a812c94f67 fix(cloud-sync): improve sync selection dialog UX and counts 2026-10-09 22:23:57 +08:00
Yifeng Xu 7406cb4aac feat(backup): back up multiple selected connections at once 2026-10-09 22:23:41 +08:00
二丫讲梵 9a128d38b4 fix(kingbase): fall back to page estimates for table sizes 2026-10-09 22:23:23 +08:00
Jetsadaporn Klamtanya 3377bf96a6 fix(postgres): keep server text for json and jsonb cells 2026-10-09 21:16:59 +08:00
二丫讲梵 884eb047f9 fix(grid): restore first WHERE suggestion default selection 2026-10-09 21:12:51 +08:00
efeire 235842e6a9 fix(transfer): support OceanBase Oracle upsert and table comments 2026-10-09 21:12:19 +08:00
onenewcode caa43b6f2a fix(sql): recognize Oracle pseudo-columns in semantic diagnostics 2026-10-09 20:50:38 +08:00
efeire e9b6e5fcb8 fix(query): stop stale commits clearing newer transaction sessions 2026-10-09 20:48:14 +08:00
二丫讲梵 73692376ef feat(grid): transpose selected rows preserving selection scope 2026-10-09 20:47:20 +08:00
ccq 7407f12d07 feat(mcp): prompt in desktop before high-risk SQL 2026-10-09 20:43:11 +08:00
ninggc 3ad44a1811 feat(sql-file): add locate button for the active opened file 2026-10-09 20:42:43 +08:00
efeire ec42060ddf fix(oceanbase): preserve required quoting in Oracle-mode templates 2026-10-09 20:41:27 +08:00
efeire 268dad2018 fix(oceanbase): correct Oracle-mode ROWID query and row mapping 2026-10-09 20:39:59 +08:00
efeire d170a7e205 feat(data-generate): support OceanBase Oracle INSERT ALL batches 2026-10-09 20:38:50 +08:00
github-actions[bot] eb6532e573 chore(packages): release 0.4.112 [skip node-packages-release] 2026-10-09 11:46:48 +00:00
github-actions[bot] 9380e98865 chore: bump module versions [skip ci] 2026-10-09 11:25:45 +00:00
1716 changed files with 172514 additions and 17950 deletions
+190
View File
@@ -0,0 +1,190 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { readFileSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { detectIssueCommand, handleIssueCommand } from "./issue-commands.mjs";
import { writeContributorSnapshot } from "../../docs/scripts/contributor-snapshot.mjs";
function workflow(name) {
return readFileSync(new URL(`../workflows/${name}.yml`, import.meta.url), "utf8");
}
function job(content, name) {
const jobs = content.slice(content.indexOf("\njobs:\n") + 7);
const definitions = [...jobs.matchAll(/^ ([\w-]+):\s*$/gm)];
const index = definitions.findIndex((match) => match[1] === name);
assert.ok(index >= 0, `missing job ${name}`);
return jobs.slice(definitions[index].index, definitions[index + 1]?.index ?? jobs.length);
}
function evaluateCondition(content, github) {
const folded = content.match(/^ if: >-\n((?: [^\n]*\n)+)/m);
const expression = (folded?.[1] ?? content.match(/^ if: (.+)$/m)?.[1])?.trim();
assert.ok(expression, "missing job condition");
const evaluate = new Function("github", "contains", `return (${expression.replace(/^\$\{\{\s*|\s*\}\}$/g, "")});`);
return evaluate(github, (search, item) =>
String(search ?? "")
.toLowerCase()
.includes(String(item).toLowerCase()),
);
}
function commentEvent(body, { userType = "User", login = "contributor", pullRequest = false } = {}) {
return {
repository_owner: "t8y2",
event: {
issue: { pull_request: pullRequest ? { url: "https://api.github.com/repos/t8y2/dbx/pulls/123" } : undefined },
comment: { body, user: { type: userType, login } },
},
};
}
test("issue command prefilter admits every supported command and skips unrelated comments", async () => {
const content = job(workflow("issue-claim"), "claim");
for (const body of ["/claim", "please /claim this", "\t/claim\n", "/unclaim", " /unclaimed\n", "\t/close\r\n"]) {
assert.ok(detectIssueCommand(body), body);
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
assert.equal(evaluateCondition(content, commentEvent(body, { userType: "Bot" })), false, body);
assert.equal(evaluateCondition(content, commentEvent(body, { pullRequest: true })), false, body);
}
for (const body of ["Thanks!", "Fixed in abc123", "", null]) {
assert.equal(evaluateCondition(content, commentEvent(body)), false);
}
for (const body of ["/claimable", "please /close", "/unclaim more", "/CLAIM"]) {
assert.equal(evaluateCondition(content, commentEvent(body)), true);
assert.deepEqual(await handleIssueCommand({ commentBody: body, commentUserType: "User", client: {} }), { status: "ignored" });
}
});
test("spam prefilter retains all existing malicious-link cases and never bypasses the matcher", () => {
const content = job(workflow("spam-comment-guard"), "delete-malicious-link");
const pattern = content.match(/grep -qiP '([^']+)'/)[1];
const matcher = new RegExp(pattern.replaceAll("[:space:]", "\\s"), "i");
for (const body of [
"https://evil.example/update.zip",
"https://evil.example/update.ZIP?download=1",
"[update.zip](//evil.example/download)",
"[update.ZIP](https://evil%2Eexample/download)",
"[update.7z](https://evil.example/download)",
"[update.exe](https://evil.example/download)",
"[update.SCR](//evil.example/download)",
]) {
assert.equal(matcher.test(body), true, body);
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
assert.equal(evaluateCondition(content, commentEvent(body, { userType: "Bot" })), false);
assert.equal(evaluateCondition(content, commentEvent(body, { login: "t8y2" })), false);
assert.equal(evaluateCondition(content, commentEvent(body, { pullRequest: true })), true);
}
for (const body of ["Thanks!", "https://example.com/docs", "", null]) {
assert.equal(evaluateCondition(content, commentEvent(body)), false);
}
for (const body of ["Please upload a .zip", "https://github.com/t8y2/dbx/archive/main.zip", "https://user-images.githubusercontent.com/files/logs.zip", "[logs.exe](https://github.com/user-attachments/files/123/logs.exe)", "[logs.7z](//raw.githubusercontent.com/t8y2/dbx/main/logs.7z)"]) {
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
assert.equal(matcher.test(body), false, body);
}
});
test("PR notification automation stays disabled while its configuration is retained", () => {
const content = workflow("notify");
assert.match(content, /# pull_request_target:\s+#\s+types: \[closed\]/);
assert.match(content, /^ workflow_dispatch:/m);
assert.doesNotMatch(content, /^ (?:pull_request_target|pull_request|issues|release|push|schedule):/m);
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: { merged: true } } }), true);
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: { merged: false } } }), false);
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: {} } }), false);
});
test("plugin checks run once before unchanged cross-platform verification", () => {
const content = workflow("plugin-dev-host");
const checks = job(content, "checks");
const tests = job(content, "test");
assert.match(checks, /runs-on: ubuntu-24\.04/);
assert.match(checks, /npm run lint --prefix plugins\/sdk\/dev-host/);
assert.match(checks, /npm run fmt:check --prefix plugins\/sdk\/dev-host/);
assert.doesNotMatch(checks, /cargo|setup-go|rust-toolchain/);
assert.match(tests, /needs: checks/);
assert.match(tests, /os: \[ubuntu-24\.04, macos-15, windows-2022\]/);
assert.doesNotMatch(tests, /npm run (?:lint|fmt:check)/);
for (const command of ["npm test --prefix plugins/sdk/dev-host", "cargo nextest run", "cargo test --doc", "npm test --prefix packages/plugin-cli", "node scripts/verify-plugin-cli-package.mjs", "DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'"]) {
assert.ok(tests.includes(command), command);
}
assert.match(content, /group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.ref \}\}\s+cancel-in-progress: true/);
});
test("contributor refresh is weekly and remains manually available", () => {
const content = workflow("contributors");
assert.match(content, /cron: "0 18 \* \* 0"/);
assert.match(content, /workflow_dispatch:/);
});
test("GitHub script selection excludes planner tests only when fast checks own them", () => {
const content = job(workflow("ci"), "github-scripts");
const script = content.match(/run: \|\n((?: [^\n]*\n)+)/)[1].replace(/^ /gm, "");
const repository = new URL("../../", import.meta.url);
const files = readdirSync(new URL(".github/scripts/", repository)).filter((file) => file.endsWith(".test.mjs"));
for (const fast of ["true", "false", ""]) {
const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", `node() { printf '%s\\n' "$@"; }\n${script}`], {
cwd: repository,
encoding: "utf8",
env: { ...process.env, FAST_CHECKS: fast },
});
assert.equal(result.status, 0, result.stderr);
const selected = result.stdout.trim().split("\n").slice(1).sort();
const expected = files
.filter((file) => fast !== "true" || !file.startsWith("ci-"))
.map((file) => `.github/scripts/${file}`)
.sort();
assert.deepEqual(selected, expected);
assert.ok(selected.includes(".github/scripts/actions-efficiency.test.mjs"));
}
});
async function snapshotFixture(context) {
const directory = await mkdtemp(join(tmpdir(), "dbx-contributor-snapshot-"));
context.after(() => rm(directory, { recursive: true, force: true }));
return join(directory, "data", "contributors.json");
}
function snapshot() {
return { repository: "t8y2/dbx", generatedAt: "2026-10-01T18:00:00.000Z", stars: 10, contributors: [{ login: "contributor", commits: 3, mergedPullRequests: 1 }] };
}
test("a missing contributor snapshot is created and timestamp-only refreshes preserve exact bytes", async (context) => {
const outputPath = await snapshotFixture(context);
const original = snapshot();
assert.equal(await writeContributorSnapshot(outputPath, original), true);
const originalBytes = await readFile(outputPath, "utf8");
assert.equal(originalBytes, `${JSON.stringify(original, null, 2)}\n`);
const refreshed = { ...original, generatedAt: "2026-10-08T18:00:00.000Z" };
assert.equal(await writeContributorSnapshot(outputPath, refreshed), false);
assert.equal(await readFile(outputPath, "utf8"), originalBytes);
assert.equal(refreshed.generatedAt, "2026-10-08T18:00:00.000Z");
});
test("actual contributor, star and repository changes update the snapshot", async (context) => {
const outputPath = await snapshotFixture(context);
const original = snapshot();
for (const updated of [
{ ...original, stars: 11 },
{ ...original, repository: "other/dbx" },
{ ...original, contributors: [{ ...original.contributors[0], commits: 4 }] },
{ ...original, contributors: [] },
]) {
await writeContributorSnapshot(outputPath, original);
const refreshed = { ...updated, generatedAt: "2026-10-08T18:00:00.000Z" };
assert.equal(await writeContributorSnapshot(outputPath, refreshed), true);
assert.deepEqual(JSON.parse(await readFile(outputPath, "utf8")), refreshed);
}
});
test("invalid contributor snapshots and filesystem errors are not silently overwritten", async (context) => {
const outputPath = await snapshotFixture(context);
await writeContributorSnapshot(outputPath, snapshot());
await writeFile(outputPath, "invalid JSON");
await assert.rejects(writeContributorSnapshot(outputPath, snapshot()), SyntaxError);
assert.equal(await readFile(outputPath, "utf8"), "invalid JSON");
await assert.rejects(writeContributorSnapshot(join(outputPath, "invalid"), snapshot()), { code: "ENOTDIR" });
});
+35 -25
View File
@@ -52,26 +52,30 @@ If input is truncated, assess only visible evidence and express uncertainty abou
class PriorityError extends Error {}
function labelNames(issue) {
return (issue.labels || []).map((label) => typeof label === "string" ? label : label.name).filter(Boolean);
return (issue.labels || []).map((label) => (typeof label === "string" ? label : label.name)).filter(Boolean);
}
function withoutUserPriority(body) {
let inPrioritySection = false;
return String(body || "").split(/\r?\n/).filter((line) => {
const heading = line.match(/^#{1,6}\s+(.+)/);
if (heading) inPrioritySection = /优先级|priority|urgency/i.test(heading[1]);
return !inPrioritySection && !/^\s*\*\*[^*]*(?:优先级|priority|urgency)[^*]*\*\*\s*[::]/i.test(line);
}).join("\n").trim();
return String(body || "")
.split(/\r?\n/)
.filter((line) => {
const heading = line.match(/^#{1,6}\s+(.+)/);
if (heading) inPrioritySection = /优先级|priority|urgency/i.test(heading[1]);
return !inPrioritySection && !/^\s*\*\*[^*]*(?:优先级|priority|urgency)[^*]*\*\*\s*[::]/i.test(line);
})
.join("\n")
.trim();
}
export function prepareIssue(issue) {
const body = withoutUserPriority(issue.body);
return {
title: String(issue.title || "").slice(0, 500),
body: body.length > MAX_BODY_LENGTH
? `${body.slice(0, 10000)}\n[TRUNCATED]\n${body.slice(-4000)}`
: body,
labels: labelNames(issue).filter((label) => /^(db\/|bug$|enhancement$|feature$|question$)/.test(label)).sort(),
body: body.length > MAX_BODY_LENGTH ? `${body.slice(0, 10000)}\n[TRUNCATED]\n${body.slice(-4000)}` : body,
labels: labelNames(issue)
.filter((label) => /^(db\/|bug$|enhancement$|feature$|question$)/.test(label))
.sort(),
truncated: body.length > MAX_BODY_LENGTH,
};
}
@@ -83,21 +87,26 @@ export function parseAssessment(content, issue) {
} catch {
throw new PriorityError("AtlasCloud returned invalid priority JSON");
}
const validTextList = (value, maxLength) => Array.isArray(value) && value.length <= 3
&& value.every((entry) => typeof entry === "string" && entry.trim() && entry.length <= maxLength);
if (!result || typeof result.priority !== "string" || !Object.hasOwn(PRIORITY_LABELS, result.priority)
|| !["high", "medium", "low"].includes(result.confidence)
|| !["security", "data-loss", "core-blocked", "functional", "feature", "cosmetic", "unknown"].includes(result.risk)
|| typeof result.reason !== "string" || !result.reason.trim() || result.reason.length > 600
|| !validTextList(result.evidence, 300) || !validTextList(result.missing_information, 250)) {
const validTextList = (value, maxLength) => Array.isArray(value) && value.length <= 3 && value.every((entry) => typeof entry === "string" && entry.trim() && entry.length <= maxLength);
if (
!result ||
typeof result.priority !== "string" ||
!Object.hasOwn(PRIORITY_LABELS, result.priority) ||
!["high", "medium", "low"].includes(result.confidence) ||
!["security", "data-loss", "core-blocked", "functional", "feature", "cosmetic", "unknown"].includes(result.risk) ||
typeof result.reason !== "string" ||
!result.reason.trim() ||
result.reason.length > 600 ||
!validTextList(result.evidence, 300) ||
!validTextList(result.missing_information, 250)
) {
throw new PriorityError("AtlasCloud returned an invalid priority assessment");
}
if (!result.evidence.every((quote) => issue.title.includes(quote) || issue.body.includes(quote))) {
throw new PriorityError("AtlasCloud priority evidence does not match the issue text");
}
const insufficientEvidence = result.confidence === "low" || result.evidence.length === 0 || result.risk === "unknown";
const unsupportedCriticalPriority = result.priority === "P0" && (result.confidence !== "high"
|| !["security", "data-loss", "core-blocked"].includes(result.risk));
const unsupportedCriticalPriority = result.priority === "P0" && (result.confidence !== "high" || !["security", "data-loss", "core-blocked"].includes(result.risk));
return {
priority: insufficientEvidence || unsupportedCriticalPriority ? "needs-info" : result.priority,
confidence: result.confidence,
@@ -219,8 +228,7 @@ export async function run({ event, apiKey, github, model = DEFAULT_MODEL, fetchI
await github("POST", `${issuePath}/labels`, { labels: [targetLabel] });
}
for (const label of existingLabels) {
if (label !== targetLabel && label.startsWith(LABEL_PREFIX)
&& Object.hasOwn(PRIORITY_LABELS, label.slice(LABEL_PREFIX.length))) {
if (label !== targetLabel && label.startsWith(LABEL_PREFIX) && Object.hasOwn(PRIORITY_LABELS, label.slice(LABEL_PREFIX.length))) {
try {
await github("DELETE", `${issuePath}/labels/${encodeURIComponent(label)}`);
} catch (error) {
@@ -234,10 +242,12 @@ export async function run({ event, apiKey, github, model = DEFAULT_MODEL, fetchI
export function formatSummary(result) {
const escape = (value) => String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
if (result.skipped) return `### AI issue priority\n\nSkipped: ${escape(result.skipped)}\n`;
return `### AI issue priority${result.dryRun ? " (dry run)" : ""}\n\n`
+ "AI suggestion based on issue text only, not a verified diagnosis or release commitment. "
+ "User-reported and maintainer priority labels are unchanged.\n\n"
+ `<pre>${escape(JSON.stringify({ model: result.model, ...result.assessment }, null, 2))}</pre>\n`;
return (
`### AI issue priority${result.dryRun ? " (dry run)" : ""}\n\n` +
"AI suggestion based on issue text only, not a verified diagnosis or release commitment. " +
"User-reported and maintainer priority labels are unchanged.\n\n" +
`<pre>${escape(JSON.stringify({ model: result.model, ...result.assessment }, null, 2))}</pre>\n`
);
}
async function main() {
+53 -44
View File
@@ -1,22 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
assessIssue,
createGitHubClient,
DEFAULT_MODEL,
formatSummary,
parseAssessment,
prepareIssue,
run,
} from "./ai-issue-priority.mjs";
import { assessIssue, createGitHubClient, DEFAULT_MODEL, formatSummary, parseAssessment, prepareIssue, run } from "./ai-issue-priority.mjs";
const baseIssue = {
number: 42,
state: "open",
title: "[Bug] Grid column alignment is inconsistent",
body: "### Description\n\nOnly the column header is misaligned; queries and editing work.\n\n"
+ "### Priority (urgency)\n\nP0 Must fix next release\n\n### Additional information\n\nDBX on Linux.",
body: "### Description\n\nOnly the column header is misaligned; queries and editing work.\n\n" + "### Priority (urgency)\n\nP0 Must fix next release\n\n### Additional information\n\nDBX on Linux.",
labels: [{ name: "bug" }, { name: "db/mysql" }, { name: "user-priority/P0" }],
};
@@ -51,7 +42,9 @@ function fixture({ issue = baseIssue, latest = issue, result = assessment(), act
if (method === "GET") return ++reads === 1 ? issue : latest;
return {};
},
fetchImpl: modelFetch(result, () => { modelCalls += 1; }),
fetchImpl: modelFetch(result, () => {
modelCalls += 1;
}),
};
return { options, calls, modelCalls: () => modelCalls };
}
@@ -114,14 +107,7 @@ test("validates every priority and normalizes low-confidence or ungrounded decis
for (const priority of ["P1", "P2", "P3", "needs-info"]) {
assert.equal(parseAssessment(JSON.stringify(assessment({ priority })), prepared).priority, priority);
}
for (const override of [
{ confidence: "low" },
{ evidence: [] },
{ risk: "unknown" },
{ priority: "P0", risk: "cosmetic" },
{ priority: "P0", risk: "feature" },
{ priority: "P0", risk: "data-loss", confidence: "medium" },
]) {
for (const override of [{ confidence: "low" }, { evidence: [] }, { risk: "unknown" }, { priority: "P0", risk: "cosmetic" }, { priority: "P0", risk: "feature" }, { priority: "P0", risk: "data-loss", confidence: "medium" }]) {
assert.equal(parseAssessment(JSON.stringify(assessment(override)), prepared).priority, "needs-info");
}
const criticalIssue = prepareIssue({ title: "Data loss", body: "Saving one row overwrites every row in the table." });
@@ -132,13 +118,25 @@ test("validates every priority and normalizes low-confidence or ungrounded decis
test("rejects malformed, oversized, unknown and fabricated model results", () => {
const prepared = prepareIssue(baseIssue);
for (const invalid of [
"not JSON", "null", "[]", "```json\n{}\n```",
"not JSON",
"null",
"[]",
"```json\n{}\n```",
...[
{ priority: "P9" }, { priority: "__proto__" }, { priority: "constructor" }, { priority: ["P0"] },
{ confidence: "certain" }, { risk: "urgent" }, { reason: " " }, { reason: "x".repeat(601) },
{ evidence: ["Invented widespread data loss"] }, { evidence: ["P0 Must fix next release"] },
{ evidence: [""] }, { evidence: "not an array" },
{ missing_information: ["x".repeat(251)] }, { missing_information: ["a", "b", "c", "d"] },
{ priority: "P9" },
{ priority: "__proto__" },
{ priority: "constructor" },
{ priority: ["P0"] },
{ confidence: "certain" },
{ risk: "urgent" },
{ reason: " " },
{ reason: "x".repeat(601) },
{ evidence: ["Invented widespread data loss"] },
{ evidence: ["P0 Must fix next release"] },
{ evidence: [""] },
{ evidence: "not an array" },
{ missing_information: ["x".repeat(251)] },
{ missing_information: ["a", "b", "c", "d"] },
].map((override) => JSON.stringify(assessment(override))),
]) {
assert.throws(() => parseAssessment(invalid, prepared));
@@ -151,14 +149,22 @@ test("labels independently of reporter and maintainer priority, removing only ob
const result = await run(state.options);
assert.equal(result.label, "ai-priority/P3");
assert.equal(state.modelCalls(), 1);
assert.deepEqual(state.calls.filter(({ method }) => method !== "GET"), [
{ method: "POST", path: "/labels", body: {
name: "ai-priority/P3", color: "0e8a16",
description: "AI: low repair/implementation priority; minor or optional improvement",
} },
{ method: "POST", path: "/issues/42/labels", body: { labels: ["ai-priority/P3"] } },
{ method: "DELETE", path: "/issues/42/labels/ai-priority%2FP1", body: undefined },
]);
assert.deepEqual(
state.calls.filter(({ method }) => method !== "GET"),
[
{
method: "POST",
path: "/labels",
body: {
name: "ai-priority/P3",
color: "0e8a16",
description: "AI: low repair/implementation priority; minor or optional improvement",
},
},
{ method: "POST", path: "/issues/42/labels", body: { labels: ["ai-priority/P3"] } },
{ method: "DELETE", path: "/issues/42/labels/ai-priority%2FP1", body: undefined },
],
);
});
test("does not rewrite an unchanged AI label", async () => {
@@ -187,8 +193,7 @@ test("skips missing key, unrelated events, PRs, closed and opted-out issues befo
test("edits reconcile priority even when a preceding workflow may have been canceled", async () => {
for (const config of [
{ action: "edited", changes: { body: { from: baseIssue.body.replace("P0", "P3") } } },
{ action: "edited", issue: { ...baseIssue, labels: [...baseIssue.labels, "ai-priority/P1"] },
changes: { body: { from: baseIssue.body.replace("P0", "P3") } } },
{ action: "edited", issue: { ...baseIssue, labels: [...baseIssue.labels, "ai-priority/P1"] }, changes: { body: { from: baseIssue.body.replace("P0", "P3") } } },
{ action: "reopened" },
{ action: "edited", changes: { title: { from: "Old title" } } },
{ action: "edited", changes: { body: { from: "Old body" } } },
@@ -211,10 +216,7 @@ test("uses fresh issue content and rechecks eligibility before paid assessment",
});
test("does not publish stale results after changes, closure or maintainer opt-out", async () => {
for (const override of [
{ title: "New title" }, { body: "New evidence" }, { labels: ["db/postgres"] },
{ state: "closed" }, { labels: ["ai-priority/skip"] },
]) {
for (const override of [{ title: "New title" }, { body: "New evidence" }, { labels: ["db/postgres"] }, { state: "closed" }, { labels: ["ai-priority/skip"] }]) {
const state = fixture({ latest: { ...baseIssue, ...override } });
assert.equal((await run(state.options)).skipped, "issue changed during assessment");
assert.equal(state.modelCalls(), 1);
@@ -247,7 +249,9 @@ test("provider failures and invalid outputs preserve all existing labels and do
async () => Response.json({ error: "secret-response-body" }, { status: 401 }),
async () => Response.json({ error: "secret-response-body" }, { status: 429 }),
async () => Response.json({ error: "secret-response-body" }, { status: 500 }),
async () => { throw new Error("secret-response-body"); },
async () => {
throw new Error("secret-response-body");
},
async () => new Response("secret-response-body"),
async () => Response.json({ choices: [] }),
async () => Response.json({ choices: [{ finish_reason: "length", message: { content: "secret-response-body" } }] }),
@@ -282,7 +286,10 @@ test("failed label addition never removes the previous AI priority", async () =>
return original(method, path, body);
};
await assert.rejects(run(state.options), /GitHub unavailable/);
assert.equal(state.calls.some(({ method }) => method === "DELETE"), false);
assert.equal(
state.calls.some(({ method }) => method === "DELETE"),
false,
);
});
test("invalid issue numbers are rejected before requests", async () => {
@@ -299,7 +306,8 @@ test("GitHub client keeps credentials separate, uses timeouts and never includes
assert.throws(() => createGitHubClient({ token: "fixture-token", repository: "https://other.example" }));
const calls = [];
const github = createGitHubClient({
token: "fixture-token", repository: "t8y2/dbx",
token: "fixture-token",
repository: "t8y2/dbx",
fetchImpl: async (url, options) => {
calls.push({ url, options });
return options.method === "DELETE" ? new Response(null, { status: 204 }) : Response.json(baseIssue);
@@ -311,7 +319,8 @@ test("GitHub client keeps credentials separate, uses timeouts and never includes
assert.ok(calls[0].options.signal instanceof AbortSignal);
assert.equal(await github("DELETE", "/issues/42/labels/test"), null);
const failing = createGitHubClient({
token: "fixture-token", repository: "t8y2/dbx",
token: "fixture-token",
repository: "t8y2/dbx",
fetchImpl: async () => new Response("secret-response-body", { status: 403 }),
});
await assert.rejects(failing("GET", "/issues/42"), (error) => {
+3 -16
View File
@@ -33,23 +33,14 @@ export function selectChunk(issues, { chunkIndex, chunkCount, maxIssues = 0 }) {
export async function listOpenUnassignedIssues(client) {
const issues = [];
for (let page = 1; ; page += 1) {
const items = await client.request(
"GET",
`/repos/${client.repository}/issues?state=open&sort=created&direction=desc&per_page=100&page=${page}`,
);
const items = await client.request("GET", `/repos/${client.repository}/issues?state=open&sort=created&direction=desc&per_page=100&page=${page}`);
issues.push(...items.filter((issue) => !issue.pull_request && (issue.assignees || []).length === 0));
if (items.length < 100) break;
}
return issues;
}
export async function backfill({
client,
chunkIndex,
chunkCount,
maxIssues = 0,
searchDelayMs = DEFAULT_SEARCH_DELAY_MS,
}) {
export async function backfill({ client, chunkIndex, chunkCount, maxIssues = 0, searchDelayMs = DEFAULT_SEARCH_DELAY_MS }) {
const issues = selectChunk(await listOpenUnassignedIssues(client), { chunkIndex, chunkCount, maxIssues });
const originalSearchIssues = client.searchIssues.bind(client);
let lastSearchStartedAt = 0;
@@ -98,10 +89,6 @@ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url)
chunkIndex: integerValue(process.env.BACKFILL_CHUNK_INDEX, 0, "BACKFILL_CHUNK_INDEX"),
chunkCount: integerValue(process.env.BACKFILL_CHUNK_COUNT, 1, "BACKFILL_CHUNK_COUNT"),
maxIssues: integerValue(process.env.BACKFILL_MAX_ISSUES, 0, "BACKFILL_MAX_ISSUES"),
searchDelayMs: integerValue(
process.env.BACKFILL_SEARCH_DELAY_MS,
DEFAULT_SEARCH_DELAY_MS,
"BACKFILL_SEARCH_DELAY_MS",
),
searchDelayMs: integerValue(process.env.BACKFILL_SEARCH_DELAY_MS, DEFAULT_SEARCH_DELAY_MS, "BACKFILL_SEARCH_DELAY_MS"),
});
}
@@ -1,17 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
backfill,
listOpenUnassignedIssues,
selectChunk,
} from "./backfill-similar-issues.mjs";
import { backfill, listOpenUnassignedIssues, selectChunk } from "./backfill-similar-issues.mjs";
test("selectChunk partitions issue numbers deterministically", () => {
const issues = [1, 2, 3, 4, 5, 6].map((number) => ({ number }));
assert.deepEqual(selectChunk(issues, { chunkIndex: 0, chunkCount: 3 }).map(({ number }) => number), [6, 3]);
assert.deepEqual(selectChunk(issues, { chunkIndex: 1, chunkCount: 3 }).map(({ number }) => number), [4, 1]);
assert.deepEqual(
selectChunk(issues, { chunkIndex: 0, chunkCount: 3 }).map(({ number }) => number),
[6, 3],
);
assert.deepEqual(
selectChunk(issues, { chunkIndex: 1, chunkCount: 3 }).map(({ number }) => number),
[4, 1],
);
assert.deepEqual(
selectChunk(issues, { chunkIndex: 2, chunkCount: 3, maxIssues: 1 }).map(({ number }) => number),
[5],
@@ -34,8 +36,14 @@ test("listOpenUnassignedIssues filters pull requests and assigned issues across
const issues = await listOpenUnassignedIssues(client);
assert.equal(issues.length, 99);
assert.equal(issues.some(({ number }) => number === 1), false);
assert.equal(issues.some(({ number }) => number === 2), false);
assert.equal(
issues.some(({ number }) => number === 1),
false,
);
assert.equal(
issues.some(({ number }) => number === 2),
false,
);
assert.equal(issues.at(-1).number, 101);
});
+15 -56
View File
@@ -5,10 +5,7 @@ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs
const VERSIONS_PATH = "agents/versions.json";
const VERSION_SYNC_SUBJECT = "chore: bump module versions [skip ci]";
const JRE_BUILD_PATHS = new Set([".github/workflows/agents-release.yml"]);
const NATIVE_RELEASE_PACKAGING_PATHS = new Set([
".github/scripts/reuse-agent-release-assets.mjs",
"agents/scripts/version_agent_artifacts.py",
]);
const NATIVE_RELEASE_PACKAGING_PATHS = new Set([".github/scripts/reuse-agent-release-assets.mjs", "agents/scripts/version_agent_artifacts.py"]);
function bumpPatchVersion(version) {
const match = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(version);
@@ -35,11 +32,7 @@ export function parseLegacyStandaloneProjects(buildGradle) {
const match = /legacyStandaloneProjects\s*=\s*\[([^\]]*)\]/m.exec(buildGradle);
if (!match) return new Set();
return new Set(
[...match[1].matchAll(/['"]([^'"]+)['"]/g)]
.map((entry) => entry[1])
.filter(Boolean),
);
return new Set([...match[1].matchAll(/['"]([^'"]+)['"]/g)].map((entry) => entry[1]).filter(Boolean));
}
function fileContainsCommonDependency(path, moduleExists, readModuleFile) {
@@ -72,23 +65,10 @@ const crateNativeDriverDirectories = {
};
const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "argo", "oracle", "oracle-oci", "xugu", "kingbase", "iotdb", "neo4j", "nebula", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "etcd", "etcd2", "sqlite-worker"]);
const nativeDriverSharedPaths = {
hive: [
"agents/go-common/go-gssapi",
"agents/go-common/gohive",
"agents/go-common/gosasl",
],
argo: [
"agents/go-common/go-gssapi",
"agents/go-common/gohive",
"agents/go-common/gosasl",
],
zookeeper: [
"agents/go-common/go-gssapi",
"agents/go-common/gosasl",
],
etcd: [
"agents/go-common/go-semver",
],
hive: ["agents/go-common/go-gssapi", "agents/go-common/gohive", "agents/go-common/gosasl"],
argo: ["agents/go-common/go-gssapi", "agents/go-common/gohive", "agents/go-common/gosasl"],
zookeeper: ["agents/go-common/go-gssapi", "agents/go-common/gosasl"],
etcd: ["agents/go-common/go-semver"],
};
function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) {
@@ -137,16 +117,7 @@ function classifyModules(versions, options) {
.filter(({ module }) => module);
}
export function evaluateAgentVersionBump({
versions,
prevVersions = versions,
changedFiles,
legacyStandaloneModules = new Set(),
moduleExists = existsSync,
readModuleFile = (path) => readFileSync(path, "utf8"),
skipBump = false,
manualVersionsChanged = changedFiles.includes(VERSIONS_PATH),
}) {
export function evaluateAgentVersionBump({ versions, prevVersions = versions, changedFiles, legacyStandaloneModules = new Set(), moduleExists = existsSync, readModuleFile = (path) => readFileSync(path, "utf8"), skipBump = false, manualVersionsChanged = changedFiles.includes(VERSIONS_PATH) }) {
const nextVersions = { ...versions };
const logs = [];
let changed = false;
@@ -180,8 +151,7 @@ export function evaluateAgentVersionBump({
}
for (const { moduleName, module } of resolvedModules) {
const moduleChanged = [module.modulePath, ...module.sharedPaths]
.some((path) => pathChanged(changedFiles, path));
const moduleChanged = [module.modulePath, ...module.sharedPaths].some((path) => pathChanged(changedFiles, path));
// Only modules that package agents/common need installer-visible updates
// for shared Java runtime changes; native and standalone agents do not.
const commonAffectsModule = commonChanged && module.commonDependent;
@@ -233,22 +203,15 @@ function git(args) {
}
function lines(value) {
return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
export function resolveAgentReleaseBaseline({ prevTag, headRef = "HEAD", gitOutput = git }) {
const allChangedFiles = lines(gitOutput(["diff", "--name-only", `${prevTag}..${headRef}`]));
const versionCommits = lines(
gitOutput([
"log",
"--reverse",
"--ancestry-path",
"--format=%H%x09%s",
`${prevTag}..${headRef}`,
"--",
VERSIONS_PATH,
]),
);
const versionCommits = lines(gitOutput(["log", "--reverse", "--ancestry-path", "--format=%H%x09%s", `${prevTag}..${headRef}`, "--", VERSIONS_PATH]));
let syncCommit = "";
for (const entry of versionCommits) {
@@ -266,12 +229,8 @@ export function resolveAgentReleaseBaseline({ prevTag, headRef = "HEAD", gitOutp
const versionsRef = syncCommit || prevTag;
const versions = JSON.parse(gitOutput(["show", `${versionsRef}:${VERSIONS_PATH}`]));
const versionsChangedAfterSync = syncCommit
? lines(gitOutput(["log", "--format=%H", `${syncCommit}..${headRef}`, "--", VERSIONS_PATH])).length > 0
: false;
const changedFiles = syncCommit && !versionsChangedAfterSync
? allChangedFiles.filter((file) => file !== VERSIONS_PATH)
: allChangedFiles;
const versionsChangedAfterSync = syncCommit ? lines(gitOutput(["log", "--format=%H", `${syncCommit}..${headRef}`, "--", VERSIONS_PATH])).length > 0 : false;
const changedFiles = syncCommit && !versionsChangedAfterSync ? allChangedFiles.filter((file) => file !== VERSIONS_PATH) : allChangedFiles;
return {
prevTag,
+5 -24
View File
@@ -118,11 +118,7 @@ test("bumps the native RocketMQ agent from its Go directory", () => {
});
test("bumps ZooKeeper from native and shared SASL source directories", () => {
for (const changedFile of [
"agents/drivers/zookeeper/main.go",
"agents/go-common/gosasl/sasl.go",
"agents/go-common/go-gssapi/krb5/krb5.go",
]) {
for (const changedFile of ["agents/drivers/zookeeper/main.go", "agents/go-common/gosasl/sasl.go", "agents/go-common/go-gssapi/krb5/krb5.go"]) {
const result = evaluateAgentVersionBump({
versions: { zookeeper: "0.1.0" },
changedFiles: [changedFile],
@@ -161,12 +157,7 @@ test("bumps Cassandra from its native Go source directory", () => {
});
test("bumps Hive from native and shared Kerberos source directories", () => {
for (const changedFile of [
"agents/drivers/hive-go/main.go",
"agents/go-common/gohive/driver.go",
"agents/go-common/gosasl/gssapi.go",
"agents/go-common/go-gssapi/krb5/krb5.go",
]) {
for (const changedFile of ["agents/drivers/hive-go/main.go", "agents/go-common/gohive/driver.go", "agents/go-common/gosasl/gssapi.go", "agents/go-common/go-gssapi/krb5/krb5.go"]) {
const result = evaluateAgentVersionBump({
versions: { hive: "0.1.43" },
changedFiles: [changedFile],
@@ -207,12 +198,7 @@ test("bumps IoTDB from its native Go source directory", () => {
});
test("rebuilds native modules when shared native packaging changes", () => {
const existing = new Set([
"agents/drivers/access",
"agents/drivers/access/build.gradle",
"agents/drivers/duckdb",
"agents/drivers/neo4j-go",
]);
const existing = new Set(["agents/drivers/access", "agents/drivers/access/build.gradle", "agents/drivers/duckdb", "agents/drivers/neo4j-go"]);
const result = evaluateAgentVersionBump({
versions: { access: "0.1.37", duckdb: "0.1.3", neo4j: "0.1.40" },
changedFiles: ["agents/scripts/version_agent_artifacts.py"],
@@ -255,12 +241,7 @@ test("bumps DuckDB when its Cargo target configuration changes", () => {
});
test("builds only common-dependent Java modules for a shared runtime change", () => {
const existing = new Set([
"agents/drivers/access",
"agents/drivers/access/build.gradle",
"agents/drivers/mongodb",
"agents/drivers/mongodb/build.gradle",
]);
const existing = new Set(["agents/drivers/access", "agents/drivers/access/build.gradle", "agents/drivers/mongodb", "agents/drivers/mongodb/build.gradle"]);
const result = evaluateAgentVersionBump({
versions: { access: "0.1.0", mongodb: "0.1.0" },
changedFiles: ["agents/common/src/main/java/com/dbx/Agent.java"],
@@ -341,7 +322,7 @@ function createRepository(versions) {
mkdirSync(join(repository, "agents/drivers/duckdb"), { recursive: true });
writeVersions(repository, versions);
writeFileSync(join(repository, "agents/drivers/kingbase-go/kingbase_metadata.go"), "package main\n");
writeFileSync(join(repository, "agents/drivers/duckdb/Cargo.toml"), "[package]\nname = \"duckdb-test\"\n");
writeFileSync(join(repository, "agents/drivers/duckdb/Cargo.toml"), '[package]\nname = "duckdb-test"\n');
commitAll(repository, "feat(agents): initial release state");
return repository;
}
+25
View File
@@ -0,0 +1,25 @@
import { execFileSync } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
export function changeBase({ eventName, before, root = process.cwd() }) {
if (eventName === "pull_request") {
const commits = execFileSync("git", ["rev-list", "--parents", "-n", "1", "HEAD"], {
cwd: root,
encoding: "utf8",
stdio: "pipe",
})
.trim()
.split(/\s+/);
if (commits.length !== 3) throw new Error("PR change detection requires the checked-out merge commit and both parents");
return commits[1];
}
if (!before || /^0+$/.test(before)) return null;
if (!/^[0-9a-f]{40}$/.test(before)) throw new Error("Invalid push base SHA");
return before;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const base = changeBase({ eventName: process.env.GITHUB_EVENT_NAME, before: process.env.BEFORE_SHA });
if (base) console.log(base);
}
+119
View File
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import test from "node:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { changeBase } from "./ci-change-base.mjs";
import { changedPaths, planCi } from "./ci-plan.mjs";
function mergeFixture(context) {
const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-change-base-"));
context.after(() => rmSync(directory, { recursive: true, force: true }));
const root = path.join(directory, "source");
mkdirSync(root);
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }).trim();
const write = (file, content) => {
const filename = path.join(root, file);
mkdirSync(path.dirname(filename), { recursive: true });
writeFileSync(filename, content);
};
const commit = (message) => {
git("add", ".");
git("-c", "core.hooksPath=/dev/null", "commit", "-m", message);
return git("rev-parse", "HEAD");
};
git("init", "--initial-branch=main");
git("config", "user.email", "fixture@example.invalid");
git("config", "user.name", "CI Fixture");
write("crates/dbx-core/tests/old.rs", "original source\n");
write("crates/dbx-core/tests/deleted.rs", "removed source\n");
write("crates/dbx-web/src/routes/transfer.rs", "original route\n");
const staleBase = commit("baseline");
git("checkout", "-b", "pull-request");
git("mv", "crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/renamed\nfile.rs");
git("rm", "crates/dbx-core/tests/deleted.rs");
write("crates/dbx-core/tests/runtime_diagnostic_history.rs", "diagnostic test\n");
write("crates/dbx-web/src/routes/transfer.rs", "updated route\n");
const pullRequestHead = commit("PR diagnostic changes");
git("checkout", "main");
write(".github/workflows/ci.yml", "main CI changes\n");
write("agents/drivers/zookeeper/main.go", "main native Agent changes\n");
write("agents/drivers/oceanbase-oracle/src/main/java/Agent.java", "main JDBC changes\n");
const actualBase = commit("main advances while PR remains open");
git("-c", "core.hooksPath=/dev/null", "merge", "--no-ff", "pull-request", "-m", "synthetic PR test merge");
return { directory, root, staleBase, actualBase, pullRequestHead };
}
test("PR routing uses the exact test merge first parent, excluding unrelated main changes", (context) => {
const fixture = mergeFixture(context);
const base = changeBase({ eventName: "pull_request", before: fixture.staleBase, root: fixture.root });
assert.equal(base, fixture.actualBase);
assert.notEqual(base, fixture.pullRequestHead);
const files = changedPaths(base, fixture.root);
assert.deepEqual(files.toSorted(), ["crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/deleted.rs", "crates/dbx-core/tests/renamed\nfile.rs", "crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs"].toSorted());
const metadata = {
workspace_members: ["dbx-core", "dbx-web", "dbx"],
packages: [
{ id: "dbx-core", name: "dbx-core", manifest_path: path.join(fixture.root, "crates/dbx-core/Cargo.toml"), dependencies: [] },
{ id: "dbx-web", name: "dbx-web", manifest_path: path.join(fixture.root, "crates/dbx-web/Cargo.toml"), dependencies: [{ name: "dbx-core" }] },
{ id: "dbx", name: "dbx", manifest_path: path.join(fixture.root, "src-tauri/Cargo.toml"), dependencies: [{ name: "dbx-core" }] },
],
};
const corrected = planCi({ files, metadata, root: fixture.root });
assert.equal(corrected.rust_full, false);
assert.deepEqual(
corrected.rust_matrix.include.map((entry) => entry.group),
["application"],
);
assert.deepEqual(corrected.agent_rust.include, [{ driver: "duckdb" }]);
assert.equal(corrected.agent_java, false);
assert.deepEqual(corrected.agent_go.include, []);
assert.deepEqual(corrected.agent_integration.include, []);
assert.equal(corrected.windows_standard, true);
assert.equal(corrected.windows_win7_bundle, false);
const staleFiles = changedPaths(fixture.staleBase, fixture.root);
assert.ok(staleFiles.includes(".github/workflows/ci.yml"));
assert.ok(staleFiles.includes("agents/drivers/zookeeper/main.go"));
const polluted = planCi({ files: staleFiles, metadata, root: fixture.root });
assert.equal(polluted.rust_full, true);
assert.equal(polluted.agent_java, true);
assert.ok(polluted.agent_go.include.length > 0);
assert.ok(polluted.agent_integration.include.length > 0);
});
test("a depth-two checkout preserves the PR parents and supports the base CLI", (context) => {
const fixture = mergeFixture(context);
const checkout = path.join(fixture.directory, "depth-two");
execFileSync("git", ["clone", "--depth=2", pathToFileURL(fixture.root).href, checkout], { stdio: "pipe" });
assert.equal(changeBase({ eventName: "pull_request", root: checkout }), fixture.actualBase);
assert.deepEqual(changedPaths(fixture.actualBase, checkout), changedPaths(fixture.actualBase, fixture.root));
const output = execFileSync(process.execPath, [path.join(import.meta.dirname, "ci-change-base.mjs")], {
cwd: checkout,
encoding: "utf8",
env: { ...process.env, GITHUB_EVENT_NAME: "pull_request", BEFORE_SHA: fixture.staleBase },
});
assert.equal(output.trim(), fixture.actualBase);
});
test("PR base detection rejects a nonmerge commit and a depth-one checkout", (context) => {
const fixture = mergeFixture(context);
const checkout = path.join(fixture.directory, "depth-one");
execFileSync("git", ["clone", "--depth=1", pathToFileURL(fixture.root).href, checkout], { stdio: "pipe" });
assert.throws(() => changeBase({ eventName: "pull_request", root: checkout }), /merge commit and both parents/);
execFileSync("git", ["checkout", "--detach", fixture.pullRequestHead], { cwd: fixture.root, stdio: "pipe" });
assert.throws(() => changeBase({ eventName: "pull_request", root: fixture.root }), /merge commit and both parents/);
});
test("push routing preserves the event before SHA even when HEAD is a merge", (context) => {
const fixture = mergeFixture(context);
assert.equal(changeBase({ eventName: "push", before: fixture.staleBase, root: fixture.root }), fixture.staleBase);
assert.equal(changeBase({ eventName: "push", before: fixture.pullRequestHead, root: fixture.root }), fixture.pullRequestHead);
for (const before of [undefined, "", "0".repeat(40)]) {
assert.equal(changeBase({ eventName: "push", before }), null);
}
for (const before of ["not-a-ref", "HEAD", "a".repeat(39), "a".repeat(41)]) {
assert.throws(() => changeBase({ eventName: "push", before }), /Invalid push base SHA/);
}
});
+6 -11
View File
@@ -1,14 +1,7 @@
export const rustGroups = {
foundation: [
"dbx-types", "dbx-platform", "dbx-sql-core", "dbx-sql-dialect", "dbx-sql-data", "dbx-sql-schema", "dbx-sql",
"dbx-formats", "dbx-ai-provider", "dbx-plugin-runtime",
],
drivers: [
"dbx-driver-support", "dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb",
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-drivers",
"dbx-sqlite-worker",
],
application: ["dbx", "dbx-core", "dbx-web", "dbx-cli", "dbx-mcp"],
foundation: ["dbx-types", "dbx-platform", "dbx-sql-core", "dbx-sql-dialect", "dbx-sql-data", "dbx-sql-schema", "dbx-sql", "dbx-formats", "dbx-ai-provider", "dbx-plugin-runtime"],
drivers: ["dbx-driver-support", "dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb", "dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-drivers", "dbx-sqlite-worker"],
application: ["dbx", "dbx-core", "dbx-web", "dbx-cli", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"],
};
export const goAgents = [
@@ -32,7 +25,9 @@ export const integrationCases = [
{ driver: "zookeeper", scenario: "zookeeper-sasl", version: "3.7.0" },
...["4.9.8", "5.3.1"].map((version) => ({ driver: "rocketmq", scenario: "rocketmq", version })),
...["2.4.0.14", "2.6.0.34", "3.0.7.1", "3.3.6.13", "3.4.2.2"].map((version) => ({
driver: "tdengine", scenario: "tdengine", version,
driver: "tdengine",
scenario: "tdengine",
version,
image: `tdengine/${version === "3.4.2.2" ? "tsdb" : "tdengine"}:${version}`,
})),
...["3.11.19", "5.0.6"].map((version) => ({ driver: "cassandra-go", scenario: "cassandra", version })),
+131 -84
View File
@@ -9,10 +9,24 @@ import { goAgents, integrationCases } from "./ci-config.mjs";
import { lockfileManifests } from "./ci-lockfiles.mjs";
const root = fileURLToPath(new URL("../../", import.meta.url));
const environmentKeys = ["CGO_ENABLED", "GOOS", "GOARCH", "GONOSUMDB", "DBX_ZOOKEEPER_TEST_CONNECT_STRING",
"DBX_ZOOKEEPER_TEST_AUTH_SCHEME", "DBX_ZOOKEEPER_TEST_USERNAME", "DBX_ZOOKEEPER_TEST_PASSWORD", "TDENGINE_INTEGRATION",
"TDENGINE_TEST_HOST", "TDENGINE_TEST_PORT", "CASSANDRA_TEST_HOST", "CASSANDRA_TEST_PORT", "RABBITMQ_INTEGRATION",
"RABBITMQ_USERNAME", "RABBITMQ_PASSWORD"];
const environmentKeys = [
"CGO_ENABLED",
"GOOS",
"GOARCH",
"GONOSUMDB",
"DBX_ZOOKEEPER_TEST_CONNECT_STRING",
"DBX_ZOOKEEPER_TEST_AUTH_SCHEME",
"DBX_ZOOKEEPER_TEST_USERNAME",
"DBX_ZOOKEEPER_TEST_PASSWORD",
"TDENGINE_INTEGRATION",
"TDENGINE_TEST_HOST",
"TDENGINE_TEST_PORT",
"CASSANDRA_TEST_HOST",
"CASSANDRA_TEST_PORT",
"RABBITMQ_INTEGRATION",
"RABBITMQ_USERNAME",
"RABBITMQ_PASSWORD",
];
function withToolStubs(run, extraEnv = {}) {
const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-execution-"));
@@ -20,7 +34,9 @@ function withToolStubs(run, extraEnv = {}) {
mkdirSync(binaries);
const log = path.join(directory, "commands.jsonl");
const stub = path.join(binaries, "stub.cjs");
writeFileSync(stub, `#!/usr/bin/env node
writeFileSync(
stub,
`#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const tool = path.basename(process.argv[1]);
@@ -29,100 +45,131 @@ const env = Object.fromEntries(${JSON.stringify(environmentKeys)}.filter(name =>
fs.appendFileSync(process.env.CI_STUB_LOG, JSON.stringify({tool,args,cwd:process.cwd(),env})+'\\n');
if (process.env.CI_STUB_FAIL === tool) process.exit(23);
if (tool === 'docker' && args.includes('taos')) console.log('| ready |');
`, { mode: 0o755 });
`,
{ mode: 0o755 },
);
for (const name of ["docker", "go", "cargo", "timeout", "curl", "sleep", "bash"]) symlinkSync(stub, path.join(binaries, name));
try {
const env = { ...process.env, ...extraEnv, TMPDIR: directory, CI_STUB_LOG: log, PATH: `${binaries}${path.delimiter}${process.env.PATH}` };
return run(env, () => readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)));
} finally { rmSync(directory, { recursive: true, force: true }); }
return run(env, () =>
readFileSync(log, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
for (const agent of goAgents) {
test(`Go job preserves ${agent.driver} tests and target builds without executing compilers`, () => withToolStubs((env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-go.sh", agent.driver, agent.binary, String(agent.race)], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
const invocations = commands();
assert.deepEqual(invocations[0].args, agent.race ? ["test", "-race", "./..."] : ["test", "./..."]);
assert.ok(invocations.every((entry) => entry.cwd === path.join(root, "agents/drivers", agent.driver)));
const builds = invocations.slice(1);
assert.equal(builds.length, agent.driver === "zookeeper" ? 6 : 1);
for (const build of builds) {
assert.deepEqual(build.args.slice(0, 3), ["build", "-trimpath", "-ldflags=-s -w"]);
assert.equal(build.env.CGO_ENABLED, "0");
}
if (agent.driver === "zookeeper") assert.deepEqual(builds.map((entry) => `${entry.env.GOOS}/${entry.env.GOARCH}`),
["darwin/arm64", "darwin/amd64", "linux/arm64", "linux/amd64", "windows/arm64", "windows/amd64"]);
else assert.ok(builds[0].args.includes(`/tmp/dbx-agent-${agent.binary}-linux-x64`));
if (agent.driver === "xugu") assert.ok(invocations.every((entry) => entry.env.GONOSUMDB === "gitee.com/XuguDB/go-xugu-driver"));
}));
test(`Go job preserves ${agent.driver} tests and target builds without executing compilers`, () =>
withToolStubs((env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-go.sh", agent.driver, agent.binary, String(agent.race)], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
const invocations = commands();
assert.deepEqual(invocations[0].args, agent.race ? ["test", "-race", "./..."] : ["test", "./..."]);
assert.ok(invocations.every((entry) => entry.cwd === path.join(root, "agents/drivers", agent.driver)));
const builds = invocations.slice(1);
assert.equal(builds.length, agent.driver === "zookeeper" ? 6 : 1);
for (const build of builds) {
assert.deepEqual(build.args.slice(0, 3), ["build", "-trimpath", "-ldflags=-s -w"]);
assert.equal(build.env.CGO_ENABLED, "0");
}
if (agent.driver === "zookeeper")
assert.deepEqual(
builds.map((entry) => `${entry.env.GOOS}/${entry.env.GOARCH}`),
["darwin/arm64", "darwin/amd64", "linux/arm64", "linux/amd64", "windows/arm64", "windows/amd64"],
);
else assert.ok(builds[0].args.includes(`/tmp/dbx-agent-${agent.binary}-linux-x64`));
if (agent.driver === "xugu") assert.ok(invocations.every((entry) => entry.env.GONOSUMDB === "gitee.com/XuguDB/go-xugu-driver"));
}));
}
for (const entry of integrationCases) {
test(`live matrix retains ${entry.scenario}/${entry.version} commands without starting any container`, () => withToolStubs((env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", entry.scenario, entry.version, entry.image ?? ""], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
const invocations = commands();
if (entry.scenario === "rocketmq") {
assert.ok(invocations.some((call) => call.tool === "bash" && call.args.at(-1) === entry.version));
return;
}
const start = invocations.find((call) => call.tool === "docker" && call.args[0] === "run");
assert.ok(start, "starts the selected container");
const expectedImage = entry.image ?? (entry.scenario.startsWith("zookeeper") ? `zookeeper:${entry.version}`
: entry.scenario === "rabbitmq" ? `rabbitmq:${entry.version}-management` : `cassandra:${entry.version}`);
assert.equal(start.args.at(-1), expectedImage);
const last = invocations.at(-1);
assert.equal(last.tool, "docker");
assert.deepEqual(last.args.slice(0, 2), ["rm", "-fv"]);
const tests = invocations.filter((call) => ["go", "cargo"].includes(call.tool));
assert.ok(tests.length);
if (entry.scenario === "zookeeper") {
assert.equal(tests.length, entry.version === "3.7.0" ? 2 : 1);
assert.ok(tests[0].args.includes("^TestZooKeeperIntegration$"));
if (entry.version === "3.7.0") assert.ok(tests[1].args.includes("^TestZooKeeperLargeChildrenIntegration$"));
} else if (entry.scenario === "zookeeper-sasl") {
assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_AUTH_SCHEME, "sasl_digest");
assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_USERNAME, "dbx");
assert.ok(start.args.some((arg) => arg.includes("sessionRequireClientSASLAuth=true")));
} else if (entry.scenario === "tdengine") {
assert.deepEqual(tests[0].args, ["nextest", "run", "--manifest-path", "drivers/tdengine/Cargo.toml", "--locked", "--test", "live", "--no-capture", "--no-fail-fast"]);
assert.equal(tests[0].env.TDENGINE_INTEGRATION, "1");
assert.equal(tests[0].env.TDENGINE_TEST_PORT, "6041");
} else if (entry.scenario === "cassandra") assert.ok(tests[0].args.includes("^TestCassandraIntegration$"));
else {
assert.ok(tests[0].args.includes("^TestRabbitMQIntegration$"));
assert.equal(tests[0].env.RABBITMQ_INTEGRATION, "1");
assert.ok(start.args.includes("999:999"));
assert.ok(start.args.includes("/var/lib/rabbitmq:rw,exec,uid=999,gid=999,mode=700"));
}
}));
test(`live matrix retains ${entry.scenario}/${entry.version} commands without starting any container`, () =>
withToolStubs((env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", entry.scenario, entry.version, entry.image ?? ""], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
const invocations = commands();
if (entry.scenario === "rocketmq") {
assert.ok(invocations.some((call) => call.tool === "bash" && call.args.at(-1) === entry.version));
return;
}
const start = invocations.find((call) => call.tool === "docker" && call.args[0] === "run");
assert.ok(start, "starts the selected container");
const expectedImage = entry.image ?? (entry.scenario.startsWith("zookeeper") ? `zookeeper:${entry.version}` : entry.scenario === "rabbitmq" ? `rabbitmq:${entry.version}-management` : `cassandra:${entry.version}`);
assert.equal(start.args.at(-1), expectedImage);
const last = invocations.at(-1);
assert.equal(last.tool, "docker");
assert.deepEqual(last.args.slice(0, 2), ["rm", "-fv"]);
const tests = invocations.filter((call) => ["go", "cargo"].includes(call.tool));
assert.ok(tests.length);
if (entry.scenario === "zookeeper") {
assert.equal(tests.length, entry.version === "3.7.0" ? 2 : 1);
assert.ok(tests[0].args.includes("^TestZooKeeperIntegration$"));
if (entry.version === "3.7.0") assert.ok(tests[1].args.includes("^TestZooKeeperLargeChildrenIntegration$"));
} else if (entry.scenario === "zookeeper-sasl") {
assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_AUTH_SCHEME, "sasl_digest");
assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_USERNAME, "dbx");
assert.ok(start.args.some((arg) => arg.includes("sessionRequireClientSASLAuth=true")));
} else if (entry.scenario === "tdengine") {
assert.deepEqual(tests[0].args, ["nextest", "run", "--manifest-path", "drivers/tdengine/Cargo.toml", "--locked", "--test", "live", "--no-capture", "--no-fail-fast"]);
assert.equal(tests[0].env.TDENGINE_INTEGRATION, "1");
assert.equal(tests[0].env.TDENGINE_TEST_PORT, "6041");
} else if (entry.scenario === "cassandra") assert.ok(tests[0].args.includes("^TestCassandraIntegration$"));
else {
assert.ok(tests[0].args.includes("^TestRabbitMQIntegration$"));
assert.equal(tests[0].env.RABBITMQ_INTEGRATION, "1");
assert.ok(start.args.includes("999:999"));
assert.ok(start.args.includes("/var/lib/rabbitmq:rw,exec,uid=999,gid=999,mode=700"));
}
}));
}
test("failed live tests stay failed and still clean their container", () => withToolStubs((env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "cassandra", "5.0.6"], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 23);
assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]);
}, { CI_STUB_FAIL: "go" }));
test("failed live tests stay failed and still clean their container", () =>
withToolStubs(
(env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "cassandra", "5.0.6"], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 23);
assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]);
},
{ CI_STUB_FAIL: "go" },
));
test("failed nextest live tests propagate failure and still clean their container", () => withToolStubs((env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "tdengine", "3.4.2.2"], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 23);
assert.deepEqual(commands().find((command) => command.tool === "cargo").args.slice(0, 2), ["nextest", "run"]);
assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]);
}, { CI_STUB_FAIL: "cargo" }));
test("failed nextest live tests propagate failure and still clean their container", () =>
withToolStubs(
(env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "tdengine", "3.4.2.2"], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 23);
assert.deepEqual(
commands()
.find((command) => command.tool === "cargo")
.args.slice(0, 2),
["nextest", "run"],
);
assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]);
},
{ CI_STUB_FAIL: "cargo" },
));
test("lock preflight resolves full graphs, keeps --locked and propagates failures", () => {
const plan = { rust: true, agent_rust: { include: [{ driver: "duckdb" }, { driver: "tdengine" }] } };
assert.deepEqual(lockfileManifests(plan), ["Cargo.toml", "agents/drivers/duckdb/Cargo.toml", "agents/drivers/tdengine/Cargo.toml"]);
assert.throws(() => lockfileManifests({ rust: false, agent_rust: { include: [{ driver: "../../unknown" }] } }));
for (const failure of ["", "cargo"]) withToolStubs((env, commands) => {
const result = spawnSync(process.execPath, [".github/scripts/ci-lockfiles.mjs"], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, failure ? 23 : 0, result.stderr);
assert.equal(commands().length, failure ? 1 : 3);
for (const command of commands()) {
assert.equal(command.args[0], "metadata");
assert.ok(command.args.includes("--locked"));
assert.ok(!command.args.includes("--no-deps"));
}
}, { CI_PLAN: JSON.stringify(plan), CI_STUB_FAIL: failure });
for (const failure of ["", "cargo"])
withToolStubs(
(env, commands) => {
const result = spawnSync(process.execPath, [".github/scripts/ci-lockfiles.mjs"], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, failure ? 23 : 0, result.stderr);
assert.equal(commands().length, failure ? 1 : 3);
for (const command of commands()) {
assert.equal(command.args[0], "metadata");
assert.ok(command.args.includes("--locked"));
assert.ok(!command.args.includes("--no-deps"));
}
},
{ CI_PLAN: JSON.stringify(plan), CI_STUB_FAIL: failure },
);
});
+46 -18
View File
@@ -4,33 +4,61 @@ import { pathToFileURL } from "node:url";
export function gateFailures(needs, mode) {
if (needs.changes?.result !== "success") return ["changes did not succeed"];
let plan;
try { plan = JSON.parse(needs.changes.outputs.plan); } catch { return ["missing or invalid CI plan"]; }
try {
plan = JSON.parse(needs.changes.outputs.plan);
} catch {
return ["missing or invalid CI plan"];
}
if (!plan || typeof plan !== "object" || Array.isArray(plan)) return ["incomplete CI plan"];
const flags = ["rust", "agents", "fast", "agent_java", "agent_go_changed", "agent_rust_changed", "agent_integration_changed"];
if (!flags.every((flag) => typeof plan[flag] === "boolean")) return ["incomplete CI plan"];
const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts",
"windows-standard-check": "windows_win7_bundle", "windows-win7-bundle": "windows_win7_bundle",
"duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" };
const routedJobs = {
frontend: "frontend",
packages: "packages",
"github-scripts": "github_scripts",
"windows-standard-check": "windows_standard",
"windows-win7-bundle": "windows_win7_bundle",
"duckdb-windows-driver": "duckdb_windows",
jdbc: "jdbc",
"offline-jdbc-release": "offline_jdbc",
"nix-packaging": "nix",
};
if (mode === "all" && !Object.values(routedJobs).every((output) => ["true", "false"].includes(needs.changes.outputs[output]))) {
return ["missing or invalid job selection outputs"];
}
if (mode === "frontend" && !["true", "false"].includes(needs.changes.outputs.frontend)) {
return ["missing or invalid frontend selection output"];
}
const expected = mode === "rust" ? {
"fast-checks": plan.fast, "rust-fmt-clippy": plan.rust, "rust-test": plan.rust,
} : mode === "agents" ? {
"fast-checks": plan.fast, "agent-checks": plan.agents, "agent-java": plan.agent_java, "agent-go": plan.agent_go_changed,
"agent-rust": plan.agent_rust_changed, "agent-integration": plan.agent_integration_changed,
} : mode === "frontend" ? {
"frontend-checks": needs.changes.outputs.frontend === "true",
"frontend-typecheck": needs.changes.outputs.frontend === "true",
"frontend-test": needs.changes.outputs.frontend === "true",
} : mode === "all" ? {
rust: true, agents: true, "fast-checks": plan.fast,
...Object.fromEntries(Object.entries(routedJobs)
.map(([job, output]) => [job, needs.changes.outputs[output] === "true"])),
} : null;
const expected =
mode === "rust"
? {
"fast-checks": plan.fast,
"rust-fmt-clippy": plan.rust,
"rust-test": plan.rust,
}
: mode === "agents"
? {
"fast-checks": plan.fast,
"agent-checks": plan.agents,
"agent-java": plan.agent_java,
"agent-go": plan.agent_go_changed,
"agent-rust": plan.agent_rust_changed,
"agent-integration": plan.agent_integration_changed,
}
: mode === "frontend"
? {
"frontend-checks": needs.changes.outputs.frontend === "true",
"frontend-typecheck": needs.changes.outputs.frontend === "true",
"frontend-test": needs.changes.outputs.frontend === "true",
}
: mode === "all"
? {
rust: true,
agents: true,
"fast-checks": plan.fast,
...Object.fromEntries(Object.entries(routedJobs).map(([job, output]) => [job, needs.changes.outputs[output] === "true"])),
}
: null;
if (!expected) return [`unknown gate: ${mode}`];
return Object.entries(expected).flatMap(([job, required]) => {
const result = needs[job]?.result;
+70 -11
View File
@@ -2,19 +2,48 @@
Paths in this guide are relative to `.github/`.
`workflows/ci.yml` retains one workflow and one cancellation group. Splitting jobs
does not change release workflows or branch-protection settings. The existing
`workflows/ci.yml` retains one workflow. Splitting jobs does not change release
workflows or branch-protection settings. The existing
`rust` and `agents` aggregate check names remain; `ci` additionally summarizes all
selected jobs. None of these gates accepts a failed, cancelled, missing, or
unexpectedly skipped prerequisite. The gates are guarded by
`if: always() && !cancelled()`: on a superseded commit a newer push cancels the
run, and `always()` alone would still execute the gate and report those
cancellations as a failure of the commit that no longer matters.
`if: always() && !cancelled()`: on a cancelled run, `always()` alone would still
execute the gate and report cancelled prerequisites as a failure.
## Contributor concurrency
PRs from anyone other than the repository owner share main `CI` concurrency
groups keyed by the PR author's stable numeric user ID and the parity of the PR
number (`pr-author-<id>-even` / `pr-author-<id>-odd`). At most two runs per author
execute at a time — one per parity group — and other authors have independent
groups. Actions expressions have no modulo, so parity is read from the PR number's
last digit with `endsWith`. Two PRs of the same parity still queue behind each other.
The PR author, not the trigger actor or author association, determines this
policy, so a maintainer retrigger cannot bypass contributor limits.
`cancel-in-progress: false` preserves the executing PR, and `queue: max` retains
up to 100 pending runs rather than replacing earlier pending PRs. Further runs
beyond that limit are cancelled by GitHub. Updates to the same contributor PR
also enqueue separate runs; this policy does not deduplicate superseded commits.
The repository owner (`t8y2` here, compared with `github.repository_owner`) is
exempt from author-level limits. Owner PRs retain one cancellation group per PR,
so separate owner PRs run independently and a new commit cancels that PR's old
run. Main pushes retain their independent ref-based cancellation group. These
paths use `queue: single`, never `queue: max` with in-progress cancellation.
This limits main CI workflow runs, not the runner count inside one selected PR.
Other PR workflows and all release workflows remain unchanged. Existing runs
retain their original workflow configuration until a new event uses this policy.
## Selection
`scripts/ci-plan.mjs` reads Cargo workspace metadata and the exact event base/head
diff. Renames include both paths and deleted paths remain visible. Rust package
`scripts/ci-change-base.mjs` uses the first parent of the checked-out PR test merge
as its base, not the potentially stale base SHA in the PR event. A depth-two
checkout preserves both parents; a missing merge parent fails change detection.
Pushes use the exact event `before` SHA. `paths-filter` uses Git, with an empty API
token and explicit base/head, so its selection matches `scripts/ci-plan.mjs`
rather than a mutable PR file list. An unavailable push diff keeps conservative
planner coverage. Renames include both paths and deleted paths remain visible. Rust package
selection follows reverse dependencies, including build and test dependencies.
Connection-type descriptors belong to `dbx-types`; dialect descriptors belong to
`dbx-sql`. A changed core dependency also selects the standalone DuckDB Agent tests,
@@ -22,7 +51,10 @@ whose development dependency is `dbx-core`.
- Ordinary Rust PRs select foundation, driver, and application test groups from
`scripts/ci-config.mjs`. Each group contains several packages, not one runner per
crate. At most three Rust test groups run concurrently.
crate. The application group includes `dbx-tauri-consul` and `dbx-tauri-schema`.
At most three Rust test groups run concurrently; a contract checks the groups
against actual workspace metadata so extracted crates cannot silently force
every PR into the full-workspace fallback.
- Main pushes, shared Cargo manifests/locks, toolchain/config/vendor changes, CI
infrastructure changes, an unknown workspace member, or an unavailable diff
select the original full-workspace test lane instead of duplicating it with all
@@ -34,8 +66,33 @@ whose development dependency is `dbx-core`.
cases. Shared Agent inputs conservatively select all existing Agent checks.
Windows DuckDB packaging is selected by its own inputs or shared build/lock
changes, not every change to its development dependency's source.
- Existing frontend, package, Windows, JDBC, offline-payload and Nix jobs retain
their commands and path filters.
- Standard Windows compilation follows desktop reverse dependencies, including
ordinary core/driver source changes. Full Win7 packaging runs for Cargo
manifests/locks, toolchain/config/vendor changes, platform/update/installer
code, Tauri configuration, Win7 fixtures/scripts and release/CI workflow inputs.
Unknown Rust paths, unassigned Rust workspace members and unavailable diffs
retain both Windows jobs. Other desktop source and test changes keep standard
Windows compilation without repeating the full Win7 installer build. The final
gate validates each Windows selection independently; release builds are unchanged.
- Existing frontend, package, JDBC, offline-payload and Nix jobs retain their
commands and path filters, except for the snapshot-only routing below.
Contributor snapshots refresh weekly, at 02:00 Monday in Asia/Shanghai, and can
still be refreshed manually. An unchanged snapshot keeps its existing timestamp
and creates no commit. A diff containing only `docs/data/contributors.json` skips
desktop frontend validation, but the docs deployment still rebuilds the site.
Mixed diffs and unavailable diffs keep the existing validation routing.
When `fast-checks` is selected, it owns the CI planner and gate tests before
expensive dependent jobs start. `github-scripts` omits that subset in these runs,
but runs every GitHub script test when the fast-check lane is not selected.
Issue command and spam jobs reject irrelevant comments before allocating a
runner. Their existing command parser and malicious-link matcher remain the
authoritative checks. PR notification triggers are commented out; release
notifications in the publishing workflow are unchanged. Plugin development
host lint and formatting run once on Linux before the Linux, macOS and Windows
runtime, Rust and package checks; cross-platform coverage is unchanged.
Changes to the planner itself select the broad checks. Missing/invalid planning
data fails the aggregate gates rather than producing a successful skip.
@@ -70,7 +127,9 @@ incompatible SQLite backends.
| `agent-integration` | Sixteen existing engine/version/authentication cases | 4 |
Matrix jobs set `fail-fast: false`, so one failure does not conceal results from
the other selected cases. Live test predicates, readiness checks, time limits,
the other selected cases. Go and live matrix parallelism is bounded at four each
to reduce bursts competing with other workflows; this is not a reservation or a
priority guarantee for release jobs. Live test predicates, readiness checks, time limits,
credentials for disposable fixtures, and failure cleanup are retained.
Native, Java and live jobs wait for fast checks and Agent source validation before
starting expensive builds or containers.
+4 -2
View File
@@ -3,11 +3,13 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
export function lockfileManifests(plan) {
return [...(plan.rust ? ["Cargo.toml"] : []),
return [
...(plan.rust ? ["Cargo.toml"] : []),
...plan.agent_rust.include.map(({ driver }) => {
if (!["duckdb", "tdengine"].includes(driver)) throw new Error(`Unknown native Rust driver: ${driver}`);
return `agents/drivers/${driver}/Cargo.toml`;
})];
}),
];
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
+59 -46
View File
@@ -16,14 +16,7 @@ const win7InfrastructureFiles = new Set([
"src-tauri/tauri.webview2-win7-fixed.conf.json",
"src-tauri/build.rs",
]);
const win7InfrastructurePrefixes = [
"src-tauri/windows/nsis/",
"vendor/wry/",
"vendor/webview2-com-sys/",
"vendor/ctor/",
"vendor/dirs-sys/",
"vendor/pageant/",
];
const win7InfrastructurePrefixes = [".cargo/", "rust-toolchain", ".github/fixtures/win7-", "src-tauri/windows/nsis/", "src-tauri/src/commands/update", "crates/dbx-core/src/host/update", "crates/dbx-platform/", "vendor/"];
function expandAffectedPackages(packages, members, affected) {
let expanded;
@@ -42,17 +35,19 @@ export function changedPaths(base, root) {
if (!base || /^0+$/.test(base)) return null;
execFileSync("git", ["rev-parse", "--verify", `${base}^{commit}`], { cwd: root, stdio: "pipe" });
return execFileSync("git", ["diff", "--no-renames", "--name-only", "-z", base, "HEAD", "--"], {
cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
}).split("\0").filter(Boolean);
cwd: root,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
})
.split("\0")
.filter(Boolean);
}
export function knownJavaDrivers(root, nativeDrivers) {
try {
const driversRoot = path.join(root, "agents/drivers");
return readdirSync(driversRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory()
&& existsSync(path.join(driversRoot, entry.name, "build.gradle"))
&& !nativeDrivers.includes(entry.name))
.filter((entry) => entry.isDirectory() && existsSync(path.join(driversRoot, entry.name, "build.gradle")) && !nativeDrivers.includes(entry.name))
.map((entry) => entry.name)
.sort();
} catch {
@@ -61,9 +56,13 @@ export function knownJavaDrivers(root, nativeDrivers) {
}
export function cargoMetadata(root, execute = execFileSync) {
return JSON.parse(execute("cargo", ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"], {
cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
}));
return JSON.parse(
execute("cargo", ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"], {
cwd: root,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
}),
);
}
export function planCi({ files, metadata, root, eventName = "pull_request", rustChanged = false, agentsChanged = false, javaDrivers }) {
@@ -73,8 +72,7 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
const members = new Map(packages.map((pkg) => [pkg.name, pkg]));
const packagePaths = packages.map((pkg) => [pkg.name, `${path.relative(root, path.dirname(pkg.manifest_path)).split(path.sep).join("/")}/`]);
const ciChanged = unknownDiff || files.some((file) => /^\.github\/(?:workflows\/ci(?:[.-]|\/)|scripts\/ci-|actions\/ci-)/.test(file));
const sharedRust = ciChanged || files.some((file) => /^(?:Cargo\.(?:toml|lock)$|rust-toolchain|\.cargo\/|vendor\/)/.test(file)
|| /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
const sharedRust = ciChanged || files.some((file) => /^(?:Cargo\.(?:toml|lock)$|rust-toolchain|\.cargo\/|vendor\/)/.test(file) || /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
const affected = new Set();
let unknownRust = false;
for (const file of files) {
@@ -92,31 +90,34 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
expandAffectedPackages(packages, members, win7Affected);
if (full) for (const pkg of packages) affected.add(pkg.name);
expandAffectedPackages(packages, members, affected);
const win7InfrastructureChanged = unknownDiff || files.some((file) => win7InfrastructureFiles.has(file)
|| win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix)));
const win7DependencyChanged = files.includes("Cargo.toml") || files.includes("Cargo.lock")
|| files.some((file) => /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
const win7InfrastructureChanged = unknownDiff || files.some((file) => win7InfrastructureFiles.has(file) || win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix)) || /^src-tauri\/tauri.*\.json$/.test(file));
const win7DependencyChanged = files.includes("Cargo.toml") || files.includes("Cargo.lock") || files.some((file) => /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
const windowsWin7Reasons = {
infrastructure: win7InfrastructureChanged,
dependency_input: win7DependencyChanged,
desktop_dependency: win7Affected.has("dbx"),
unknown_rust: unknownRust,
unknown_member: rust && unknownMember,
};
const windowsWin7Candidate = Object.values(windowsWin7Reasons).some(Boolean);
const rustMatrix = !rust ? [] : full ? [{ group: "workspace" }] : Object.entries(rustGroups)
.filter(([, names]) => names.some((name) => affected.has(name)))
.map(([group]) => ({ group }));
const rustMatrix = !rust
? []
: full
? [{ group: "workspace" }]
: Object.entries(rustGroups)
.filter(([, names]) => names.some((name) => affected.has(name)))
.map(([group]) => ({ group }));
const nativeDrivers = [...goAgents.map((agent) => agent.driver), ...rustAgents];
const jdbcDrivers = javaDrivers ?? knownJavaDrivers(root, nativeDrivers);
const nativeChanges = new Set(nativeDrivers.filter((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`))));
const javaDriverChanges = jdbcDrivers.some((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`)));
// Driver-scoped JDBC/Go/Rust trees are not "shared Agent inputs". Unknown
// agents/drivers/<name>/ paths still fail open to full coverage below.
const sharedAgents = ciChanged || sharedRust || files.some((file) => file.startsWith("agents/")
&& !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`))
&& !jdbcDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`)))
|| files.some((file) => file.startsWith(".github/scripts/bump-agent-versions.") || file === ".github/workflows/agents-release.yml"
|| file === "crates/dbx-driver-agent/assets/agent-protocol-v2.json");
const sharedAgents =
ciChanged ||
sharedRust ||
files.some((file) => file.startsWith("agents/") && !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`)) && !jdbcDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`))) ||
files.some((file) => file.startsWith(".github/scripts/bump-agent-versions.") || file === ".github/workflows/agents-release.yml" || file === "crates/dbx-driver-agent/assets/agent-protocol-v2.json");
const allAgents = sharedAgents || (agentsChanged && nativeChanges.size === 0 && !javaDriverChanges);
if (allAgents) {
for (const driver of nativeDrivers) nativeChanges.add(driver);
@@ -128,13 +129,24 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
const java = allAgents || javaDriverChanges;
const agents = agentsChanged || sharedAgents || nativeChanges.size > 0 || javaDriverChanges;
return {
rust, rust_full: full, rust_matrix: { include: rustMatrix }, rust_groups_known: !unknownMember,
contributor_snapshot_only: files.length > 0 && files.every((file) => file === "docs/data/contributors.json"),
rust,
rust_full: full,
rust_matrix: { include: rustMatrix },
rust_groups_known: !unknownMember,
affected_packages: [...affected].sort(),
agents, agent_java: java, agent_go: { include: goMatrix }, agent_rust: { include: nativeRustMatrix },
agent_integration: { include: liveMatrix }, agent_go_changed: goMatrix.length > 0,
agent_rust_changed: nativeRustMatrix.length > 0, agent_integration_changed: liveMatrix.length > 0,
agents,
agent_java: java,
agent_go: { include: goMatrix },
agent_rust: { include: nativeRustMatrix },
agent_integration: { include: liveMatrix },
agent_go_changed: goMatrix.length > 0,
agent_rust_changed: nativeRustMatrix.length > 0,
agent_integration_changed: liveMatrix.length > 0,
duckdb_changed: nativeChanges.has("duckdb"),
duckdb_windows: sharedRust || files.some((file) => file.startsWith("agents/drivers/duckdb/")),
windows_standard: windowsWin7Candidate,
windows_win7_bundle: win7InfrastructureChanged || win7DependencyChanged || unknownRust || (rust && unknownMember),
windows_win7_candidate: windowsWin7Candidate,
windows_win7_affected_packages: [...win7Affected].sort(),
windows_win7_reasons: windowsWin7Reasons,
@@ -146,20 +158,21 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar
const root = process.cwd();
const metadata = cargoMetadata(root);
const plan = planCi({
files: changedPaths(process.env.BASE_SHA, root), metadata, root, eventName: process.env.GITHUB_EVENT_NAME,
rustChanged: process.env.RUST_CHANGED === "true", agentsChanged: process.env.AGENTS_CHANGED === "true",
files: changedPaths(process.env.BASE_SHA, root),
metadata,
root,
eventName: process.env.GITHUB_EVENT_NAME,
rustChanged: process.env.RUST_CHANGED === "true",
agentsChanged: process.env.AGENTS_CHANGED === "true",
});
if (process.env.GITHUB_OUTPUT) {
const outputs = { ...plan, plan };
appendFileSync(process.env.GITHUB_OUTPUT, Object.entries(outputs).map(([name, value]) => `${name}=${JSON.stringify(value)}\n`).join(""));
appendFileSync(
process.env.GITHUB_OUTPUT,
Object.entries(outputs)
.map(([name, value]) => `${name}=${JSON.stringify(value)}\n`)
.join(""),
);
}
console.log(JSON.stringify({
...plan,
win7_routing_comparison: {
current: process.env.WIN7_CURRENT === "true",
candidate: plan.windows_win7_candidate,
affected_packages: plan.windows_win7_affected_packages,
reasons: plan.windows_win7_reasons,
},
}, null, 2));
console.log(JSON.stringify(plan, null, 2));
}
+171 -40
View File
@@ -12,11 +12,16 @@ import { assertCoverage, parseCoverage } from "./ci-rust-coverage.mjs";
const root = "/fixture";
const graph = {
"dbx-types": [], "dbx-platform": [], "dbx-formats": [], "dbx-sql-core": ["dbx-types"],
"dbx-sql-dialect": ["dbx-types"], "dbx-sql-data": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
"dbx-types": [],
"dbx-platform": [],
"dbx-formats": [],
"dbx-sql-core": ["dbx-types"],
"dbx-sql-dialect": ["dbx-types"],
"dbx-sql-data": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
"dbx-sql-schema": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
"dbx-sql": ["dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-sql-schema", "dbx-types"],
"dbx-ai-provider": ["dbx-platform"], "dbx-plugin-runtime": ["dbx-platform", "dbx-types"],
"dbx-ai-provider": ["dbx-platform"],
"dbx-plugin-runtime": ["dbx-platform", "dbx-types"],
"dbx-driver-support": ["dbx-platform", "dbx-types"],
"dbx-driver-agent": ["dbx-platform", "dbx-sql-core", "dbx-types"],
"dbx-driver-elasticsearch": ["dbx-driver-support", "dbx-types"],
@@ -25,16 +30,22 @@ const graph = {
"dbx-driver-postgres": ["dbx-driver-support", "dbx-sql-core", "dbx-types"],
"dbx-driver-redis": ["dbx-driver-support", "dbx-types"],
"dbx-driver-sqlserver": ["dbx-driver-support", "dbx-sql-core", "dbx-sql-data", "dbx-types"],
"dbx-sqlite-worker": [], "dbx-drivers": ["dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb",
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-driver-support",
"dbx-platform", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-types", "dbx-sqlite-worker"],
"dbx-sqlite-worker": [],
"dbx-drivers": ["dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb", "dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-driver-support", "dbx-platform", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-types", "dbx-sqlite-worker"],
"dbx-core": ["dbx-drivers", "dbx-sql", "dbx-types", "dbx-platform", "dbx-ai-provider", "dbx-plugin-runtime", "dbx-formats"],
"dbx-mcp": ["dbx-core"], "dbx-cli": ["dbx-core", "dbx-mcp"], "dbx-web": ["dbx-core", "dbx-mcp"], "dbx": ["dbx-core", "dbx-mcp"],
"dbx-mcp": ["dbx-core"],
"dbx-cli": ["dbx-core", "dbx-mcp"],
"dbx-web": ["dbx-core", "dbx-mcp"],
dbx: ["dbx-core", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"],
"dbx-tauri-consul": ["dbx-core"],
"dbx-tauri-schema": ["dbx-core"],
};
const metadata = {
workspace_members: Object.keys(graph),
packages: Object.entries(graph).map(([name, dependencies]) => ({
id: name, name, manifest_path: `${root}/${name === "dbx" ? "src-tauri" : `crates/${name}`}/Cargo.toml`,
id: name,
name,
manifest_path: `${root}/${name === "dbx" ? "src-tauri" : `crates/${name}`}/Cargo.toml`,
dependencies: dependencies.map((dependency) => ({ name: dependency })),
})),
};
@@ -52,11 +63,26 @@ test("the planner uses the runner stable Cargo only for metadata", () => {
assert.deepEqual(invocation.args, ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"]);
assert.equal(invocation.options.cwd, "/workspace");
});
// Keep the gate fixtures in sync with ci-gate.mjs routedJobs. Both Windows jobs
// share the windows_win7_bundle routing output.
const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts",
"windows-standard-check": "windows_win7_bundle", "windows-win7-bundle": "windows_win7_bundle",
"duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" };
const routedJobs = {
frontend: "frontend",
packages: "packages",
"github-scripts": "github_scripts",
"windows-standard-check": "windows_standard",
"windows-win7-bundle": "windows_win7_bundle",
"duckdb-windows-driver": "duckdb_windows",
jdbc: "jdbc",
"offline-jdbc-release": "offline_jdbc",
"nix-packaging": "nix",
};
test("only an exact contributor snapshot diff skips desktop frontend validation", () => {
for (const eventName of ["pull_request", "push"]) {
assert.equal(plan(["docs/data/contributors.json"], { eventName }).contributor_snapshot_only, true);
for (const files of [null, [], ["docs/lib/contributorActivity.test.ts"], ["docs/data/contributors.json", "apps/desktop/src/main.ts"], ["docs/data/contributors.json", "docs/lib/contributorActivity.ts"], ["docs/data/contributors.json", "Cargo.lock"]]) {
assert.equal(plan(files, { eventName }).contributor_snapshot_only, false);
}
}
});
test("foundation changes select transitive consumers and standalone DuckDB", () => {
const result = plan(["crates/dbx-types/src/lib.rs"]);
@@ -85,13 +111,30 @@ test("Win7 candidate follows the desktop package dependency graph", () => {
assert.equal(plan(["crates/dbx-cli/src/main.rs"]).windows_win7_candidate, false);
});
test("old Win7 Rust paths remain candidates through the dependency graph", () => {
test("ordinary desktop Rust changes keep Windows compilation without full Win7 packaging", () => {
for (const file of [
"src-tauri/src/commands/update.rs",
"crates/dbx-core/src/host/update.rs",
"crates/dbx-core/src/query/mod.rs",
"crates/dbx-core/tests/runtime_diagnostic_history.rs",
"crates/dbx-drivers/src/lib.rs",
"crates/dbx-driver-postgres/src/postgres.rs",
"crates/dbx-platform/src/lib.rs",
"crates/dbx-driver-mysql/tests/query.rs",
"src-tauri/src/commands/query.rs",
"crates/dbx-tauri-consul/src/lib.rs",
"crates/dbx-tauri-schema/src/lib.rs",
]) {
const result = plan([file]);
assert.equal(result.windows_standard, true, file);
assert.equal(result.windows_win7_bundle, false, file);
}
for (const file of ["crates/dbx-cli/src/main.rs", "crates/dbx-web/src/routes/transfer.rs", "docs/README.md"]) {
const result = plan([file]);
assert.equal(result.windows_standard, false, file);
assert.equal(result.windows_win7_bundle, false, file);
}
});
test("old Win7 Rust paths remain candidates through the dependency graph", () => {
for (const file of ["src-tauri/src/commands/update.rs", "crates/dbx-core/src/host/update.rs", "crates/dbx-driver-postgres/src/postgres.rs", "crates/dbx-platform/src/lib.rs"]) {
assert.equal(plan([file]).windows_win7_candidate, true, file);
}
});
@@ -114,20 +157,45 @@ test("Win7 infrastructure changes remain candidates outside the Cargo graph", ()
"vendor/ctor/src/lib.rs",
"vendor/dirs-sys/src/lib.rs",
"vendor/pageant/src/lib.rs",
".cargo/config.toml",
"rust-toolchain.toml",
".github/fixtures/win7-imports/dll.json",
"src-tauri/tauri.conf.json",
"src-tauri/tauri.windows.conf.json",
"src-tauri/src/commands/update.rs",
"crates/dbx-core/src/host/update.rs",
"crates/dbx-platform/src/lib.rs",
]) {
assert.equal(plan([file]).windows_win7_candidate, true, file);
const result = plan([file]);
assert.equal(result.windows_win7_candidate, true, file);
assert.equal(result.windows_standard, true, file);
assert.equal(result.windows_win7_bundle, true, file);
}
});
test("Win7 dependency inputs fail open", () => {
for (const file of ["Cargo.toml", "Cargo.lock", "src-tauri/Cargo.toml", "crates/dbx-cli/Cargo.toml"]) {
assert.equal(plan([file]).windows_win7_candidate, true, file);
assert.equal(plan([file]).windows_standard, true, file);
assert.equal(plan([file]).windows_win7_bundle, true, file);
}
});
test("unknown Rust changes and unknown diffs remain Win7 candidates", () => {
assert.equal(plan(["crates/new-engine/src/lib.rs"]).windows_win7_candidate, true);
assert.equal(plan(null).windows_win7_candidate, true);
for (const files of [["crates/new-engine/src/lib.rs"], null]) {
const result = plan(files);
assert.equal(result.windows_win7_candidate, true);
assert.equal(result.windows_standard, true);
assert.equal(result.windows_win7_bundle, true);
}
const fixture = structuredClone(metadata);
fixture.workspace_members.push("new-engine");
fixture.packages.push({ id: "new-engine", name: "new-engine", manifest_path: `${root}/crates/new-engine/Cargo.toml`, dependencies: [] });
const result = plan(["crates/dbx-web/src/lib.rs"], { metadata: fixture });
assert.equal(result.windows_standard, true);
assert.equal(result.windows_win7_bundle, true);
assert.equal(result.windows_win7_reasons.unknown_member, true);
assert.equal(plan(["docs/README.md"], { metadata: fixture }).windows_win7_bundle, false);
});
test("unrelated CI inputs do not become Win7 candidates through full Rust coverage", () => {
@@ -152,6 +220,7 @@ test("Win7 candidate reports each routing reason", () => {
dependency_input: false,
desktop_dependency: false,
unknown_rust: false,
unknown_member: false,
});
});
@@ -163,8 +232,7 @@ test("code generation and test-only dependencies participate in impact analysis"
assert.ok(plan(["crates/dbx-formats/src/lib.rs"], { metadata: fixture }).affected_packages.includes("dbx-types"));
});
for (const file of ["Cargo.toml", "Cargo.lock", ".cargo/config.toml", "rust-toolchain.toml", "vendor/wry/src/lib.rs",
"crates/dbx-sql/Cargo.toml", ".github/workflows/ci.yml", ".github/scripts/ci-plan.mjs", ".github/scripts/ci-gate.test.mjs"]) {
for (const file of ["Cargo.toml", "Cargo.lock", ".cargo/config.toml", "rust-toolchain.toml", "vendor/wry/src/lib.rs", "crates/dbx-sql/Cargo.toml", ".github/workflows/ci.yml", ".github/scripts/ci-plan.mjs", ".github/scripts/ci-gate.test.mjs"]) {
test(`shared input ${file} retains full workspace and Agent coverage`, () => {
const result = plan([file]);
assert.deepEqual(groups(result), ["workspace"]);
@@ -223,8 +291,7 @@ test("known JDBC driver changes only select Java agent tests", () => {
});
test("shared Agent inputs and unknown native modules never silently lose coverage", () => {
for (const file of ["agents/common/src/main/java/Protocol.java", "agents/scripts/validate_agents.py", "agents/build.gradle",
"agents/drivers/new-driver/main.go", "crates/dbx-driver-agent/assets/agent-protocol-v2.json", ".github/workflows/agents-release.yml"]) {
for (const file of ["agents/common/src/main/java/Protocol.java", "agents/scripts/validate_agents.py", "agents/build.gradle", "agents/drivers/new-driver/main.go", "crates/dbx-driver-agent/assets/agent-protocol-v2.json", ".github/workflows/agents-release.yml"]) {
const result = plan([file]);
assert.equal(result.agent_go.include.length, goAgents.length, file);
assert.equal(result.agent_integration.include.length, 16, file);
@@ -237,13 +304,32 @@ test("shared Agent inputs and unknown native modules never silently lose coverag
});
test("the independent live matrix preserves every old version and authentication case", () => {
assert.deepEqual(integrationCases.map((entry) => `${entry.scenario}:${entry.version}`), [
"zookeeper:3.4.14", "zookeeper:3.5.5", "zookeeper:3.7.0", "zookeeper:3.9.5", "zookeeper-sasl:3.7.0",
"rocketmq:4.9.8", "rocketmq:5.3.1", "tdengine:2.4.0.14", "tdengine:2.6.0.34", "tdengine:3.0.7.1",
"tdengine:3.3.6.13", "tdengine:3.4.2.2", "cassandra:3.11.19", "cassandra:5.0.6", "rabbitmq:3.13", "rabbitmq:4.3",
]);
assert.deepEqual(
integrationCases.map((entry) => `${entry.scenario}:${entry.version}`),
[
"zookeeper:3.4.14",
"zookeeper:3.5.5",
"zookeeper:3.7.0",
"zookeeper:3.9.5",
"zookeeper-sasl:3.7.0",
"rocketmq:4.9.8",
"rocketmq:5.3.1",
"tdengine:2.4.0.14",
"tdengine:2.6.0.34",
"tdengine:3.0.7.1",
"tdengine:3.3.6.13",
"tdengine:3.4.2.2",
"cassandra:3.11.19",
"cassandra:5.0.6",
"rabbitmq:3.13",
"rabbitmq:4.3",
],
);
assert.equal(integrationCases.find((entry) => entry.version === "3.4.2.2").image, "tdengine/tsdb:3.4.2.2");
assert.deepEqual(goAgents.filter((entry) => entry.race).map((entry) => entry.driver), ["rocketmq", "zookeeper"]);
assert.deepEqual(
goAgents.filter((entry) => entry.race).map((entry) => entry.driver),
["rocketmq", "zookeeper"],
);
});
test("test groups partition every current workspace package exactly once", () => {
@@ -252,6 +338,21 @@ test("test groups partition every current workspace package exactly once", () =>
assert.deepEqual(selected.toSorted(), Object.keys(graph).toSorted());
});
test("Rust groups cover the actual workspace metadata, including extracted Tauri crates", () => {
const repository = path.resolve(import.meta.dirname, "../..");
const actual = cargoMetadata(repository);
const members = actual.packages.filter((pkg) => actual.workspace_members.includes(pkg.id)).map((pkg) => pkg.name);
assert.deepEqual(Object.values(rustGroups).flat().toSorted(), members.toSorted());
const result = planCi({ files: ["crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs"], metadata: actual, root: repository });
assert.equal(result.rust_groups_known, true);
assert.equal(result.rust_full, false);
assert.deepEqual(groups(result), ["application"]);
assert.deepEqual(result.agent_rust.include, [{ driver: "duckdb" }]);
assert.equal(result.agent_java, false);
assert.equal(result.agent_go.include.length, 0);
assert.equal(result.agent_integration.include.length, 0);
});
test("workspace Cargo flags preserve full versus fast coverage and strict clippy", () => {
for (const mode of ["fast", "full"]) {
const command = rustCommand("test", "workspace", mode);
@@ -293,13 +394,17 @@ test("nextest, doctests and coverage select identical packages and features in e
function results(files) {
const selected = plan(files);
return { changes: { result: "success", outputs: { plan: JSON.stringify(selected) } },
return {
changes: { result: "success", outputs: { plan: JSON.stringify(selected) } },
"fast-checks": { result: selected.fast ? "success" : "skipped" },
"rust-fmt-clippy": { result: selected.rust ? "success" : "skipped" }, "rust-test": { result: selected.rust ? "success" : "skipped" },
"agent-checks": { result: selected.agents ? "success" : "skipped" }, "agent-java": { result: selected.agent_java ? "success" : "skipped" },
"rust-fmt-clippy": { result: selected.rust ? "success" : "skipped" },
"rust-test": { result: selected.rust ? "success" : "skipped" },
"agent-checks": { result: selected.agents ? "success" : "skipped" },
"agent-java": { result: selected.agent_java ? "success" : "skipped" },
"agent-go": { result: selected.agent_go_changed ? "success" : "skipped" },
"agent-rust": { result: selected.agent_rust_changed ? "success" : "skipped" },
"agent-integration": { result: selected.agent_integration_changed ? "success" : "skipped" } };
"agent-integration": { result: selected.agent_integration_changed ? "success" : "skipped" },
};
}
test("gates accept only successful selected jobs and intentionally skipped unselected jobs", () => {
@@ -342,17 +447,23 @@ test("git diff routing includes both sides of renames, deleted files, and unusua
const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-plan-"));
const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8", stdio: "pipe" }).trim();
try {
git("init"); git("config", "user.email", "fixture@example.invalid"); git("config", "user.name", "CI Fixture");
git("init");
git("config", "user.email", "fixture@example.invalid");
git("config", "user.name", "CI Fixture");
writeFileSync(path.join(directory, "old.rs"), "unchanged source\n");
writeFileSync(path.join(directory, "deleted.rs"), "gone\n");
git("add", "."); git("-c", "core.hooksPath=/dev/null", "commit", "-m", "fixture baseline");
git("add", ".");
git("-c", "core.hooksPath=/dev/null", "commit", "-m", "fixture baseline");
const base = git("rev-parse", "HEAD");
git("mv", "old.rs", "renamed\nfile.rs"); git("rm", "deleted.rs");
git("mv", "old.rs", "renamed\nfile.rs");
git("rm", "deleted.rs");
git("-c", "core.hooksPath=/dev/null", "commit", "-m", "fixture change");
assert.deepEqual(changedPaths(base, directory).toSorted(), ["old.rs", "deleted.rs", "renamed\nfile.rs"].toSorted());
assert.equal(changedPaths("0".repeat(40), directory), null);
assert.throws(() => changedPaths("not-a-ref", directory));
} finally { rmSync(directory, { recursive: true, force: true }); }
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("the final gate rejects absent routing outputs and skipped selected jobs", () => {
@@ -382,6 +493,7 @@ test("the final gate requires both Windows jobs when the bundle routing is selec
needs.changes.outputs[output] = "false";
}
needs.changes.outputs.windows_win7_bundle = "true";
needs.changes.outputs.windows_standard = "true";
needs["windows-standard-check"].result = "success";
needs["windows-win7-bundle"].result = "success";
assert.deepEqual(gateFailures(needs, "all"), []);
@@ -393,9 +505,28 @@ test("the final gate requires both Windows jobs when the bundle routing is selec
assert.ok(gateFailures(needs, "all").length);
});
test("the final gate independently requires standard Windows compilation without Win7 packaging", () => {
const needs = results(["crates/dbx-core/src/query/mod.rs"]);
const selected = plan(["crates/dbx-core/src/query/mod.rs"]);
needs.rust = needs.agents = { result: "success" };
for (const [job, output] of Object.entries(routedJobs)) {
needs.changes.outputs[output] = String(selected[output] ?? false);
needs[job] = { result: selected[output] ? "success" : "skipped" };
}
assert.equal(needs.changes.outputs.windows_standard, "true");
assert.equal(needs.changes.outputs.windows_win7_bundle, "false");
assert.deepEqual(gateFailures(needs, "all"), []);
for (const result of ["failure", "skipped", "cancelled"]) {
needs["windows-standard-check"].result = result;
assert.ok(gateFailures(needs, "all").some((failure) => failure.includes("windows-standard-check")));
}
needs["windows-standard-check"].result = "success";
delete needs.changes.outputs.windows_standard;
assert.ok(gateFailures(needs, "all").length);
});
test("coverage audit rejects lost features, omitted packages, and overlapping group selection", () => {
const coverage = { workspace: Object.fromEntries(Object.keys(graph).map((name) => [name, ["fixture"]])),
...Object.fromEntries(Object.entries(rustGroups).map(([group, packages]) => [group, Object.fromEntries(packages.map((name) => [name, ["fixture"]]))])) };
const coverage = { workspace: Object.fromEntries(Object.keys(graph).map((name) => [name, ["fixture"]])), ...Object.fromEntries(Object.entries(rustGroups).map(([group, packages]) => [group, Object.fromEntries(packages.map((name) => [name, ["fixture"]]))])) };
assertCoverage(coverage);
const lost = structuredClone(coverage);
lost.foundation["dbx-types"] = [];
+11 -5
View File
@@ -6,11 +6,17 @@ import { rustGroups } from "./ci-config.mjs";
import { rustCommand } from "./ci-rust.mjs";
export function parseCoverage(output) {
return Object.fromEntries(output.trim().split("\n").filter(Boolean).map((line) => {
const [name, features] = line.split("|");
assert.ok(features !== undefined, `Invalid Cargo tree output: ${line}`);
return [name.split(" ")[0], features.split(",").filter(Boolean)];
}));
return Object.fromEntries(
output
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [name, features] = line.split("|");
assert.ok(features !== undefined, `Invalid Cargo tree output: ${line}`);
return [name.split(" ")[0], features.split(",").filter(Boolean)];
}),
);
}
export function assertCoverage(coverage) {
+30 -13
View File
@@ -4,26 +4,43 @@ import { pathToFileURL } from "node:url";
import { rustGroups } from "./ci-config.mjs";
export function rustCommand(action, group, mode) {
if (!["test", "doctest", "clippy", "tree"].includes(action) || !["full", "fast"].includes(mode)
|| (group !== "workspace" && !Object.hasOwn(rustGroups, group)) || (action === "clippy" && group !== "workspace")) {
if (!["test", "doctest", "clippy", "tree"].includes(action) || !["full", "fast"].includes(mode) || (group !== "workspace" && !Object.hasOwn(rustGroups, group)) || (action === "clippy" && group !== "workspace")) {
throw new Error(`Unknown Rust CI configuration: ${action}/${group}/${mode}`);
}
const capabilityFeatures = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher", "sqlserver-native-attention"];
if (mode === "full") capabilityFeatures.push("system-fonts");
const appFeatures = ["dbx", "dbx-core", "dbx-web"].flatMap((name) => capabilityFeatures.map((feature) => `${name}/${feature}`));
const foundationFeatures = ["dbx-types/mq-admin", "dbx-types/openapi", "dbx-sql/duckdb-sidecar", "dbx-sql/openapi",
"dbx-sql-data/openapi", "dbx-sql-schema/duckdb-sidecar",
"dbx-platform/downloads", "dbx-platform/host-prompts", "dbx-platform/test-support", "dbx-plugin-runtime/default", "dbx-plugin-runtime/test-support"];
const features = group === "foundation" ? foundationFeatures : group === "drivers" ? [
...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "sqlserver-native-attention", "test-support"].map((feature) => `dbx-drivers/${feature}`),
"dbx-driver-agent/test-support", "dbx-driver-mysql/test-support", "dbx-driver-redis/test-support",
"dbx-driver-support/test-support",
"dbx-sqlite-worker/runtime", "dbx-types/openapi", "dbx-sql-data/openapi",
] : appFeatures;
const foundationFeatures = [
"dbx-types/mq-admin",
"dbx-types/openapi",
"dbx-sql/duckdb-sidecar",
"dbx-sql/openapi",
"dbx-sql-data/openapi",
"dbx-sql-schema/duckdb-sidecar",
"dbx-platform/downloads",
"dbx-platform/host-prompts",
"dbx-platform/test-support",
"dbx-plugin-runtime/default",
"dbx-plugin-runtime/test-support",
];
const features =
group === "foundation"
? foundationFeatures
: group === "drivers"
? [
...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "sqlserver-native-attention", "test-support"].map((feature) => `dbx-drivers/${feature}`),
"dbx-driver-agent/test-support",
"dbx-driver-mysql/test-support",
"dbx-driver-redis/test-support",
"dbx-driver-support/test-support",
"dbx-sqlite-worker/runtime",
"dbx-types/openapi",
"dbx-sql-data/openapi",
]
: appFeatures;
const packages = group === "workspace" ? ["--workspace"] : rustGroups[group].flatMap((name) => ["--package", name]);
const command = action === "test" ? ["nextest", "run", "--no-fail-fast"] : action === "doctest" ? ["test", "--doc"] : [action];
return [...command, ...packages, "--locked", ...(["test", "clippy"].includes(action) ? ["--timings"] : []), ...(action === "clippy" ? ["--all-targets"] : []),
"--no-default-features", "--features", features.join(","), ...(action === "clippy" ? ["--", "-D", "warnings"] : [])];
return [...command, ...packages, "--locked", ...(["test", "clippy"].includes(action) ? ["--timings"] : []), ...(action === "clippy" ? ["--all-targets"] : []), "--no-default-features", "--features", features.join(","), ...(action === "clippy" ? ["--", "-D", "warnings"] : [])];
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
@@ -29,10 +29,13 @@ test("file timing reports sort total work and keep timing phases", () => {
}),
]);
assert.deepEqual(rows.map(({ file, totalMs, testsMs, collectMs, setupMs }) => ({ file, totalMs, testsMs, collectMs, setupMs })), [
{ file: "slow.spec.ts", totalMs: 200, testsMs: 60, collectMs: 50, setupMs: 90 },
{ file: "fast.spec.ts", totalMs: 100, testsMs: 100, collectMs: 0, setupMs: 0 },
]);
assert.deepEqual(
rows.map(({ file, totalMs, testsMs, collectMs, setupMs }) => ({ file, totalMs, testsMs, collectMs, setupMs })),
[
{ file: "slow.spec.ts", totalMs: 200, testsMs: 60, collectMs: 50, setupMs: 90 },
{ file: "fast.spec.ts", totalMs: 100, testsMs: 100, collectMs: 0, setupMs: 0 },
],
);
assert.match(consoleReport(rows, 1), /0\.20s slow\.spec\.ts/);
assert.doesNotMatch(consoleReport(rows, 1), /fast\.spec\.ts/);
assert.match(markdownReport(rows, "1\/2"), /Vitest file timings \(1\/2\)/);
+119 -27
View File
@@ -15,6 +15,65 @@ function job(name, content = workflow) {
return jobs.slice(definitions[index].index, definitions[index + 1]?.index ?? jobs.length);
}
function concurrencySettings(github) {
const block = workflow.match(/^concurrency:\n((?: [^\n]+\n)+)/m)?.[1];
assert.ok(block, "missing workflow concurrency");
const format = (template, ...values) => template.replace(/\{(\d+)\}/g, (_, index) => String(values[Number(index)]));
const endsWith = (search, value) => String(search).toLowerCase().endsWith(String(value).toLowerCase());
return Object.fromEntries(
block
.trim()
.split("\n")
.map((line) => {
const [, setting, value] = line.match(/^\s*([\w-]+):\s*(.+)$/);
const evaluate = (expression) => new Function("github", "format", "endsWith", `return (${expression});`)(github, format, endsWith);
const expressions = [...value.matchAll(/\$\{\{\s*(.*?)\s*\}\}/g)];
return [setting, expressions.length === 1 && expressions[0][0] === value ? evaluate(expressions[0][1]) : value.replace(/\$\{\{\s*(.*?)\s*\}\}/g, (_, expression) => evaluate(expression))];
}),
);
}
function concurrencyContext({ authorId = 101, author = "contributor", actor = author, number = 100, ref = `refs/pull/${number}/merge`, eventName = "pull_request" } = {}) {
return { workflow: "CI", repository_owner: "t8y2", actor, ref, event_name: eventName, event: eventName === "pull_request" ? { pull_request: { number, user: { id: authorId, login: author } } } : {} };
}
test("a contributor's PRs share two main CI queues split by PR number parity, regardless of trigger actor", () => {
const even = concurrencySettings(concurrencyContext());
assert.deepEqual(even, { group: "CI-pr-author-101-even", "cancel-in-progress": false, queue: "max" });
for (const context of [concurrencyContext({ number: 202 }), concurrencyContext({ actor: "t8y2" }), concurrencyContext({ actor: "github-actions[bot]" }), concurrencyContext({ author: "renamed-contributor" })]) {
assert.deepEqual(concurrencySettings(context), even);
}
for (const number of [101, 11849, 11853, 7]) {
assert.deepEqual(concurrencySettings(concurrencyContext({ number })), { ...even, group: "CI-pr-author-101-odd" }, String(number));
}
for (const number of [2, 4, 6, 8, 11850]) assert.equal(concurrencySettings(concurrencyContext({ number })).group, even.group);
assert.notEqual(concurrencySettings(concurrencyContext({ authorId: 102, author: "another-contributor" })).group, even.group);
});
test("repository-owner PRs remain independent and superseded runs are cancelled only within a PR", () => {
const first = concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2" }));
const second = concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2", ref: "refs/pull/200/merge" }));
assert.deepEqual(first, { group: "CI-refs/pull/100/merge", "cancel-in-progress": true, queue: "single" });
assert.notEqual(first.group, second.group);
assert.deepEqual(concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2", actor: "another-contributor" })), first);
});
test("main pushes retain their independent cancellation group without PR context", () => {
const main = concurrencySettings(concurrencyContext({ eventName: "push", ref: "refs/heads/main", actor: "github-actions[bot]" }));
assert.deepEqual(main, { group: "CI-refs/heads/main", "cancel-in-progress": true, queue: "single" });
assert.notEqual(main.group, concurrencySettings(concurrencyContext()).group);
});
test("expanded author queues never combine max queuing with in-progress cancellation", () => {
for (const context of [concurrencyContext(), concurrencyContext({ author: "t8y2" }), concurrencyContext({ eventName: "push", ref: "refs/heads/main" })]) {
const settings = concurrencySettings(context);
assert.ok(settings.queue === "single" || settings.queue === "max");
assert.equal(settings.queue === "max" && settings["cancel-in-progress"], false);
}
const concurrency = workflow.match(/^concurrency:\n((?: [^\n]+\n)+)/m)[1];
assert.doesNotMatch(concurrency, /github\.actor|author_association/);
});
test("fast checks run format and contracts before graph resolution or compilation", () => {
const fast = job("fast-checks");
for (const command of ["cargo fmt --check", "node --test scripts/core-architecture.test.mjs", "node scripts/sync-connection-types.mjs --check", "node .github/scripts/ci-lockfiles.mjs", "node .github/scripts/ci-rust-coverage.mjs"]) assert.ok(fast.includes(command));
@@ -23,12 +82,31 @@ test("fast checks run format and contracts before graph resolution or compilatio
assert.ok(fast.includes("needs.changes.outputs.rust_groups_known == 'true'"));
});
test("fast checks retain ownership of the planner subset before expensive jobs", () => {
assert.match(job("github-scripts"), /FAST_CHECKS: \$\{\{ needs\.changes\.outputs\.fast \}\}/);
assert.match(job("github-scripts"), /for file in \.github\/scripts\/\*\.test\.mjs/);
assert.match(job("fast-checks"), /name: CI planner and gate tests\s+run: node --test \.github\/scripts\/ci-\*\.test\.mjs/);
});
test("contributor snapshot routing preserves docs deployment and mixed frontend validation", () => {
assert.ok(job("changes").includes("frontend: ${{ steps.filter.outputs.frontend == 'true' && steps.plan.outputs.contributor_snapshot_only != 'true' }}"));
assert.match(job("changes"), /frontend:\s+- 'apps\/desktop\/\*\*'\s+- 'docs\/\*\*'/);
const docsWorkflow = readFileSync(new URL("../workflows/docs.yml", import.meta.url), "utf8");
assert.match(docsWorkflow, /paths: \["docs\/\*\*"\]/);
for (const file of ["contributors", "issue-claim", "notify", "plugin-dev-host", "spam-comment-guard"]) {
assert.ok(job("changes").includes(`- '.github/workflows/${file}.yml'`));
}
for (const file of ["contributor-snapshot", "update-contributors"]) {
assert.ok(job("changes").includes(`- 'docs/scripts/${file}.mjs'`));
}
});
test("Agent and Rust matrices are bounded and do not cancel sibling failures", () => {
for (const [name, output, parallel] of [
["rust-test", "rust_matrix", 3],
["agent-rust", "agent_rust", 2],
["agent-go", "agent_go", 8],
["agent-integration", "agent_integration", 8],
["agent-go", "agent_go", 4],
["agent-integration", "agent_integration", 4],
]) {
const content = job(name);
assert.match(content, /fail-fast: false/);
@@ -124,7 +202,11 @@ test("S3 and secretless Rust jobs export identical compiler wrappers", (context)
const setup = job(name).match(/- name: Configure sccache\n([\s\S]*?)(?=\n - name: Rust cache)/)?.[1];
assert.ok(setup, name);
assert.doesNotMatch(setup, /^\s+if:/m);
const script = setup.match(/ run: \|\n([\s\S]*)/)[1].split("\n").map((line) => line.slice(10)).join("\n");
const script = setup
.match(/ run: \|\n([\s\S]*)/)[1]
.split("\n")
.map((line) => line.slice(10))
.join("\n");
for (const remote of [true, false]) {
const output = path.join(directory, `${name}-${remote}.env`);
const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", script], {
@@ -143,16 +225,25 @@ test("S3 and secretless Rust jobs export identical compiler wrappers", (context)
});
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, "");
const exported = Object.fromEntries(readFileSync(output, "utf8").trim().split("\n").map((line) => {
const separator = line.indexOf("=");
return [line.slice(0, separator), line.slice(separator + 1)];
}));
assert.deepEqual(exported, {
...(remote ? backend : {}),
SCCACHE_IDLE_TIMEOUT: "0",
CC: `${compiler} cc`,
CXX: `${compiler} c++`,
}, `${name}: ${remote ? "S3" : "local"}`);
const exported = Object.fromEntries(
readFileSync(output, "utf8")
.trim()
.split("\n")
.map((line) => {
const separator = line.indexOf("=");
return [line.slice(0, separator), line.slice(separator + 1)];
}),
);
assert.deepEqual(
exported,
{
...(remote ? backend : {}),
SCCACHE_IDLE_TIMEOUT: "0",
CC: `${compiler} cc`,
CXX: `${compiler} c++`,
},
`${name}: ${remote ? "S3" : "local"}`,
);
}
}
});
@@ -188,7 +279,7 @@ test("Rust test jobs install pinned nextest and retain separate doctests", () =>
const pluginRelease = readFileSync(new URL("../workflows/plugin-cli-release.yml", import.meta.url), "utf8");
const consumers = [job("packages"), job("rust-test"), job("agent-rust"), job("agent-integration"), job("test", pluginDevHost), job("prepare", pluginRelease)];
for (const content of consumers) {
assert.ok(content.includes("uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3"));
assert.ok(content.includes("uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b"));
assert.ok(content.includes("tool: cargo-nextest@0.9.137"));
assert.doesNotMatch(content, /cargo test (?!.*--doc)/);
const testCommand = content.indexOf("cargo nextest run") >= 0 ? "cargo nextest run" : content.includes("pnpm test:packages") ? "pnpm test:packages" : content.includes("ci-rust.mjs test") ? "ci-rust.mjs test" : "ci-agent-integration.sh";
@@ -215,7 +306,8 @@ test("local Rust entrypoints and live TDengine tests use nextest", () => {
test("DuckDB Windows builds persist Rust and C++ compiler results", () => {
const content = job("duckdb-windows-driver");
assert.ok(content.includes('SCCACHE_GHA_ENABLED: "true"'));
// S3 is preferred over the quota-limited GHA backend; GHA remains the fallback.
assert.ok(content.includes("SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}"));
assert.ok(content.includes('CC: "sccache cl.exe"'));
assert.ok(content.includes('CXX: "sccache cl.exe"'));
assert.ok(content.includes("0b201ec74fa43914dc39ae48a89fd1d8cb592756"));
@@ -224,11 +316,10 @@ test("DuckDB Windows builds persist Rust and C++ compiler results", () => {
});
test("Windows compatibility jobs cache Rust compilation without wrapping C or C++", () => {
assert.ok(job("changes").includes("'vendor/webview2-com-sys/**'"));
const standard = job("windows-standard-check");
assert.ok(standard.includes("needs.changes.outputs.windows_win7_bundle == 'true'"));
assert.ok(standard.includes("needs.changes.outputs.windows_standard == 'true'"));
assert.ok(standard.includes("RUSTC_WRAPPER: sccache"));
assert.ok(standard.includes('SCCACHE_GHA_ENABLED: "true"'));
assert.ok(standard.includes("SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}"));
assert.ok(standard.includes("SCCACHE_GHA_VERSION: windows-standard-v1"));
assert.ok(standard.includes("fc920bf0ec8de6ee65d409111f7ec508035751ba"));
assert.ok(standard.includes('version: "v0.16.0"'));
@@ -236,8 +327,9 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+
assert.ok(standard.includes("sccache --show-stats"));
const win7 = job("windows-win7-bundle");
assert.ok(win7.includes("needs.changes.outputs.windows_win7_bundle == 'true'"));
assert.doesNotMatch(win7, /x86_64-pc-windows-msvc|Setup Rust for standard Windows/);
for (const setting of ["RUSTC_WRAPPER: sccache", 'SCCACHE_GHA_ENABLED: "true"', "SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1", 'SCCACHE_IDLE_TIMEOUT: "0"', 'CARGO_PROFILE_RELEASE_LTO: "off"', 'CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"']) assert.ok(win7.includes(setting));
for (const setting of ["RUSTC_WRAPPER: sccache", "SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}", "SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1", 'SCCACHE_IDLE_TIMEOUT: "0"', 'CARGO_PROFILE_RELEASE_LTO: "off"', 'CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"']) assert.ok(win7.includes(setting));
assert.ok(win7.includes("fc920bf0ec8de6ee65d409111f7ec508035751ba"));
assert.ok(win7.includes('version: "v0.16.0"'));
assert.ok(win7.includes("sccache --show-stats"));
@@ -245,7 +337,7 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+
assert.ok(win7.includes("--config .github/fixtures/win7-host-repro-config.toml"));
const hostConfig = readFileSync(new URL("../fixtures/win7-host-repro-config.toml", import.meta.url), "utf8");
assert.ok(hostConfig.includes("target-applies-to-host = false"));
assert.ok(hostConfig.includes('[host.x86_64-pc-windows-msvc]'));
assert.ok(hostConfig.includes("[host.x86_64-pc-windows-msvc]"));
assert.ok(hostConfig.includes('rustflags = ["-Clink-arg=/Brepro"]'));
assert.ok(win7.includes("--timings"));
assert.ok(win7.includes("name: DBX-win7-cargo-timings"));
@@ -276,20 +368,20 @@ test("Win7 TLS cache keys ignore the workspace lockfile", () => {
assert.doesNotMatch(win7, /key: win7-(?:aws-lc|openssl).*hashFiles\('Cargo\.lock'/);
});
test("the planner uses the exact event base and preserves a single workflow cancellation scope", () => {
test("path filtering and the planner use the exact test merge first parent", () => {
const changes = job("changes");
assert.ok(changes.includes("github.event.pull_request.base.sha || github.event.before"));
assert.ok(changes.includes("BEFORE_SHA: ${{ github.event.before }}"));
assert.ok(changes.includes('BASE_SHA="$(node .github/scripts/ci-change-base.mjs)"'));
assert.ok(changes.includes("fetch-depth: 2"));
assert.ok(changes.includes('git fetch --no-tags --depth=1 origin "$BASE_SHA"'));
assert.ok(changes.includes("base: ${{ steps.change-base.outputs.sha }}"));
assert.match(changes, /with:\s+token: ''\s+base: \$\{\{ steps\.change-base\.outputs\.sha \}\}\s+ref: \$\{\{ github\.sha \}\}/);
assert.ok(changes.includes("BASE_SHA: ${{ steps.change-base.outputs.sha }}"));
assert.ok(changes.includes("node .github/scripts/ci-plan.mjs"));
assert.doesNotMatch(changes, /fetch-depth:\s*0/);
assert.doesNotMatch(changes, /dtolnay\/rust-toolchain/);
for (const flag of ["rust", "rust_full", "rust_matrix", "agents", "agent_go", "agent_rust", "agent_integration",
"windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) {
for (const flag of ["rust", "rust_full", "rust_matrix", "agents", "agent_go", "agent_rust", "agent_integration", "windows_standard", "windows_win7_bundle", "windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) {
assert.ok(changes.includes(`steps.plan.outputs.${flag}`));
}
assert.ok(changes.includes("WIN7_CURRENT: ${{ steps.filter.outputs.windows_win7_bundle }}"));
assert.ok(workflow.includes("group: ${{ github.workflow }}-${{ github.ref }}"));
assert.ok(workflow.includes("cancel-in-progress: true"));
assert.doesNotMatch(changes, /steps\.filter\.outputs\.windows_win7_bundle|github\.event\.pull_request\.base\.sha|WIN7_CURRENT/);
});
+1 -3
View File
@@ -33,9 +33,7 @@ function parseArgs(argv) {
}
if (!args.token) throw new Error("CNB_TOKEN is required.");
if (!args.currentTag || !args.tagPattern || !Number.isInteger(args.retain) || args.retain < 1) {
throw new Error(
"Usage: cleanup-cnb-releases.mjs --current-tag <tag> --tag-pattern <regex> --retain <count> [--apply]",
);
throw new Error("Usage: cleanup-cnb-releases.mjs --current-tag <tag> --tag-pattern <regex> --retain <count> [--apply]");
}
try {
new RegExp(args.tagPattern);
+5 -12
View File
@@ -17,18 +17,11 @@ const appReleases = [
];
test("retention only removes old releases from the selected family", () => {
const plan = planReleaseRetention(
[
release("agents-latest", "agents-latest", "2026-01-01T00:00:00Z"),
release("agent", "agents-v0.2.67", "2026-07-29T05:35:19Z"),
...appReleases.toReversed(),
],
{
currentTag: "v0.5.69",
tagPattern: "^v[0-9]+[.][0-9]+[.][0-9]+$",
retain: 5,
},
);
const plan = planReleaseRetention([release("agents-latest", "agents-latest", "2026-01-01T00:00:00Z"), release("agent", "agents-v0.2.67", "2026-07-29T05:35:19Z"), ...appReleases.toReversed()], {
currentTag: "v0.5.69",
tagPattern: "^v[0-9]+[.][0-9]+[.][0-9]+$",
retain: 5,
});
assert.equal(plan.skipped, "");
assert.deepEqual(
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Add Google's public Docker Hub cache without replacing daemon settings."""
import argparse
import json
import os
import shutil
import stat
import subprocess
import tempfile
from pathlib import Path
MIRROR = "https://mirror.gcr.io"
def configure(path, validate=None):
path = Path(path)
config = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
if not isinstance(config, dict):
raise ValueError("Docker daemon config must be a JSON object")
mirrors = config.get("registry-mirrors", [])
if not isinstance(mirrors, list) or any(not isinstance(item, str) for item in mirrors):
raise ValueError("registry-mirrors must be an array of strings")
if any(item.rstrip("/") == MIRROR for item in mirrors):
return
config["registry-mirrors"] = [*mirrors, MIRROR]
original_stat = path.stat() if path.exists() else None
candidate = None
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
prefix="daemon-mirror-", suffix=".json", delete=False) as output:
candidate = Path(output.name)
output.write(json.dumps(config, indent=2) + "\n")
candidate.chmod(stat.S_IMODE(original_stat.st_mode) if original_stat else 0o644)
if original_stat and hasattr(os, "chown"):
os.chown(candidate, original_stat.st_uid, original_stat.st_gid)
if validate:
validate(candidate)
if original_stat:
backup = path.with_name(path.name + ".before-dbx-mirror")
if not backup.exists():
descriptor = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
stat.S_IMODE(original_stat.st_mode))
with os.fdopen(descriptor, "wb") as output:
output.write(path.read_bytes())
shutil.copystat(path, backup)
if hasattr(os, "chown"):
os.chown(backup, original_stat.st_uid, original_stat.st_gid)
os.replace(candidate, path)
finally:
if candidate and candidate.exists():
candidate.unlink()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("config", type=Path)
parser.add_argument("--validate", action="store_true", help="Validate with dockerd before replacing config")
args = parser.parse_args()
def validate(candidate):
subprocess.run(["dockerd", "--validate", "--config-file", str(candidate)], check=True, timeout=15)
configure(args.config, validate if args.validate else None)
@@ -0,0 +1,115 @@
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
spec = importlib.util.spec_from_file_location(
"docker_mirror", Path(__file__).with_name("configure-docker-mirror.py")
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class DockerMirrorTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.path = Path(self.directory.name) / "daemon.json"
def test_missing_config(self):
module.configure(self.path)
self.assertEqual(json.loads(self.path.read_text()), {"registry-mirrors": [module.MIRROR]})
self.assertEqual(list(self.path.parent.iterdir()), [self.path])
def test_preserves_settings_and_existing_mirror_order(self):
original = {"log-driver": "json-file", "features": {"containerd-snapshotter": True},
"registry-mirrors": ["https://existing.example", "https://second.example/"]}
self.path.write_text(json.dumps(original))
module.configure(self.path)
expected = {**original, "registry-mirrors": [*original["registry-mirrors"], module.MIRROR]}
self.assertEqual(json.loads(self.path.read_text()), expected)
def test_second_run_does_not_write(self):
module.configure(self.path)
before = self.path.read_bytes()
modified = self.path.stat().st_mtime_ns
module.configure(self.path)
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(self.path.stat().st_mtime_ns, modified)
def test_existing_mirror_with_trailing_slash_does_not_write(self):
before = '{"registry-mirrors": ["https://mirror.gcr.io/"], "debug": false}\n'
self.path.write_text(before)
module.configure(self.path)
self.assertEqual(self.path.read_text(), before)
def test_invalid_json_fails_without_writing(self):
before = b'{"debug":'
self.path.write_bytes(before)
with self.assertRaises(json.JSONDecodeError):
module.configure(self.path)
self.assertEqual(self.path.read_bytes(), before)
def test_invalid_structure_fails_without_writing(self):
for value in [[], {"registry-mirrors": "https://existing.example"},
{"registry-mirrors": [None]}]:
with self.subTest(value=value):
before = json.dumps(value).encode()
self.path.write_bytes(before)
with self.assertRaises(ValueError):
module.configure(self.path)
self.assertEqual(self.path.read_bytes(), before)
def test_existing_file_permissions_preserved(self):
self.path.write_text('{"debug": false}')
self.path.chmod(0o600)
mode = self.path.stat().st_mode
module.configure(self.path)
self.assertEqual(self.path.stat().st_mode, mode)
def test_backup_preserves_original_bytes_and_mode(self):
before = b'{ "debug": false, "log-driver": "json-file" }\n'
self.path.write_bytes(before)
self.path.chmod(0o600)
mode = self.path.stat().st_mode
module.configure(self.path)
backup = self.path.with_name(self.path.name + ".before-dbx-mirror")
self.assertEqual(backup.read_bytes(), before)
self.assertEqual(backup.stat().st_mode, mode)
module.configure(self.path)
self.assertEqual(backup.read_bytes(), before)
def test_validator_failure_preserves_original_and_removes_candidate(self):
before = b'{"debug": false}\n'
self.path.write_bytes(before)
def reject(candidate):
self.assertEqual(json.loads(candidate.read_text())["registry-mirrors"], [module.MIRROR])
self.assertEqual(self.path.read_bytes(), before)
raise ValueError("daemon validation failed")
with self.assertRaisesRegex(ValueError, "daemon validation failed"):
module.configure(self.path, reject)
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(list(self.path.parent.iterdir()), [self.path])
def test_validation_precedes_atomic_replace(self):
before = b'{"debug": true}\n'
self.path.write_bytes(before)
candidates = []
def validate(candidate):
self.assertEqual(candidate.parent, self.path.parent)
self.assertEqual(self.path.read_bytes(), before)
self.assertTrue(json.loads(candidate.read_text())["debug"])
candidates.append(candidate)
module.configure(self.path, validate)
self.assertEqual(len(candidates), 1)
self.assertFalse(candidates[0].exists())
self.assertIn(module.MIRROR, json.loads(self.path.read_text())["registry-mirrors"])
if __name__ == "__main__":
unittest.main()
+5 -42
View File
@@ -1,36 +1,8 @@
import fs from "node:fs";
const extraAliases = {
mysql: [
"mariadb",
"percona",
"tidb",
"greatsql",
"oceanbase mysql",
"oceanbase mysql mode",
"oceanbase mysql 模式",
"tdsql mysql",
"tdsql-mysql",
"polardb mysql",
"polardb for mysql",
"polardb-x",
"polardbx",
],
postgres: [
"postgresql",
"postgres",
"pgsql",
"pg",
"hologres",
"cloudberry",
"apache cloudberry",
"cockroachdb",
"cockroach db",
"polardb postgresql",
"polardb for postgresql",
"polardb postgres",
"polardb for postgres",
],
mysql: ["mariadb", "percona", "tidb", "greatsql", "oceanbase mysql", "oceanbase mysql mode", "oceanbase mysql 模式", "tdsql mysql", "tdsql-mysql", "polardb mysql", "polardb for mysql", "polardb-x", "polardbx"],
postgres: ["postgresql", "postgres", "pgsql", "pg", "hologres", "cloudberry", "apache cloudberry", "cockroachdb", "cockroach db", "polardb postgresql", "polardb for postgresql", "polardb postgres", "polardb for postgres"],
sqlite: ["sqlite3", "sql lite"],
clickhouse: ["click house", "ch"],
sqlserver: ["sql server", "mssql", "microsoft sql server", "sqlservice"],
@@ -47,7 +19,7 @@ const extraAliases = {
saphana: ["hana", "sap hana"],
opengauss: ["open gauss"],
"oceanbase-oracle": ["oceanbase oracle", "oceanbase oracle mode", "oceanbase oracle 模式"],
gbase: ["gbase 8a", "gbase8a", "gbase 8s", "gbase8s"],
gbase: ["gbase 8a", "gbase8a", "gbase 8c", "gbase8c", "gbase 8s", "gbase8s"],
access: ["microsoft access", "ms access"],
vastbase: ["vastbase g", "vastbaseg"],
prestosql: ["presto", "presto sql"],
@@ -104,9 +76,7 @@ const catalogEntries = [
}),
...supplementalDrivers.filter((driver) => !manifestDbTypes.has(driver.dbType)),
];
const duplicateDbTypes = catalogEntries
.map((driver) => driver.dbType)
.filter((dbType, index, values) => values.indexOf(dbType) !== index);
const duplicateDbTypes = catalogEntries.map((driver) => driver.dbType).filter((dbType, index, values) => values.indexOf(dbType) !== index);
if (duplicateDbTypes.length > 0) {
throw new Error(`Duplicate database issue catalog entries: ${[...new Set(duplicateDbTypes)].join(", ")}`);
@@ -115,12 +85,5 @@ if (duplicateDbTypes.length > 0) {
export const databaseIssueDrivers = catalogEntries.map((driver) => ({
dbType: driver.dbType,
label: driver.label,
aliases: [
...new Set([
driver.dbType,
driver.label,
...(driver.aliases || []),
...(extraAliases[driver.dbType] || []),
]),
],
aliases: [...new Set([driver.dbType, driver.label, ...(driver.aliases || []), ...(extraAliases[driver.dbType] || [])])],
}));
+2 -1
View File
@@ -6,7 +6,7 @@ import { dirname, join, resolve } from "node:path";
const LOCALES_DIR = "apps/desktop/src/i18n/locales";
const SOURCE_LOCALE = "zh-CN";
const TARGET_LOCALES = ["en", "es", "it", "ja", "ko", "pt-BR", "ru", "zh-TW"];
const TARGET_LOCALES = ["en", "es", "it", "ja", "ko", "pt-BR", "ru", "th", "zh-TW"];
// `git show <ref>:<path>` needs forward slashes even on Windows, where
// `path.join()` yields backslashes. Allow an override (CI keeps the default).
const refPath = (path) => path.replaceAll("\\", "/");
@@ -18,6 +18,7 @@ const TARGET_LABELS = {
ko: "Korean",
"pt-BR": "Brazilian Portuguese",
ru: "Russian",
th: "Thai",
"zh-TW": "Traditional Chinese used in Taiwan",
};
+43 -18
View File
@@ -21,20 +21,32 @@ function fixture(context, platform = "darwin-arm64") {
const binary = join(source, "package/bin/dbx-mcp");
writeFileSync(binary, "#!/bin/sh\nprintf 'dbx-mcp 0.4.96\\n'\n", { mode: 0o755 });
copyFileSync(binary, join(source, "dbx-mcp"));
for (const [archive, member] of [["npm.tgz", "package/bin/dbx-mcp"], ["github.tar.gz", "dbx-mcp"]]) {
for (const [archive, member] of [
["npm.tgz", "package/bin/dbx-mcp"],
["github.tar.gz", "dbx-mcp"],
]) {
const result = spawnSync("tar", ["-czf", join(root, archive), "-C", source, member]);
assert.equal(result.status, 0, result.stderr.toString());
}
const integrity = "sha512-" + createHash("sha512").update(readFileSync(join(root, "npm.tgz"))).digest("base64");
const checksum = createHash("sha256").update(readFileSync(join(root, "github.tar.gz"))).digest("hex");
const integrity =
"sha512-" +
createHash("sha512")
.update(readFileSync(join(root, "npm.tgz")))
.digest("base64");
const checksum = createHash("sha256")
.update(readFileSync(join(root, "github.tar.gz")))
.digest("hex");
writeFileSync(join(root, "latest.json"), JSON.stringify({ description: 'ignore "version":"99.0.0"', version: "0.4.96" }));
writeFileSync(join(root, "metadata.json"), JSON.stringify({ name: "test", version: "0.4.96", dist: { integrity } }));
writeFileSync(join(root, "refs.json"), JSON.stringify([
{ ref: "refs/tags/packages-v0.4.9", object: {} },
{ ref: "refs/tags/packages-v0.4.96", object: {} },
{ ref: "refs/tags/packages-v1.0.0-beta.1", object: {} },
{ ref: "refs/tags/packages-v0.4.10", object: {} },
]));
writeFileSync(
join(root, "refs.json"),
JSON.stringify([
{ ref: "refs/tags/packages-v0.4.9", object: {} },
{ ref: "refs/tags/packages-v0.4.96", object: {} },
{ ref: "refs/tags/packages-v1.0.0-beta.1", object: {} },
{ ref: "refs/tags/packages-v0.4.10", object: {} },
]),
);
writeFileSync(join(root, "SHA256SUMS"), `${checksum} dbx-mcp-${platform}.tar.gz\n`);
const routes = {};
for (const registry of ["https://registry.npmjs.org", "https://registry.npmmirror.com"]) {
@@ -46,7 +58,9 @@ function fixture(context, platform = "darwin-arm64") {
routes["https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/SHA256SUMS"] = "SHA256SUMS";
routes[`https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/dbx-mcp-${platform}.tar.gz`] = "github.tar.gz";
writeFileSync(join(root, "routes.json"), JSON.stringify(routes));
writeFileSync(join(mocks, "curl"), `#!${process.execPath}
writeFileSync(
join(mocks, "curl"),
`#!${process.execPath}
const fs = require('node:fs');
const path = require('node:path');
const args = process.argv.slice(2);
@@ -57,9 +71,13 @@ if (process.env.FAIL_URL && new RegExp(process.env.FAIL_URL).test(url)) process.
const routes = JSON.parse(fs.readFileSync(path.join(root, 'routes.json')));
if (!routes[url]) process.exit(22);
fs.copyFileSync(path.join(root, routes[url]), args[args.indexOf('-o') + 1]);
`, { mode: 0o755 });
`,
{ mode: 0o755 },
);
writeFileSync(join(mocks, "uname"), `#!/bin/sh\ncase "$1" in -s) echo '${platform.startsWith("darwin") ? "Darwin" : "Linux"}';; -m) echo '${platform.includes("arm64") ? "aarch64" : "x86_64"}';; esac\n`, { mode: 0o755 });
writeFileSync(join(mocks, "codesign"), `#!${process.execPath}
writeFileSync(
join(mocks, "codesign"),
`#!${process.execPath}
const args = process.argv.slice(2);
const mode = process.env.SIGNATURE_MODE || 'valid';
const requirement = 'identifier "com.dbx.app.mcp" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "TVDM965TDL"';
@@ -73,13 +91,17 @@ console.error('Identifier=com.dbx.app.mcp');
console.error('TeamIdentifier=TVDM965TDL');
console.error('Timestamp=' + (mode === 'no-timestamp' ? 'none' : 'Oct 3, 2026'));
console.error('designated => ' + (mode === 'hash-bound' ? 'cdhash H"' + 'ab'.repeat(20) + '"' : requirement.replaceAll('exists', '/* exists */')));
`, { mode: 0o755 });
`,
{ mode: 0o755 },
);
const installedBinary = join(home, ".dbx/bin/dbx-mcp");
const marker = join(home, ".dbx/bin/.dbx-mcp-version");
const run = (extra = {}) => spawnSync("sh", [installer], {
env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, FIXTURE_PLATFORM: platform, ...extra },
encoding: "utf8", timeout: 30_000,
});
const run = (extra = {}) =>
spawnSync("sh", [installer], {
env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, FIXTURE_PLATFORM: platform, ...extra },
encoding: "utf8",
timeout: 30_000,
});
return { root, home, mocks, binary: installedBinary, marker, run };
}
@@ -92,7 +114,10 @@ for (const platform of platforms) {
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.96\n");
const json = JSON.parse(result.stdout.split("\n").find((line) => line.startsWith('{"mcpServers"')));
assert.equal(json.mcpServers.dbx.command, setup.binary);
const tomlCommand = result.stdout.split("\n").find((line) => line.startsWith("command = ")).slice(10);
const tomlCommand = result.stdout
.split("\n")
.find((line) => line.startsWith("command = "))
.slice(10);
assert.equal(JSON.parse(tomlCommand), setup.binary);
assert.deepEqual(readdirSync(join(setup.home, ".dbx/bin")).sort(), [".dbx-mcp-version", "dbx-mcp"]);
});
+9 -66
View File
@@ -63,13 +63,7 @@ export function detectIssueCommand(commentBody) {
export function findEligibleMergedPullRequest({ context, commentUser, repository }) {
return context.pullRequests
.filter(
(pullRequest) =>
pullRequest.state === "MERGED" &&
pullRequest.baseRefName === context.defaultBranch &&
pullRequest.repository?.nameWithOwner === repository &&
sameLogin(pullRequest.author?.login, commentUser),
)
.filter((pullRequest) => pullRequest.state === "MERGED" && pullRequest.baseRefName === context.defaultBranch && pullRequest.repository?.nameWithOwner === repository && sameLogin(pullRequest.author?.login, commentUser))
.sort((left, right) => (right.mergedAt || "").localeCompare(left.mergedAt || ""))[0];
}
@@ -92,10 +86,7 @@ async function handleUnclaim({ issueNumber, commentUser, client }) {
const assignee = context.assignees.find(({ login }) => sameLogin(login, commentUser));
if (!assignee) {
await client.commentIssue(
issueNumber,
`❌ @${commentUser} 你当前没有认领 #${issueNumber},无法使用 \`/unclaim\`。`,
);
await client.commentIssue(issueNumber, `❌ @${commentUser} 你当前没有认领 #${issueNumber},无法使用 \`/unclaim\`。`);
return { status: "not-assignee" };
}
@@ -116,28 +107,15 @@ async function handleClose({ issueNumber, commentUser, repository, client }) {
const pullRequest = findEligibleMergedPullRequest({ context, commentUser, repository });
if (!pullRequest) {
await client.commentIssue(
issueNumber,
`❌ @${commentUser} 暂不能关闭 #${issueNumber}:未找到由你提交、已合并到 \`${context.defaultBranch}\` 且关联此 Issue 的 PR。`,
);
await client.commentIssue(issueNumber, `❌ @${commentUser} 暂不能关闭 #${issueNumber}:未找到由你提交、已合并到 \`${context.defaultBranch}\` 且关联此 Issue 的 PR。`);
return { status: "no-merged-pull-request" };
}
await client.closeIssue(
issueNumber,
`✅ @${commentUser} 的关联 PR #${pullRequest.number} 已合并,关闭 #${issueNumber}。`,
);
await client.closeIssue(issueNumber, `✅ @${commentUser} 的关联 PR #${pullRequest.number} 已合并,关闭 #${issueNumber}。`);
return { status: "closed", pullRequest };
}
export async function handleIssueCommand({
issueNumber,
commentBody,
commentUser,
commentUserType,
repository,
client,
}) {
export async function handleIssueCommand({ issueNumber, commentBody, commentUser, commentUserType, repository, client }) {
const command = detectIssueCommand(commentBody);
if (!command) return { status: "ignored" };
if (commentUserType === "Bot") return { status: "ignored-bot" };
@@ -158,17 +136,7 @@ export function createGitHubClient({ repository, runGh = gh }) {
return {
async getIssueContext(issueNumber) {
return JSON.parse(
await runGh([
"issue",
"view",
String(issueNumber),
"--repo",
repository,
"--json",
"state,assignees",
]),
);
return JSON.parse(await runGh(["issue", "view", String(issueNumber), "--repo", repository, "--json", "state,assignees"]));
},
async getIssueCloseContext(issueNumber) {
@@ -176,18 +144,7 @@ export function createGitHubClient({ repository, runGh = gh }) {
let context;
do {
const args = [
"api",
"graphql",
"-f",
`owner=${owner}`,
"-f",
`name=${name}`,
"-F",
`number=${issueNumber}`,
"-f",
`query=${ISSUE_CLOSE_CONTEXT_QUERY}`,
];
const args = ["api", "graphql", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${issueNumber}`, "-f", `query=${ISSUE_CLOSE_CONTEXT_QUERY}`];
if (endCursor) args.push("-f", `endCursor=${endCursor}`);
const response = JSON.parse(await runGh(args));
@@ -205,11 +162,7 @@ export function createGitHubClient({ repository, runGh = gh }) {
if (!context.defaultBranch) throw new Error(`Default branch was not found for ${repository}`);
}
context.pullRequests.push(
...issue.timelineItems.nodes
.map((node) => node.source)
.filter((source) => source?.__typename === "PullRequest"),
);
context.pullRequests.push(...issue.timelineItems.nodes.map((node) => node.source).filter((source) => source?.__typename === "PullRequest"));
const { hasNextPage, endCursor: nextCursor } = issue.timelineItems.pageInfo;
endCursor = hasNextPage ? nextCursor : undefined;
@@ -231,17 +184,7 @@ export function createGitHubClient({ repository, runGh = gh }) {
},
async closeIssue(issueNumber, comment) {
await runGh([
"issue",
"close",
String(issueNumber),
"--repo",
repository,
"--reason",
"completed",
"--comment",
comment,
]);
await runGh(["issue", "close", String(issueNumber), "--repo", repository, "--reason", "completed", "--comment", comment]);
},
};
}
+8 -21
View File
@@ -1,12 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createGitHubClient,
detectIssueCommand,
findEligibleMergedPullRequest,
handleIssueCommand,
} from "./issue-commands.mjs";
import { createGitHubClient, detectIssueCommand, findEligibleMergedPullRequest, handleIssueCommand } from "./issue-commands.mjs";
const repository = "t8y2/dbx";
@@ -117,13 +112,7 @@ test("findEligibleMergedPullRequest requires the assignee's merged PR on the def
repository: { nameWithOwner: repository },
};
const context = closeContext({
pullRequests: [
{ ...eligible, number: 101, state: "OPEN" },
{ ...eligible, number: 102, baseRefName: "release" },
{ ...eligible, number: 103, author: { login: "someone-else" } },
{ ...eligible, number: 104, repository: { nameWithOwner: "someone/dbx" } },
eligible,
],
pullRequests: [{ ...eligible, number: 101, state: "OPEN" }, { ...eligible, number: 102, baseRefName: "release" }, { ...eligible, number: 103, author: { login: "someone-else" } }, { ...eligible, number: 104, repository: { nameWithOwner: "someone/dbx" } }, eligible],
});
assert.equal(findEligibleMergedPullRequest({ context, commentUser: "contributor", repository }), eligible);
@@ -175,9 +164,7 @@ test("close succeeds after the assignee's linked PR is merged", async () => {
test("commands ignore bots and closed unclaim requests", async () => {
const botClient = recordingClient();
const botResult = await handleIssueCommand(
commandOptions("/claim", botClient, { commentUserType: "Bot" }),
);
const botResult = await handleIssueCommand(commandOptions("/claim", botClient, { commentUserType: "Bot" }));
assert.equal(botResult.status, "ignored-bot");
assert.deepEqual(botClient.calls, []);
@@ -213,10 +200,7 @@ test("GitHub client aggregates paginated pull request cross references", async (
assignees: { nodes: [{ login: "contributor" }] },
timelineItems: {
pageInfo: { hasNextPage: false, endCursor: "done" },
nodes: [
{ source: { __typename: "Issue", number: 11 } },
{ source: { __typename: "PullRequest", number: 12 } },
],
nodes: [{ source: { __typename: "Issue", number: 11 } }, { source: { __typename: "PullRequest", number: 12 } }],
},
},
},
@@ -233,7 +217,10 @@ test("GitHub client aggregates paginated pull request cross references", async (
});
const context = await client.getIssueCloseContext("123");
assert.deepEqual(context.pullRequests.map(({ number }) => number), [10, 12]);
assert.deepEqual(
context.pullRequests.map(({ number }) => number),
[10, 12],
);
assert.equal(args.length, 2);
assert.equal(args[1].includes("endCursor=next-page"), true);
});
+2 -8
View File
@@ -17,14 +17,8 @@ test("reads the JDBC plugin version from Gradle", () => {
});
test("checks Gradle and manifest versions together", () => {
assert.deepEqual(
evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.26", headManifestVersion: "0.1.26" }),
[],
);
assert.match(
evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.27", headManifestVersion: "0.1.26" })[0],
/build\.gradle is 0\.1\.27/,
);
assert.deepEqual(evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.26", headManifestVersion: "0.1.26" }), []);
assert.match(evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.27", headManifestVersion: "0.1.26" })[0], /build\.gradle is 0\.1\.27/);
});
test("bumps Gradle and manifest versions for JDBC source changes", () => {
+27 -105
View File
@@ -17,52 +17,14 @@ const USER_PRIORITY_LABELS = {
P3: { color: "bfd4f2", description: "User-reported priority: low" },
};
const LABEL_PALETTE = [
"0e8a16",
"1d76db",
"5319e7",
"c2e0c6",
"bfd4f2",
"d4c5f9",
"fef2c0",
"fbca04",
"f9d0c4",
"e99695",
"f29513",
"c5def5",
];
const LABEL_PALETTE = ["0e8a16", "1d76db", "5319e7", "c2e0c6", "bfd4f2", "d4c5f9", "fef2c0", "fbca04", "f9d0c4", "e99695", "f29513", "c5def5"];
const globallyAmbiguousAliases = new Set([
"access",
"ch",
"h2",
"iris",
"jdbc",
"mq",
"pg",
"spark",
]);
const globallyAmbiguousAliases = new Set(["access", "ch", "h2", "iris", "jdbc", "mq", "pg", "spark"]);
const genericDatabaseValues = [
"general",
"generic",
"global",
"all",
"any",
"none",
"n/a",
"na",
"通用",
"全部",
"所有",
"无",
"不适用",
];
const genericDatabaseValues = ["general", "generic", "global", "all", "any", "none", "n/a", "na", "通用", "全部", "所有", "无", "不适用"];
const drivers = databaseIssueDrivers;
const exactAsciiAliases = new Set(
drivers.flatMap((driver) => driver.aliases.map((alias) => compactAscii(alias)).filter((alias) => alias.length >= 5)),
);
const exactAsciiAliases = new Set(drivers.flatMap((driver) => driver.aliases.map((alias) => compactAscii(alias)).filter((alias) => alias.length >= 5)));
function loadIssue() {
if (process.env.GITHUB_EVENT_PATH && fs.existsSync(process.env.GITHUB_EVENT_PATH)) {
@@ -113,20 +75,12 @@ function extractInlineField(body, predicate) {
}
function extractDatabaseField(body) {
const matchesDatabaseHeading = (heading) => (
heading.includes("数据库类型") ||
heading.toLowerCase().includes("database type") ||
/^database$/i.test(heading)
);
return extractIssueFormSection(body, matchesDatabaseHeading)
|| extractInlineField(body, matchesDatabaseHeading);
const matchesDatabaseHeading = (heading) => heading.includes("数据库类型") || heading.toLowerCase().includes("database type") || /^database$/i.test(heading);
return extractIssueFormSection(body, matchesDatabaseHeading) || extractInlineField(body, matchesDatabaseHeading);
}
function extractPriorityField(body) {
return extractIssueFormSection(body, (heading) => (
heading.includes("优先级") ||
heading.toLowerCase().includes("priority")
));
return extractIssueFormSection(body, (heading) => heading.includes("优先级") || heading.toLowerCase().includes("priority"));
}
function extractTitleSummaryField(body) {
@@ -146,21 +100,24 @@ function extractTitleSummaryField(body) {
}
function matchPriorityLabel(value) {
const match = String(value || "").normalize("NFKC").match(/\bP([0-3])\b/i);
const match = String(value || "")
.normalize("NFKC")
.match(/\bP([0-3])\b/i);
return match ? `P${match[1]}` : null;
}
function genericIssueTitlePrefix(title) {
const normalized = String(title || "").normalize("NFKC").trim().replace(/\s+/g, " ");
const normalized = String(title || "")
.normalize("NFKC")
.trim()
.replace(/\s+/g, " ");
const bracketMatch = normalized.match(/^([\[【][^\]】]*(?:bug|feature|feat|compatibility|question)[^\]】]*[\]】])$/i);
if (bracketMatch) return bracketMatch[1];
const bareMatch = normalized.match(/^(bug|feature|feat|compatibility|question):?$/i);
if (!bareMatch) return null;
const normalizedPrefix = bareMatch[1].toLowerCase() === "feat" ? "Feat" : (
bareMatch[1].charAt(0).toUpperCase() + bareMatch[1].slice(1).toLowerCase()
);
const normalizedPrefix = bareMatch[1].toLowerCase() === "feat" ? "Feat" : bareMatch[1].charAt(0).toUpperCase() + bareMatch[1].slice(1).toLowerCase();
return `[${normalizedPrefix}]`;
}
@@ -179,14 +136,7 @@ function cleanTitleSummaryLine(line) {
}
function isSkippableTitleSummaryLine(line) {
return (
/^DBX debug log$/i.test(line) ||
/^(Exported|User agent|Platform|Timezone):/i.test(line) ||
/^(Native|Tauri) log dir:/i.test(line) ||
/^=+.*logs?.*=+$/i.test(line) ||
/^\[\d{4}-\d{2}-\d{2}(?:T|\])/.test(line) ||
/^\[(DEBUG|INFO|WARN|ERROR|LOG|STARTUP)\]/i.test(line)
);
return /^DBX debug log$/i.test(line) || /^(Exported|User agent|Platform|Timezone):/i.test(line) || /^(Native|Tauri) log dir:/i.test(line) || /^=+.*logs?.*=+$/i.test(line) || /^\[\d{4}-\d{2}-\d{2}(?:T|\])/.test(line) || /^\[(DEBUG|INFO|WARN|ERROR|LOG|STARTUP)\]/i.test(line);
}
function firstSentence(value) {
@@ -199,9 +149,7 @@ function firstSentence(value) {
for (const line of lines) {
const sentenceMatch = line.match(/^(.{4,120}?[。!?!?]|.{12,120}?\.(?=\s|$))/u);
const sentence = (sentenceMatch ? sentenceMatch[1] : line)
.replace(/[。!?!?.]+$/u, "")
.trim();
const sentence = (sentenceMatch ? sentenceMatch[1] : line).replace(/[。!?!?.]+$/u, "").trim();
if (sentence.length >= 4) {
return sentence.length > 90 ? `${sentence.slice(0, 89)}…` : sentence;
}
@@ -251,11 +199,7 @@ function levenshteinDistance(left, right) {
const current = [leftIndex + 1];
for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
const substitutionCost = left[leftIndex] === right[rightIndex] ? 0 : 1;
current[rightIndex + 1] = Math.min(
current[rightIndex] + 1,
previous[rightIndex + 1] + 1,
previous[rightIndex] + substitutionCost,
);
current[rightIndex + 1] = Math.min(current[rightIndex] + 1, previous[rightIndex + 1] + 1, previous[rightIndex] + substitutionCost);
}
previous = current;
}
@@ -318,9 +262,7 @@ function aliasMatches(text, alias) {
.filter(Boolean)
.map(escapeRegExp)
.join("[\\s._/-]*");
const trailingBoundary = /[0-9]$/.test(normalizedAlias)
? "(?:[^a-z0-9]|$)"
: "(?:[^a-z0-9]|$|(?=v?\\d))";
const trailingBoundary = /[0-9]$/.test(normalizedAlias) ? "(?:[^a-z0-9]|$)" : "(?:[^a-z0-9]|$|(?=v?\\d))";
const pattern = new RegExp(`(^|[^a-z0-9])${flexibleAlias}${trailingBoundary}`, "i");
return pattern.test(normalizedText);
}
@@ -332,10 +274,7 @@ function isGenericDatabaseValue(value) {
.map((part) => part.trim())
.filter(Boolean);
return (
parts.length > 0 &&
parts.every((part) => genericDatabaseValues.some((generic) => aliasMatches(part, generic)))
);
return parts.length > 0 && parts.every((part) => genericDatabaseValues.some((generic) => aliasMatches(part, generic)));
}
function matchDrivers(text, { allowAmbiguous }) {
@@ -456,24 +395,18 @@ export function triageIssue(issue) {
const hasDatabaseField = databaseField.length > 0;
const fieldIsGeneric = hasDatabaseField && isGenericDatabaseValue(databaseField);
const sourceText = hasDatabaseField ? databaseField : issue.title || "";
const matchedDrivers = isGenericDatabaseValue(sourceText)
? []
: uniqueByDbType(matchDrivers(sourceText, { allowAmbiguous: hasDatabaseField }));
const matchedDrivers = isGenericDatabaseValue(sourceText) ? [] : uniqueByDbType(matchDrivers(sourceText, { allowAmbiguous: hasDatabaseField }));
const titleMatchedDrivers = uniqueByDbType(matchDrivers(issue.title || "", { allowAmbiguous: false }));
if (hasDatabaseField && titleMatchedDrivers.length > 0) {
matchedDrivers.push(...titleMatchedDrivers);
}
if (hasDatabaseField && matchedDrivers.length === 0 && !fieldIsGeneric) {
matchedDrivers.push(
...uniqueByDbType(matchDrivers(`${issue.title || ""}\n${databaseField}`, { allowAmbiguous: false })),
);
matchedDrivers.push(...uniqueByDbType(matchDrivers(`${issue.title || ""}\n${databaseField}`, { allowAmbiguous: false })));
}
const uniqueMatchedDrivers = uniqueByDbType(matchedDrivers);
const targetLabels = uniqueMatchedDrivers.map((driver) => `${LABEL_PREFIX}${driver.dbType}`);
const targetLabelColors = Object.fromEntries(
uniqueMatchedDrivers.map((driver) => [`${LABEL_PREFIX}${driver.dbType}`, stablePaletteColor(driver.dbType)]),
);
const targetLabelColors = Object.fromEntries(uniqueMatchedDrivers.map((driver) => [`${LABEL_PREFIX}${driver.dbType}`, stablePaletteColor(driver.dbType)]));
const priorityField = extractPriorityField(issue.body || "");
const targetPriorityValue = matchPriorityLabel(priorityField);
const targetPriorityLabel = targetPriorityValue ? `${USER_PRIORITY_PREFIX}${targetPriorityValue}` : null;
@@ -484,22 +417,11 @@ export function triageIssue(issue) {
const existingLabels = labelNames(issue.labels);
const titleToUpdate = inferIssueTitle(issue.title || "", issue.body || "");
const existingDatabaseLabels = existingLabels.filter((name) => name.startsWith(LABEL_PREFIX));
const staleDatabaseLabels = hasDatabaseField
? existingDatabaseLabels.filter((name) => !targetLabels.includes(name))
: [];
const staleDatabaseLabels = hasDatabaseField ? existingDatabaseLabels.filter((name) => !targetLabels.includes(name)) : [];
const existingPriorityLabels = existingLabels.filter((name) => name.startsWith(USER_PRIORITY_PREFIX));
const stalePriorityLabels = targetPriorityLabel
? existingPriorityLabels.filter((name) => name !== targetPriorityLabel)
: [];
const labelsToAdd = [...targetLabels, targetPriorityLabel]
.filter(Boolean)
.filter((name) => !existingLabels.includes(name));
const labelSpecs = [
...uniqueMatchedDrivers.map(databaseLabelSpec),
...(targetPriorityValue
? [{ name: targetPriorityLabel, ...USER_PRIORITY_LABELS[targetPriorityValue] }]
: []),
];
const stalePriorityLabels = targetPriorityLabel ? existingPriorityLabels.filter((name) => name !== targetPriorityLabel) : [];
const labelsToAdd = [...targetLabels, targetPriorityLabel].filter(Boolean).filter((name) => !existingLabels.includes(name));
const labelSpecs = [...uniqueMatchedDrivers.map(databaseLabelSpec), ...(targetPriorityValue ? [{ name: targetPriorityLabel, ...USER_PRIORITY_LABELS[targetPriorityValue] }] : [])];
const summary = {
issue: issue.number,
databaseField: hasDatabaseField ? databaseField : null,
@@ -21,8 +21,8 @@ function issueWithDatabase(database, title = "[Bug]") {
}
function matchedLabels(database, title) {
return triageIssue(issueWithDatabase(database, title)).summary.labelsToAdd
.filter((label) => label.startsWith("db/"))
return triageIssue(issueWithDatabase(database, title))
.summary.labelsToAdd.filter((label) => label.startsWith("db/"))
.sort();
}
@@ -35,10 +35,7 @@ test("database issue catalog covers every DatabaseType", () => {
test("database label synchronization includes every catalog entry once", () => {
const labelNames = allDatabaseLabelSpecs().map((label) => label.name);
assert.equal(new Set(labelNames).size, databaseIssueDrivers.length);
assert.deepEqual(
labelNames.sort(),
databaseIssueDrivers.map((driver) => `db/${driver.dbType}`).sort(),
);
assert.deepEqual(labelNames.sort(), databaseIssueDrivers.map((driver) => `db/${driver.dbType}`).sort());
});
test("Kingbase aliases retain historical and current company names", () => {
+33 -86
View File
@@ -33,24 +33,12 @@ export const STATIC_LABEL_SPECS = {
const CORE_CRATE_PATH = /^crates\/dbx-(?:core|driver(?:s|-[^/]+)|sql(?:-[^/]+)?|formats|types|platform|ai-provider|plugin-runtime)\//;
const TYPE_LABELS = new Set(["bug", "enhancement", "documentation", "maintenance"]);
const LABEL_PALETTE = [
"0e8a16",
"1d76db",
"5319e7",
"c2e0c6",
"bfd4f2",
"d4c5f9",
"fef2c0",
"fbca04",
"f9d0c4",
"e99695",
"f29513",
"c5def5",
];
const LABEL_PALETTE = ["0e8a16", "1d76db", "5319e7", "c2e0c6", "bfd4f2", "d4c5f9", "fef2c0", "fbca04", "f9d0c4", "e99695", "f29513", "c5def5"];
const DRIVER_DATABASE_ALIASES = {
"cassandra-go": "cassandra",
gbase8a: "gbase",
gbase8c: "gbase",
gbase8s: "gbase",
"h2-legacy": "h2",
kafka: "mq",
@@ -86,12 +74,7 @@ const DATABASE_PATH_TOKENS = [
[/sqlserver/i, "sqlserver"],
];
const PACKAGE_DEPENDENCY_SECTIONS = [
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
];
const PACKAGE_DEPENDENCY_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
function stablePaletteColor(value) {
let hash = 0;
@@ -106,14 +89,14 @@ function normalizeChangedFiles(changedFiles) {
}
function conventionalType(title) {
const normalized = String(title || "").trim().replace(/^[^\p{L}\p{N}]+/u, "");
const normalized = String(title || "")
.trim()
.replace(/^[^\p{L}\p{N}]+/u, "");
return /^([a-z]+)(?:\([^)]*\))?!?[::]/i.exec(normalized)?.[1]?.toLowerCase() || null;
}
export function isDocumentationFile(file) {
return file.startsWith("docs/")
|| /(^|\/)README(?:\.[^/]+)?\.md$/i.test(file)
|| /(^|\/)(SECURITY|CONTRIBUTING|CODE_OF_CONDUCT)\.md$/i.test(file);
return file.startsWith("docs/") || /(^|\/)README(?:\.[^/]+)?\.md$/i.test(file) || /(^|\/)(SECURITY|CONTRIBUTING|CODE_OF_CONDUCT)\.md$/i.test(file);
}
export function inferTypeLabel(title, changedFiles) {
@@ -130,10 +113,7 @@ export function inferAreaLabels(changedFiles) {
const labels = new Set();
const has = (predicate) => changedFiles.some(predicate);
if (has((file) => file === "package.json"
|| file.startsWith("apps/desktop/")
|| file.startsWith("packages/app-tests/")
|| file.startsWith("src-tauri/"))) {
if (has((file) => file === "package.json" || file.startsWith("apps/desktop/") || file.startsWith("packages/app-tests/") || file.startsWith("src-tauri/"))) {
labels.add("area/desktop");
}
if (has((file) => CORE_CRATE_PATH.test(file) || file.startsWith("packages/node-core/"))) {
@@ -143,27 +123,16 @@ export function inferAreaLabels(changedFiles) {
labels.add("area/web");
}
if (has((file) => file.startsWith("agents/"))) labels.add("area/agents");
if (has((file) => file.startsWith("crates/dbx-mcp/")
|| file.startsWith("packages/mcp-")
|| file.startsWith("packages/mcp-server/")
|| file.startsWith("examples/mcp/"))) {
if (has((file) => file.startsWith("crates/dbx-mcp/") || file.startsWith("packages/mcp-") || file.startsWith("packages/mcp-server/") || file.startsWith("examples/mcp/"))) {
labels.add("area/mcp");
}
if (has((file) => file.startsWith("crates/dbx-cli/")
|| file.startsWith("packages/cli-")
|| file.startsWith("packages/cli/")
|| file.startsWith("skills/dbx/")
|| file.startsWith("examples/cli/"))) {
if (has((file) => file.startsWith("crates/dbx-cli/") || file.startsWith("packages/cli-") || file.startsWith("packages/cli/") || file.startsWith("skills/dbx/") || file.startsWith("examples/cli/"))) {
labels.add("area/cli");
}
if (has((file) => file.startsWith("plugins/"))) labels.add("area/plugins");
if (has(isDocumentationFile)) labels.add("area/docs");
if (has((file) => file.startsWith(".github/"))) labels.add("area/ci");
if (has((file) => file.startsWith("deploy/")
|| file === "flake.nix"
|| file === "flake.lock"
|| file === "Makefile"
|| /^packages\/(?:cli|mcp)-(?:darwin|linux|win32)-/.test(file))) {
if (has((file) => file.startsWith("deploy/") || file === "flake.nix" || file === "flake.lock" || file === "Makefile" || /^packages\/(?:cli|mcp)-(?:darwin|linux|win32)-/.test(file))) {
labels.add("area/deploy");
}
@@ -178,29 +147,20 @@ export function isUiChangeFile(file) {
if (isTestFile(file)) return false;
if (file.startsWith("apps/desktop/")) {
return /\.(?:vue|css|less|sass|scss|styl)$/i.test(file)
|| file.startsWith("apps/desktop/src/components/")
|| file.startsWith("apps/desktop/src/i18n/")
|| file.startsWith("apps/desktop/src/styles/")
|| file.startsWith("apps/desktop/public/")
|| isVisualAsset(file);
return /\.(?:vue|css|less|sass|scss|styl)$/i.test(file) || file.startsWith("apps/desktop/src/components/") || file.startsWith("apps/desktop/src/i18n/") || file.startsWith("apps/desktop/src/styles/") || file.startsWith("apps/desktop/public/") || isVisualAsset(file);
}
if (file.startsWith("src-tauri/icons/")) return true;
if (file.startsWith("docs/")) {
return /\.(?:css|jsx|tsx)$/i.test(file)
|| file.startsWith("docs/components/")
|| (file.startsWith("docs/public/") && isVisualAsset(file));
return /\.(?:css|jsx|tsx)$/i.test(file) || file.startsWith("docs/components/") || (file.startsWith("docs/public/") && isVisualAsset(file));
}
return false;
}
export function isTestFile(file) {
return /(^|\/)(?:__tests__|fixtures|snapshots|test|tests|test-support)(\/|$)/.test(file)
|| /\.(?:spec|test)\.[^/]+$/i.test(file)
|| /(^|\/)packages\/app-tests\//.test(file);
return /(^|\/)(?:__tests__|fixtures|snapshots|test|tests|test-support)(\/|$)/.test(file) || /\.(?:spec|test)\.[^/]+$/i.test(file) || /(^|\/)packages\/app-tests\//.test(file);
}
function normalizeDatabaseType(value, aliases) {
@@ -248,8 +208,7 @@ export function parsePackageDependencyNames(source) {
function unquoteTomlKey(value) {
const trimmed = String(value || "").trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
@@ -340,17 +299,10 @@ function dependencySide(file) {
if (file === "package.json" || file === "docs/package.json" || file === "packages/mongo-shell/package.json") {
return "frontend";
}
if (/^packages\/(?:cli(?:-|\/)|mcp(?:-|server\/)).*\/package\.json$/.test(file)
|| file === "packages/cli/package.json"
|| file === "packages/mcp-server/package.json") {
if (/^packages\/(?:cli(?:-|\/)|mcp(?:-|server\/)).*\/package\.json$/.test(file) || file === "packages/cli/package.json" || file === "packages/mcp-server/package.json") {
return "backend";
}
if (file === "Cargo.toml"
|| file.endsWith("/Cargo.toml")
|| file.endsWith("/build.gradle")
|| file.endsWith("/build.gradle.kts")
|| file.endsWith("/go.mod")
|| file.endsWith("/pom.xml")) {
if (file === "Cargo.toml" || file.endsWith("/Cargo.toml") || file.endsWith("/build.gradle") || file.endsWith("/build.gradle.kts") || file.endsWith("/go.mod") || file.endsWith("/pom.xml")) {
return "backend";
}
return null;
@@ -382,13 +334,7 @@ export function inferAddedDependencies(changedFiles, readBaseFile, readHeadFile)
return added;
}
export function evaluatePullRequestLabels({
title,
changedFiles,
knownDatabaseTypes,
readBaseFile = () => null,
readHeadFile = () => null,
}) {
export function evaluatePullRequestLabels({ title, changedFiles, knownDatabaseTypes, readBaseFile = () => null, readHeadFile = () => null }) {
const files = normalizeChangedFiles(changedFiles);
const areaLabels = inferAreaLabels(files);
const labels = new Set(areaLabels.length > 3 ? ["area/multiple"] : areaLabels);
@@ -510,12 +456,7 @@ async function removeLabel(pullRequestNumber, name) {
}
function isManagedLabel(name) {
return TYPE_LABELS.has(name)
|| name.startsWith("area/")
|| name.startsWith(DATABASE_LABEL_PREFIX)
|| name.startsWith("dependencies/")
|| name === "tests-only"
|| name === "ui-change";
return TYPE_LABELS.has(name) || name.startsWith("area/") || name.startsWith(DATABASE_LABEL_PREFIX) || name.startsWith("dependencies/") || name === "tests-only" || name === "ui-change";
}
async function main() {
@@ -547,14 +488,20 @@ async function main() {
};
}
console.log(JSON.stringify({
pullRequest: pullRequest.number,
title: pullRequest.title,
...result,
existingLabels,
labelsToAdd,
labelsToRemove,
}, null, 2));
console.log(
JSON.stringify(
{
pullRequest: pullRequest.number,
title: pullRequest.title,
...result,
existingLabels,
labelsToAdd,
labelsToRemove,
},
null,
2,
),
);
if (dryRun) {
console.log("Dry run enabled; no GitHub API calls were made");
+34 -78
View File
@@ -1,42 +1,14 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
evaluatePullRequestLabels,
inferAddedDependencies,
inferDatabaseTypes,
parseCargoDependencyNames,
parseGoDependencyNames,
parseGradleDependencyNames,
parseMavenDependencyNames,
parsePackageDependencyNames,
} from "./label-pull-request.mjs";
import { evaluatePullRequestLabels, inferAddedDependencies, inferDatabaseTypes, parseCargoDependencyNames, parseGoDependencyNames, parseGradleDependencyNames, parseMavenDependencyNames, parsePackageDependencyNames } from "./label-pull-request.mjs";
const knownDatabaseTypes = new Set([
"access",
"cassandra",
"doris",
"jdbc",
"mongodb",
"mq",
"mysql",
"neo4j",
"oceanbase-oracle",
"oracle",
"postgres",
"redis",
"sqlite",
"sqlserver",
"vastbase",
]);
const knownDatabaseTypes = new Set(["access", "cassandra", "doris", "jdbc", "mongodb", "mq", "mysql", "neo4j", "oceanbase-oracle", "oracle", "postgres", "redis", "sqlite", "sqlserver", "vastbase"]);
test("labels a desktop MySQL UI fix", () => {
const result = evaluatePullRequestLabels({
title: "fix(mysql): improve connection dialog",
changedFiles: [
"apps/desktop/src/components/connection/MySqlConnectionDialog.vue",
"apps/desktop/src/i18n/en.ts",
],
changedFiles: ["apps/desktop/src/components/connection/MySqlConnectionDialog.vue", "apps/desktop/src/i18n/en.ts"],
knownDatabaseTypes,
});
@@ -65,15 +37,7 @@ test("labels the bundled DBX skill as CLI work", () => {
test("maps agent and dialect paths to existing database types", () => {
assert.deepEqual(
inferDatabaseTypes([
"agents/drivers/oracle-go/go.mod",
"agents/drivers/cassandra-go/go.mod",
"agents/drivers/neo4j-go/go.mod",
"agents/drivers/vastbase-go/go.mod",
"agents/drivers/kafka/build.gradle",
"plugins/dialects/postgresql.yaml",
"plugins/dialects/oceanbase.yaml",
], knownDatabaseTypes),
inferDatabaseTypes(["agents/drivers/oracle-go/go.mod", "agents/drivers/cassandra-go/go.mod", "agents/drivers/neo4j-go/go.mod", "agents/drivers/vastbase-go/go.mod", "agents/drivers/kafka/build.gradle", "plugins/dialects/postgresql.yaml", "plugins/dialects/oceanbase.yaml"], knownDatabaseTypes),
["cassandra", "mq", "neo4j", "oceanbase-oracle", "oracle", "postgres", "vastbase"],
);
});
@@ -81,10 +45,7 @@ test("maps agent and dialect paths to existing database types", () => {
test("labels tests-only changes", () => {
const result = evaluatePullRequestLabels({
title: "test(core): cover transaction recovery",
changedFiles: [
"crates/dbx-core/tests/transaction_recovery.rs",
"apps/desktop/src/lib/__tests__/transactionRecovery.spec.ts",
],
changedFiles: ["crates/dbx-core/tests/transaction_recovery.rs", "apps/desktop/src/lib/__tests__/transactionRecovery.spec.ts"],
knownDatabaseTypes,
});
@@ -94,10 +55,7 @@ test("labels tests-only changes", () => {
test("does not treat component tests or MCP server code as UI or deploy changes", () => {
const result = evaluatePullRequestLabels({
title: "🐛 fix(mcp): cover server routing",
changedFiles: [
"apps/desktop/src/components/mcp/McpPanel.spec.ts",
"packages/mcp-server/src/index.ts",
],
changedFiles: ["apps/desktop/src/components/mcp/McpPanel.spec.ts", "packages/mcp-server/src/index.ts"],
knownDatabaseTypes,
});
@@ -117,15 +75,7 @@ test("recognizes conventional titles with a full-width colon", () => {
test("recognizes extracted core crates and their database sources", () => {
const result = evaluatePullRequestLabels({
title: "fix(sql): preserve PostgreSQL query contracts",
changedFiles: [
"crates/dbx-driver-postgres/src/postgres.rs",
"crates/dbx-sql-core/src/sql.rs",
"crates/dbx-types/src/types.rs",
"crates/dbx-platform/src/process.rs",
"crates/dbx-ai-provider/src/ai.rs",
"crates/dbx-formats/src/csv_export.rs",
"crates/dbx-plugin-runtime/src/plugins.rs",
],
changedFiles: ["crates/dbx-driver-postgres/src/postgres.rs", "crates/dbx-sql-core/src/sql.rs", "crates/dbx-types/src/types.rs", "crates/dbx-platform/src/process.rs", "crates/dbx-ai-provider/src/ai.rs", "crates/dbx-formats/src/csv_export.rs", "crates/dbx-plugin-runtime/src/plugins.rs"],
knownDatabaseTypes,
});
@@ -168,11 +118,11 @@ test("detects frontend and backend additions in the same pull request", () => {
const manifests = {
base: {
"package.json": JSON.stringify({ dependencies: { vue: "3.5.0" } }),
"crates/dbx-core/Cargo.toml": "[dependencies]\ntokio = \"1\"\n",
"crates/dbx-core/Cargo.toml": '[dependencies]\ntokio = "1"\n',
},
head: {
"package.json": JSON.stringify({ dependencies: { vue: "3.5.0", zod: "4" } }),
"crates/dbx-core/Cargo.toml": "[dependencies]\ntokio = \"1\"\nserde = \"1\"\n",
"crates/dbx-core/Cargo.toml": '[dependencies]\ntokio = "1"\nserde = "1"\n',
},
};
const result = evaluatePullRequestLabels({
@@ -183,13 +133,7 @@ test("detects frontend and backend additions in the same pull request", () => {
readHeadFile: (file) => manifests.head[file],
});
assert.deepEqual(result.labels, [
"area/core",
"area/desktop",
"dependencies/backend",
"dependencies/frontend",
"enhancement",
]);
assert.deepEqual(result.labels, ["area/core", "area/desktop", "dependencies/backend", "dependencies/frontend", "enhancement"]);
});
test("ignores lockfile-only dependency changes", () => {
@@ -204,7 +148,8 @@ test("ignores lockfile-only dependency changes", () => {
test("parses dependency names from supported backend manifests", () => {
assert.deepEqual(
[...parseCargoDependencyNames(`
[
...parseCargoDependencyNames(`
[workspace.dependencies]
serde = "1"
@@ -213,39 +158,50 @@ windows-sys = "0.61"
[dependencies.mysql_async]
git = "https://example.com/mysql_async"
`)].sort(),
`),
].sort(),
["mysql_async", "serde", "windows-sys"],
);
assert.deepEqual(
[...parseGradleDependencyNames(`
[
...parseGradleDependencyNames(`
implementation 'com.zaxxer:HikariCP:7.0.2'
testImplementation project(':test-support')
runtimeOnly libs.slf4j.nop
`)].sort(),
`),
].sort(),
["catalog:libs.slf4j.nop", "com.zaxxer:HikariCP", "project::test-support"],
);
assert.deepEqual(
[...parseGoDependencyNames(`
[
...parseGoDependencyNames(`
require github.com/example/direct v1.0.0
require (
github.com/example/block v2.0.0
)
`)].sort(),
`),
].sort(),
["github.com/example/block", "github.com/example/direct"],
);
assert.deepEqual(
[...parseMavenDependencyNames(`
[
...parseMavenDependencyNames(`
<dependencies>
<dependency><groupId>org.example</groupId><artifactId>client</artifactId><version>1</version></dependency>
</dependencies>
`)],
`),
],
["org.example:client"],
);
assert.deepEqual(
[...parsePackageDependencyNames(JSON.stringify({
dependencies: { vue: "3" },
devDependencies: { vitest: "4" },
}))].sort(),
[
...parsePackageDependencyNames(
JSON.stringify({
dependencies: { vue: "3" },
devDependencies: { vitest: "4" },
}),
),
].sort(),
["vitest", "vue"],
);
});
+31 -56
View File
@@ -1,16 +1,7 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
import { basename, join } from "node:path";
import { tmpdir } from "node:os";
@@ -23,14 +14,7 @@ const NATIVE_MODULES = new Set(["duckdb", "oracle", "oracle-oci", "xugu", "kingb
const NATIVE_MODULE_PLATFORMS = {
"oracle-oci": ["windows-x64"],
};
const PLATFORMS = [
"macos-aarch64",
"macos-x64",
"linux-aarch64",
"linux-x64",
"windows-aarch64",
"windows-x64",
];
const PLATFORMS = ["macos-aarch64", "macos-x64", "linux-aarch64", "linux-x64", "windows-aarch64", "windows-x64"];
function expectedNativePlatforms(moduleName) {
return [...(NATIVE_MODULE_PLATFORMS[moduleName] ?? PLATFORMS)].sort();
@@ -93,9 +77,7 @@ export function collectReusableAssetPlan({ registry, release, versions, modules,
const missingPlatforms = expectedPlatforms.filter((platform) => !nativePlatforms.includes(platform));
const extraPlatforms = nativePlatforms.filter((platform) => !expectedPlatforms.includes(platform));
if (missingPlatforms.length > 0 || extraPlatforms.length > 0) {
throw new Error(
`Previous native artifacts are incomplete for ${moduleName}: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`,
);
throw new Error(`Previous native artifacts are incomplete for ${moduleName}: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`);
}
}
@@ -119,9 +101,7 @@ export function collectReusableAssetPlan({ registry, release, versions, modules,
const missingPlatforms = PLATFORMS.filter((platform) => !platforms.includes(platform));
const extraPlatforms = platforms.filter((platform) => !PLATFORMS.includes(platform));
if (missingPlatforms.length > 0 || extraPlatforms.length > 0) {
throw new Error(
`Previous JRE ${jreKey} artifacts are incomplete: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`,
);
throw new Error(`Previous JRE ${jreKey} artifacts are incomplete: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`);
}
for (const platform of platforms) {
jreAssets.push({
@@ -210,13 +190,17 @@ export function extractReusableDriverPackages({ packagesDir, outputDir, versions
const javaName = `dbx-agent-${moduleName}-${releaseVersion}.tar.zst`;
if (filenames.has(javaName)) {
extractRawDriver(join(packagesDir, javaName), {
name: javaName,
moduleName,
kind: "jar",
platform: "",
releaseVersion,
}, outputDir);
extractRawDriver(
join(packagesDir, javaName),
{
name: javaName,
moduleName,
kind: "jar",
platform: "",
releaseVersion,
},
outputDir,
);
extracted += 1;
}
@@ -225,13 +209,17 @@ export function extractReusableDriverPackages({ packagesDir, outputDir, versions
const nativeName = `dbx-agent-${moduleName}-${releaseVersion}-${platform}.tar.zst`;
if (!filenames.has(nativeName)) continue;
nativePlatforms.push(platform);
extractRawDriver(join(packagesDir, nativeName), {
name: nativeName,
moduleName,
kind: "native",
platform,
releaseVersion,
}, outputDir);
extractRawDriver(
join(packagesDir, nativeName),
{
name: nativeName,
moduleName,
kind: "native",
platform,
releaseVersion,
},
outputDir,
);
extracted += 1;
}
@@ -253,23 +241,7 @@ function gh(args, options = {}) {
function downloadReleaseAssets(assets, downloadDir) {
if (assets.length === 0) return;
const args = [
"--fail",
"--location",
"--silent",
"--show-error",
"--retry",
"5",
"--retry-all-errors",
"--retry-delay",
"2",
"--connect-timeout",
"30",
"--parallel",
"--parallel-immediate",
"--parallel-max",
"6",
];
const args = ["--fail", "--location", "--silent", "--show-error", "--retry", "5", "--retry-all-errors", "--retry-delay", "2", "--connect-timeout", "30", "--parallel", "--parallel-immediate", "--parallel-max", "6"];
for (const asset of assets) {
if (!asset.browser_download_url) {
throw new Error(`GitHub release asset is missing browser_download_url: ${asset.name}`);
@@ -359,7 +331,10 @@ function main() {
}));
if (plannedAssets.length > 0) {
downloadReleaseAssets(plannedAssets.map((asset) => asset.releaseAsset), downloadDir);
downloadReleaseAssets(
plannedAssets.map((asset) => asset.releaseAsset),
downloadDir,
);
}
for (const asset of plannedAssets) {
@@ -3,23 +3,12 @@ import test from "node:test";
import { collectReusableAssetPlan } from "./reuse-agent-release-assets.mjs";
const platforms = [
"macos-aarch64",
"macos-x64",
"linux-aarch64",
"linux-x64",
"windows-aarch64",
"windows-x64",
];
const platforms = ["macos-aarch64", "macos-x64", "linux-aarch64", "linux-x64", "windows-aarch64", "windows-x64"];
test("collects complete reusable Java, native, and JRE assets", () => {
const access = artifact("dbx-agent-access-0.1.34.tar.zst", "a");
const kingbase = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-agent-kingbase-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const jre = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-jre-21-${platform}.tar.zst`, String(index + 7))]),
);
const kingbase = Object.fromEntries(platforms.map((platform, index) => [platform, artifact(`dbx-agent-kingbase-0.1.40-${platform}.tar.zst`, String(index + 1))]));
const jre = Object.fromEntries(platforms.map((platform, index) => [platform, artifact(`dbx-jre-21-${platform}.tar.zst`, String(index + 7))]));
const registry = {
drivers: {
access: { version: "0.1.34", jar: access },
@@ -39,95 +28,93 @@ test("collects complete reusable Java, native, and JRE assets", () => {
assert.equal(plan.driverAssets.length, 7);
assert.equal(plan.jreAssets.length, 6);
assert.deepEqual(plan.driverAssets.map((asset) => asset.moduleName), ["access", ...Array(6).fill("kingbase")]);
assert.deepEqual(
plan.driverAssets.map((asset) => asset.moduleName),
["access", ...Array(6).fill("kingbase")],
);
});
test("rejects an incomplete reusable native platform set", () => {
const native = Object.fromEntries(
platforms.slice(1).map((platform, index) => [platform, artifact(`dbx-agent-vastbase-0.1.38-${platform}.tar.zst`, String(index + 1))]),
);
const native = Object.fromEntries(platforms.slice(1).map((platform, index) => [platform, artifact(`dbx-agent-vastbase-0.1.38-${platform}.tar.zst`, String(index + 1))]));
const registry = { drivers: { vastbase: { version: "0.1.38", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { vastbase: "0.1.38" },
modules: ["vastbase"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { vastbase: "0.1.38" },
modules: ["vastbase"],
reuseJre: false,
}),
/missing=macos-aarch64/,
);
});
test("requires all TDengine native platforms when reusing a release", () => {
const native = Object.fromEntries(
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-tdengine-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-tdengine-0.1.40-${platform}.tar.zst`, String(index + 1))]));
const registry = { drivers: { tdengine: { version: "0.1.40", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { tdengine: "0.1.40" },
modules: ["tdengine"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { tdengine: "0.1.40" },
modules: ["tdengine"],
reuseJre: false,
}),
/missing=windows-x64/,
);
});
test("requires all Neo4j native platforms when reusing a release", () => {
const native = Object.fromEntries(
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-neo4j-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-neo4j-0.1.40-${platform}.tar.zst`, String(index + 1))]));
const registry = { drivers: { neo4j: { version: "0.1.40", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { neo4j: "0.1.40" },
modules: ["neo4j"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { neo4j: "0.1.40" },
modules: ["neo4j"],
reuseJre: false,
}),
/missing=windows-x64/,
);
});
test("requires all IoTDB native platforms when reusing a release", () => {
const native = Object.fromEntries(
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-iotdb-0.1.30-${platform}.tar.zst`, String(index + 1))]),
);
const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-iotdb-0.1.30-${platform}.tar.zst`, String(index + 1))]));
const registry = { drivers: { iotdb: { version: "0.1.30", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { iotdb: "0.1.30" },
modules: ["iotdb"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { iotdb: "0.1.30" },
modules: ["iotdb"],
reuseJre: false,
}),
/missing=windows-x64/,
);
});
test("requires all RocketMQ native platforms when reusing a release", () => {
const native = Object.fromEntries(
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-rocketmq-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-rocketmq-0.1.40-${platform}.tar.zst`, String(index + 1))]));
const registry = { drivers: { rocketmq: { version: "0.1.40", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { rocketmq: "0.1.40" },
modules: ["rocketmq"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { rocketmq: "0.1.40" },
modules: ["rocketmq"],
reuseJre: false,
}),
/missing=windows-x64/,
);
});
@@ -146,7 +133,10 @@ test("reuses the Windows-only Oracle OCI native package", () => {
// Windows-only is the complete platform set for this module, so requiring all
// six platforms would reject a correct release.
assert.deepEqual(plan.driverAssets.map((asset) => asset.platform), ["windows-x64"]);
assert.deepEqual(
plan.driverAssets.map((asset) => asset.platform),
["windows-x64"],
);
});
test("rejects an Oracle OCI release that lost its Windows native package", () => {
@@ -154,21 +144,20 @@ test("rejects an Oracle OCI release that lost its Windows native package", () =>
const registry = { drivers: { "oracle-oci": { version: "0.1.0", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { "oracle-oci": "0.1.0" },
modules: ["oracle-oci"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { "oracle-oci": "0.1.0" },
modules: ["oracle-oci"],
reuseJre: false,
}),
/missing=windows-x64, extra=linux-x64/,
);
});
test("ignores zero-size legacy JAR placeholders for native-only modules", () => {
const native = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]),
);
const native = Object.fromEntries(platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]));
const registry = {
drivers: {
duckdb: {
@@ -193,7 +182,10 @@ test("ignores zero-size legacy JAR placeholders for native-only modules", () =>
});
assert.equal(plan.driverAssets.length, 6);
assert.equal(plan.driverAssets.some((asset) => asset.kind === "jar"), false);
assert.equal(
plan.driverAssets.some((asset) => asset.kind === "jar"),
false,
);
});
test("rejects a registry version that differs from the effective baseline", () => {
@@ -201,13 +193,14 @@ test("rejects a registry version that differs from the effective baseline", () =
const registry = { drivers: { access: { version: "0.1.33", jar: access } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor([access]),
versions: { access: "0.1.34" },
modules: ["access"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor([access]),
versions: { access: "0.1.34" },
modules: ["access"],
reuseJre: false,
}),
/registry=0\.1\.33, expected=0\.1\.34/,
);
});
+33 -16
View File
@@ -22,12 +22,18 @@ async function command(executable, args, signal) {
return new Promise((resolveCommand, reject) => {
const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"], signal });
let output = "";
child.stdout.on("data", (data) => { output += data; });
child.stderr.on("data", (data) => { output += data; });
child.stdout.on("data", (data) => {
output += data;
});
child.stderr.on("data", (data) => {
output += data;
});
const timer = setTimeout(() => child.kill("SIGKILL"), 120000);
const fail = () => reject(new Error(`${executable.split("/").at(-1)} ${args[0]} failed.`));
let failed = false;
child.on("error", () => { failed = true; });
child.on("error", () => {
failed = true;
});
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0 && !failed) resolveCommand(output);
@@ -43,10 +49,7 @@ export async function verifyMcp(binary, options = {}) {
if (!statSync(binary).isFile()) throw new Error("MCP binary must be a regular file.");
await command(ctx.codesign, ["--verify", "--strict", "--all-architectures", "-R", `=${ctx.requirement}`, binary], ctx.signal);
const info = await command(ctx.codesign, ["--display", "--verbose=4", "--requirements", "-", binary], ctx.signal);
if (!info.split(/\r?\n/).includes(`Identifier=${mcpIdentifier}`)
|| !info.split(/\r?\n/).includes(`TeamIdentifier=${ctx.env.APPLE_TEAM_ID}`)
|| !/^Timestamp=(?!none$).+/m.test(info)
|| /Signature=adhoc/.test(info)) {
if (!info.split(/\r?\n/).includes(`Identifier=${mcpIdentifier}`) || !info.split(/\r?\n/).includes(`TeamIdentifier=${ctx.env.APPLE_TEAM_ID}`) || !/^Timestamp=(?!none$).+/m.test(info) || /Signature=adhoc/.test(info)) {
throw new Error("MCP signature must have the expected identifier, team and secure timestamp.");
}
const designated = info.match(/^designated => (.+)$/m)?.[1];
@@ -66,7 +69,10 @@ export async function signMcp(binary, options = {}) {
throw new Error("APPLE_CERTIFICATE must be a base64 PKCS12 bundle.");
}
const originalList = await command(ctx.security, ["list-keychains", "-d", "user"], ctx.signal);
const originalKeychains = originalList.split(/\r?\n/).filter((line) => line.trim()).map((line) => JSON.parse(line.trim()));
const originalKeychains = originalList
.split(/\r?\n/)
.filter((line) => line.trim())
.map((line) => JSON.parse(line.trim()));
if (!originalKeychains.every((keychain) => typeof keychain === "string")) throw new Error("Invalid keychain search list.");
const directory = mkdtempSync(join(ctx.env.RUNNER_TEMP || ctx.env.TMPDIR || tmpdir(), "dbx-mcp-signing-"));
chmodSync(directory, 0o700);
@@ -84,22 +90,33 @@ export async function signMcp(binary, options = {}) {
await command(ctx.security, ["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychain], ctx.signal);
await command(ctx.security, ["list-keychains", "-d", "user", "-s", keychain, ...originalKeychains], ctx.signal);
const identities = await command(ctx.security, ["find-identity", "-v", "-p", "codesigning", keychain], ctx.signal);
const candidates = [...identities.matchAll(/\b([A-Fa-f0-9]{40}) "(Developer ID Application: [^"\r\n]+)"/g)]
.filter(([, hash, name]) => name.endsWith(` (${ctx.env.APPLE_TEAM_ID})`)
&& (name === ctx.env.APPLE_SIGNING_IDENTITY || hash.toUpperCase() === ctx.env.APPLE_SIGNING_IDENTITY.toUpperCase()));
const candidates = [...identities.matchAll(/\b([A-Fa-f0-9]{40}) "(Developer ID Application: [^"\r\n]+)"/g)].filter(
([, hash, name]) => name.endsWith(` (${ctx.env.APPLE_TEAM_ID})`) && (name === ctx.env.APPLE_SIGNING_IDENTITY || hash.toUpperCase() === ctx.env.APPLE_SIGNING_IDENTITY.toUpperCase()),
);
if (candidates.length !== 1) throw new Error("Expected exactly one valid configured Developer ID Application identity in the temporary keychain.");
await command(ctx.codesign, ["--force", "--sign", candidates[0][1], "--keychain", keychain, "--identifier", mcpIdentifier,
"--requirements", `=designated => ${ctx.requirement}`, "--timestamp", binary], ctx.signal);
await command(ctx.codesign, ["--force", "--sign", candidates[0][1], "--keychain", keychain, "--identifier", mcpIdentifier, "--requirements", `=designated => ${ctx.requirement}`, "--timestamp", binary], ctx.signal);
await verifyMcp(binary, options);
} catch (failure) {
error = failure;
} finally {
const failures = [];
try { await command(ctx.security, ["list-keychains", "-d", "user", "-s", ...originalKeychains]); } catch (failure) { failures.push(failure); }
try {
await command(ctx.security, ["list-keychains", "-d", "user", "-s", ...originalKeychains]);
} catch (failure) {
failures.push(failure);
}
if (existsSync(keychain)) {
try { await command(ctx.security, ["delete-keychain", keychain]); } catch (failure) { failures.push(failure); }
try {
await command(ctx.security, ["delete-keychain", keychain]);
} catch (failure) {
failures.push(failure);
}
}
try {
rmSync(directory, { recursive: true, force: true });
} catch (failure) {
failures.push(failure);
}
try { rmSync(directory, { recursive: true, force: true }); } catch (failure) { failures.push(failure); }
if (failures.length) error = new Error("MCP signing keychain cleanup failed; refusing publication.", { cause: error });
}
if (error) throw error;
+26 -126
View File
@@ -10,81 +10,12 @@ const MAX_QUERY_LENGTH = 480;
const MAX_CANDIDATES = 3;
const SEARCH_RESULT_LIMIT = 20;
const ignoredSectionPatterns = [
/^来源$/i,
/^source$/i,
/数据库类型/i,
/database type/i,
/支持信息/i,
/support info/i,
/优先级/i,
/priority/i,
/补充信息/i,
/additional context/i,
/环境信息/i,
/environment/i,
];
const ignoredSectionPatterns = [/^来源$/i, /^source$/i, /数据库类型/i, /database type/i, /支持信息/i, /support info/i, /优先级/i, /priority/i, /补充信息/i, /additional context/i, /环境信息/i, /environment/i];
const genericLatinTokens = new Set([
"ai",
"alter",
"bug",
"datagrip",
"dbeaver",
"dbx",
"delete",
"desktop",
"feature",
"from",
"insert",
"issue",
"navicat",
"question",
"request",
"select",
"sql",
"support",
"table",
"update",
"version",
"web",
"where",
"windows",
]);
const genericLatinTokens = new Set(["ai", "alter", "bug", "datagrip", "dbeaver", "dbx", "delete", "desktop", "feature", "from", "insert", "issue", "navicat", "question", "request", "select", "sql", "support", "table", "update", "version", "web", "where", "windows"]);
const genericCjkTokens = new Set([
"一个",
"以及",
"使用",
"功能",
"可以",
"当前",
"支持",
"数据库",
"新增",
"增加",
"希望",
"异常",
"操作",
"所在",
"数据",
"显示",
"没有",
"现在",
"设置",
"问题",
"进行",
"错误",
"需要",
]);
const identifierQueryNoiseTokens = new Set([
"失效",
"弹出",
"快捷",
"提醒",
"时候",
"选择",
]);
const genericCjkTokens = new Set(["一个", "以及", "使用", "功能", "可以", "当前", "支持", "数据库", "新增", "增加", "希望", "异常", "操作", "所在", "数据", "显示", "没有", "现在", "设置", "问题", "进行", "错误", "需要"]);
const identifierQueryNoiseTokens = new Set(["失效", "弹出", "快捷", "提醒", "时候", "选择"]);
const cjkSegmenter = new Intl.Segmenter("zh-CN", { granularity: "word" });
const databaseDrivers = databaseIssueDrivers;
@@ -107,7 +38,9 @@ function labelNames(labels) {
}
function stripIssuePrefix(title) {
return String(title || "").replace(/^\s*\[[^\]]+\]\s*/u, "").trim();
return String(title || "")
.replace(/^\s*\[[^\]]+\]\s*/u, "")
.trim();
}
function stripMarkdown(value) {
@@ -143,9 +76,7 @@ function databaseField(body) {
}
function relevantBody(body) {
const sections = parseIssueSections(body).filter(
({ heading }) => !ignoredSectionPatterns.some((pattern) => pattern.test(heading)),
);
const sections = parseIssueSections(body).filter(({ heading }) => !ignoredSectionPatterns.some((pattern) => pattern.test(heading)));
return stripMarkdown(sections.map(({ content }) => content).join("\n")).slice(0, 2400);
}
@@ -197,9 +128,7 @@ function semanticTokens(value) {
export function searchTitleTerms(issue) {
const identifiers = technicalIdentifiers(issue);
const tokens = [...semanticTokens(stripIssuePrefix(issue.title))].filter(
(token) => identifiers.size === 0 || !identifierQueryNoiseTokens.has(token),
);
const tokens = [...semanticTokens(stripIssuePrefix(issue.title))].filter((token) => identifiers.size === 0 || !identifierQueryNoiseTokens.has(token));
return tokens.slice(0, 8).join(" ").slice(0, MAX_QUERY_LENGTH);
}
@@ -207,11 +136,7 @@ function latinTokens(value) {
return new Set(
normalizeText(value)
.match(/[a-z0-9][a-z0-9+._-]{1,}/gu)
?.filter((token) => (
!genericLatinTokens.has(token)
&& !/^v?\d+(?:[._-]\d+)*$/u.test(token)
&& !/^(.)\1{2,}$/u.test(token)
)) || [],
?.filter((token) => !genericLatinTokens.has(token) && !/^v?\d+(?:[._-]\d+)*$/u.test(token) && !/^(.)\1{2,}$/u.test(token)) || [],
);
}
@@ -334,26 +259,15 @@ export function buildCorpusContext(issue, candidates) {
const frequencies = new Map();
for (const token of queryTitleTokens) {
const documentFrequency = candidateTitleTokens.reduce(
(count, tokens) => count + (tokens.has(token) ? 1 : 0),
0,
);
const documentFrequency = candidateTitleTokens.reduce((count, tokens) => count + (tokens.has(token) ? 1 : 0), 0);
frequencies.set(token, documentFrequency);
const queryFrequencyBoost = 1 + Math.log(Math.max(1, tokenOccurrenceCount(issueTitle, token)));
weights.set(token, inverseDocumentFrequency(candidates.length, documentFrequency) * queryFrequencyBoost);
}
const observedWeights = [...queryTitleTokens]
.filter((token) => (frequencies.get(token) || 0) > 0)
.map((token) => weights.get(token) || 0);
const observedWeights = [...queryTitleTokens].filter((token) => (frequencies.get(token) || 0) > 0).map((token) => weights.get(token) || 0);
const maximumWeight = Math.max(0, ...observedWeights);
const anchorTokens = new Set(
[...queryTitleTokens].filter((token) => (
maximumWeight > 0
&& (frequencies.get(token) || 0) > 0
&& (weights.get(token) || 0) >= maximumWeight * 0.9
)),
);
const anchorTokens = new Set([...queryTitleTokens].filter((token) => maximumWeight > 0 && (frequencies.get(token) || 0) > 0 && (weights.get(token) || 0) >= maximumWeight * 0.9));
return { queryTitleTokens, candidateTitleTokens, weights, anchorTokens };
}
@@ -369,29 +283,18 @@ export function scoreCandidate(issue, candidate, rank = 0, corpusContext) {
const candidateTitle = stripIssuePrefix(candidate.title);
const issueBody = relevantBody(issue.body).slice(0, 1200);
const candidateBody = relevantBody(candidate.body).slice(0, 1200);
const titleSimilarity = diceCoefficient(
characterNgrams(contentTitleText(issue)),
characterNgrams(contentTitleText(candidate)),
);
const titleSimilarity = diceCoefficient(characterNgrams(contentTitleText(issue)), characterNgrams(contentTitleText(candidate)));
const bodySimilarity = diceCoefficient(characterNgrams(issueBody), characterNgrams(candidateBody));
const context = corpusContext || buildCorpusContext(issue, [candidate]);
const candidateTitleTokens = corpusContext
? context.candidateTitleTokens[rank]
: context.candidateTitleTokens[0];
const candidateTitleTokens = corpusContext ? context.candidateTitleTokens[rank] : context.candidateTitleTokens[0];
const titleCoverage = weightedCoverage(context.queryTitleTokens, candidateTitleTokens, context.weights);
const anchorHit = setsOverlap(candidateTitleTokens, context.anchorTokens);
const issueIdentifiers = technicalIdentifiers(issue);
const candidateIdentifiers = technicalIdentifiers(candidate);
const identifierCoverage = issueIdentifiers.size === 0
? 0
: intersectionSize(issueIdentifiers, candidateIdentifiers) / issueIdentifiers.size;
const identifierCoverage = issueIdentifiers.size === 0 ? 0 : intersectionSize(issueIdentifiers, candidateIdentifiers) / issueIdentifiers.size;
const rankPrior = 1 / Math.log2(rank + 2);
let score = titleCoverage * 0.45
+ titleSimilarity * 0.25
+ bodySimilarity * 0.1
+ identifierCoverage * 0.15
+ rankPrior * 0.05;
let score = titleCoverage * 0.45 + titleSimilarity * 0.25 + bodySimilarity * 0.1 + identifierCoverage * 0.15 + rankPrior * 0.05;
const issueTypes = typeLabels(issue.labels);
const candidateTypes = typeLabels(candidate.labels);
if (issueTypes.size > 0 && candidateTypes.size > 0) {
@@ -401,10 +304,7 @@ export function scoreCandidate(issue, candidate, rank = 0, corpusContext) {
// Like Discourse, retrieval is deliberately broad and public suggestions
// require a separate threshold. Rare title terms act as BM25F-style anchors.
const accepted = score >= 0.34
|| (anchorHit && titleCoverage >= 0.38 && score >= 0.2)
|| (identifierCoverage >= 0.5 && titleCoverage >= 0.2 && score >= 0.28)
|| (titleSimilarity + bodySimilarity >= 0.42 && score >= 0.28);
const accepted = score >= 0.34 || (anchorHit && titleCoverage >= 0.38 && score >= 0.2) || (identifierCoverage >= 0.5 && titleCoverage >= 0.2 && score >= 0.28) || (titleSimilarity + bodySimilarity >= 0.42 && score >= 0.28);
return {
accepted,
@@ -421,9 +321,7 @@ export function scoreCandidate(issue, candidate, rank = 0, corpusContext) {
}
export function rankCandidates(issue, items) {
const candidates = items.filter(
(candidate) => !candidate.pull_request && Number(candidate.number) !== Number(issue.number),
);
const candidates = items.filter((candidate) => !candidate.pull_request && Number(candidate.number) !== Number(issue.number));
const context = buildCorpusContext(issue, candidates);
return candidates
.map((candidate, rank) => ({ candidate, ...scoreCandidate(issue, candidate, rank, context) }))
@@ -510,11 +408,13 @@ export async function run({ issue = loadIssue(), client } = {}) {
return [];
}
const github = client || new GitHubClient({
token: process.env.GITHUB_TOKEN,
repository: process.env.GITHUB_REPOSITORY,
apiBase: process.env.GITHUB_API_URL,
});
const github =
client ||
new GitHubClient({
token: process.env.GITHUB_TOKEN,
repository: process.env.GITHUB_REPOSITORY,
apiBase: process.env.GITHUB_API_URL,
});
if (await github.hasExistingComment(issue.number)) {
console.log("Similar issue comment already exists");
return [];
+20 -23
View File
@@ -2,15 +2,7 @@ import assert from "node:assert/strict";
import { createServer } from "node:http";
import test from "node:test";
import {
GitHubClient,
formatComment,
rankCandidates,
run,
scoreCandidate,
searchTerms,
searchTitleTerms,
} from "./suggest-similar-issues.mjs";
import { GitHubClient, formatComment, rankCandidates, run, scoreCandidate, searchTerms, searchTitleTerms } from "./suggest-similar-issues.mjs";
const baseIssue = {
number: 4043,
@@ -213,7 +205,10 @@ test("uses corpus rarity to reject generic real-world matches", () => {
];
const ranked = rankCandidates(issue, candidates);
assert.deepEqual(ranked.map(({ candidate }) => candidate.number), [2201]);
assert.deepEqual(
ranked.map(({ candidate }) => candidate.number),
[2201],
);
assert.equal(ranked[0].signals.anchorHit, true);
});
@@ -247,9 +242,7 @@ test("ranks candidates, excludes the current issue and limits output", () => {
});
test("formats a cautious localized comment with concise issue references", () => {
const comment = formatComment(baseIssue, [
{ candidate: { number: 82, title: "[Bug] [快捷键](https://example.com)", state: "closed" } },
]);
const comment = formatComment(baseIssue, [{ candidate: { number: 82, title: "[Bug] [快捷键](https://example.com)", state: "closed" } }]);
assert.match(comment, /^<!-- dbx-similar-issues -->/u);
assert.match(comment, /\n- #82\n/u);
@@ -272,16 +265,20 @@ test("run searches and posts one idempotent comment", async (t) => {
return;
}
if (request.method === "GET" && request.url.startsWith("/search/issues?")) {
response.end(JSON.stringify({
search_type: "hybrid",
items: [{
number: 82,
title: "Ctrl+Enter 执行当前 SQL 语句",
body: "Ctrl+Enter 直接执行光标所在语句,不显示选择框。",
state: "open",
labels: [{ name: "bug" }, { name: "db/mysql" }],
}],
}));
response.end(
JSON.stringify({
search_type: "hybrid",
items: [
{
number: 82,
title: "Ctrl+Enter 执行当前 SQL 语句",
body: "Ctrl+Enter 直接执行光标所在语句,不显示选择框。",
state: "open",
labels: [{ name: "bug" }, { name: "db/mysql" }],
},
],
}),
);
return;
}
if (request.method === "POST" && request.url.endsWith("/comments")) {
+10 -10
View File
@@ -59,13 +59,15 @@ export function stripDownloadSection(body) {
// 剥离 release notes 里的 issue 引用 (closes #xxx),保留贡献者署名 (contributed by @xxx)。
// 同时处理纯 closes 括号和混在 contributed by 括号里的 closes 片段。
function stripIssueRefs(text) {
return text
// 纯 closes 括号整体去掉,如 (closes #123) 或 (closes #123, closes #456)
.replace(/\s*\(closes #\d+(?:,\s*closes #\d+)*\)/g, "")
// closes 在前、contributed by 在后,如 (closes #88, contributed by @xxx) → (contributed by @xxx)
.replace(/\(closes #\d+,\s*/g, "(")
// contributed by 在前、closes 在后,如 (contributed by @xxx, closes #123) → (contributed by @xxx)
.replace(/,\s*closes #\d+/g, "");
return (
text
// 纯 closes 括号整体去掉,如 (closes #123) 或 (closes #123, closes #456)
.replace(/\s*\(closes #\d+(?:,\s*closes #\d+)*\)/g, "")
// closes 在前、contributed by 在后,如 (closes #88, contributed by @xxx) → (contributed by @xxx)
.replace(/\(closes #\d+,\s*/g, "(")
// contributed by 在前、closes 在后,如 (contributed by @xxx, closes #123) → (contributed by @xxx)
.replace(/,\s*closes #\d+/g, "")
);
}
// 对整份 releases JSON 统一剥离 closes # 引用。cache 复用的英文翻译来自 R2,
@@ -187,9 +189,7 @@ function buildLatestEnNotes(enReleasesJson) {
}
export function buildLatestReleaseNotes(releases) {
const latest = releases
.filter(isAppRelease)
.sort((a, b) => new Date(b.published_at) - new Date(a.published_at))[0];
const latest = releases.filter(isAppRelease).sort((a, b) => new Date(b.published_at) - new Date(a.published_at))[0];
if (!latest) return null;
return { version: latest.tag_name, notes: latest.body || "" };
}
+4 -14
View File
@@ -40,9 +40,7 @@ async function main() {
});
console.log(`Uploading ${assets.length} CNB asset(s) with concurrency ${args.concurrency}.`);
await mapWithConcurrency(assets, args.concurrency, (assetPath) =>
uploadWithRetry(client, release.id, assetPath, args.overwriteExisting),
);
await mapWithConcurrency(assets, args.concurrency, (assetPath) => uploadWithRetry(client, release.id, assetPath, args.overwriteExisting));
}
function parseArgs(argv) {
@@ -70,9 +68,7 @@ function parseArgs(argv) {
}
if (!args.token) throw new Error("CNB_TOKEN is required.");
if (!args.githubReleasePath || (!args.metadataOnly && !args.assetsDir)) {
throw new Error(
"Usage: sync-cnb-release.mjs --github-release <release.json> (--assets-dir <dir> | --metadata-only) [--prune-assets] [--make-latest]",
);
throw new Error("Usage: sync-cnb-release.mjs --github-release <release.json> (--assets-dir <dir> | --metadata-only) [--prune-assets] [--make-latest]");
}
if (!Number.isInteger(args.concurrency) || args.concurrency < 1) {
throw new Error("CNB_UPLOAD_CONCURRENCY must be a positive integer.");
@@ -162,19 +158,13 @@ export class CnbClient {
}
async deleteAsset(releaseId, assetId) {
await this.request(
"DELETE",
`/${this.repository}/-/releases/${encodeURIComponent(releaseId)}/assets/${encodeURIComponent(assetId)}`,
);
await this.request("DELETE", `/${this.repository}/-/releases/${encodeURIComponent(releaseId)}/assets/${encodeURIComponent(assetId)}`);
}
async listReleases(pageSize = 100) {
const releases = [];
for (let page = 1; ; page++) {
const batch = await this.request(
"GET",
`/${this.repository}/-/releases?page=${page}&page_size=${pageSize}`,
);
const batch = await this.request("GET", `/${this.repository}/-/releases?page=${page}&page_size=${pageSize}`);
if (!Array.isArray(batch)) throw new Error("CNB release list response must be an array.");
releases.push(...batch);
if (batch.length < pageSize) return releases;
+9 -20
View File
@@ -103,11 +103,7 @@ test("ensureRelease explicitly marks a promoted release as latest", async (t) =>
token: "test-token",
});
await client.ensureRelease(
"v1.2.3",
{ name: "DBX v1.2.3", body: "Stable", isPrerelease: false },
{ makeLatest: true },
);
await client.ensureRelease("v1.2.3", { name: "DBX v1.2.3", body: "Stable", isPrerelease: false }, { makeLatest: true });
assert.deepEqual(requests, [
{ method: "GET", url: "/dbxio.com/dbx/-/releases/tags/v1.2.3", body: null },
@@ -157,26 +153,19 @@ test("metadata-only sync can mark the release as latest without reading an asset
const tempDir = await mkdtemp(join(tmpdir(), "sync-cnb-release-"));
t.after(() => rm(tempDir, { recursive: true, force: true }));
const releasePath = join(tempDir, "release.json");
await writeFile(
releasePath,
JSON.stringify({ tagName: "v1.2.3", name: "DBX v1.2.3", body: "Stable", isPrerelease: false }),
);
await writeFile(releasePath, JSON.stringify({ tagName: "v1.2.3", name: "DBX v1.2.3", body: "Stable", isPrerelease: false }));
const address = server.address();
assert.ok(address && typeof address !== "string");
const result = await new Promise((resolve) => {
const child = spawn(
process.execPath,
[scriptPath, "--github-release", releasePath, "--metadata-only", "--make-latest"],
{
env: {
...process.env,
CNB_API_BASE: `http://127.0.0.1:${address.port}`,
CNB_REPOSITORY: "dbxio.com/dbx",
CNB_TOKEN: "test-token",
},
const child = spawn(process.execPath, [scriptPath, "--github-release", releasePath, "--metadata-only", "--make-latest"], {
env: {
...process.env,
CNB_API_BASE: `http://127.0.0.1:${address.port}`,
CNB_REPOSITORY: "dbxio.com/dbx",
CNB_TOKEN: "test-token",
},
);
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => (stdout += chunk));
+10 -27
View File
@@ -6,51 +6,34 @@
"": {
"name": "dbx-ui-preview",
"dependencies": {
"playwright": "1.49.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
"playwright": "1.64.1"
}
},
"node_modules/playwright": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.49.1"
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
}
}
+1 -1
View File
@@ -6,6 +6,6 @@
"seed": "node seed.mjs"
},
"dependencies": {
"playwright": "1.49.1"
"playwright": "1.64.1"
}
}
+19 -10
View File
@@ -55,7 +55,8 @@ async function files() {
// the description with the block in place of the old one, or at its foot
function withBlock(body, block) {
body = body || "";
const a = body.indexOf(BEGIN), b = body.indexOf(END);
const a = body.indexOf(BEGIN),
b = body.indexOf(END);
if (a >= 0 && b > a) return body.slice(0, a) + block + body.slice(b + END.length);
return body.replace(/\s*$/, "") + "\n\n" + block;
}
@@ -102,7 +103,9 @@ async function pushMedia(sha, dir) {
git(["fetch", "-q", "--depth=1", remote, BRANCH], work);
base = git(["rev-parse", "FETCH_HEAD"], work);
git(["checkout", "-q", "-f", "--detach", "FETCH_HEAD"], work);
} catch { /* the first one */ }
} catch {
/* the first one */
}
// this PR's latest run only, and no PR that's been closed
for (const d of await fs.readdir(work).catch(() => [])) {
const m = /^pr-(\d+)$/.exec(d);
@@ -153,8 +156,12 @@ ${shots.map((x) => `<figure${x.warn ? ' class="shaky"' : ""}><img src="${esc(x.f
}
async function pagesURL() {
try { return (await gh(`/repos/${repo}/pages`)).html_url.replace(/\/?$/, "/"); }
catch { const [o, n] = repo.split("/"); return `https://${o.toLowerCase()}.github.io/${n}/`; }
try {
return (await gh(`/repos/${repo}/pages`)).html_url.replace(/\/?$/, "/");
} catch {
const [o, n] = repo.split("/");
return `https://${o.toLowerCase()}.github.io/${n}/`;
}
}
async function publish() {
@@ -163,12 +170,12 @@ async function publish() {
const dir = process.env.OUT_DIR;
const result = process.env.RECORD_RESULT || "success";
let m = null;
try { m = JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8")); } catch {}
try {
m = JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8"));
} catch {}
if (result === "cancelled" && !m) return; // a newer commit took over
if (!m || m.leak || (!m.scenes.some((s) => s.shots.length) && !m.video)) {
const why = m?.leak ? "录制时屏幕上出现了密钥,已全部丢弃,没有发布任何截图"
: m?.skipped === "no visible UI change found in the diff" ? "看了改动,没有找到用户能看到的界面变化,所以没有录制"
: `这次没录成(${result})`;
const why = m?.leak ? "录制时屏幕上出现了密钥,已全部丢弃,没有发布任何截图" : m?.skipped === "no visible UI change found in the diff" ? "看了改动,没有找到用户能看到的界面变化,所以没有录制" : `这次没录成(${result})`;
const errs = m?.errors?.length ? `\n\n<details><summary>细节</summary>\n\n${m.errors.map((e) => "- " + e).join("\n")}\n</details>` : "";
await setBlock(`${head(sha)}\n${m?.summary ? `**改动**(按代码):${m.summary}\n\n` : ""}${m?.mismatch ? `> [!WARNING]\n> **描述与代码不符**:${m.mismatch}\n\n` : ""}${why}。${runURL ? `详见[运行记录](${runURL})。` : ""}${errs}`);
return;
@@ -195,5 +202,7 @@ async function publish() {
}
const cmd = process.argv[2];
(cmd === "detect" ? detect() : cmd === "publish" ? publish() : Promise.reject(new Error("detect | publish")))
.catch((e) => { console.error(e); process.exit(1); });
(cmd === "detect" ? detect() : cmd === "publish" ? publish() : Promise.reject(new Error("detect | publish"))).catch((e) => {
console.error(e);
process.exit(1);
});
+134 -60
View File
@@ -34,7 +34,10 @@ const MODEL = env("PLAN_MODEL", "deepseek-flash");
const VIEW = { width: 1440, height: 900 };
// never pressed: they delete the sandbox's own data or its setup
const FORBIDDEN = /删除|清空|移除|卸载|重置|格式化|delete|remove|clear all|drop\b|truncate\b/i;
const secrets = env("SECRETS", "").split("\n").map((s) => s.trim()).filter((s) => s.length >= 8);
const secrets = env("SECRETS", "")
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length >= 8);
const log = (...a) => console.log("[ui-preview]", ...a);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -46,7 +49,13 @@ const manifest = { summary: "", scenes: [], video: null, poster: null, errors: [
function outline() {
const lines = [];
const MAX = 420;
const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(" ").replace(/\s+/g, " ").slice(0, 70);
const own = (e) =>
[...e.childNodes]
.filter((n) => n.nodeType === 3)
.map((n) => n.textContent.trim())
.join(" ")
.replace(/\s+/g, " ")
.slice(0, 70);
const shown = (e) => {
const r = e.getBoundingClientRect();
if (!r.width || !r.height) return false;
@@ -58,9 +67,19 @@ function outline() {
if (lines.length >= MAX || e.hasAttribute?.("data-ui-preview")) return;
const tag = e.tagName.toLowerCase();
if (["script", "style", "svg", "link", "meta", "noscript", "template"].includes(tag) || !shown(e)) return;
const cls = [...e.classList].slice(0, 4).map((c) => "." + c).join("");
const data = [...e.attributes].filter((a) => a.name.startsWith("data-") && a.value.length < 40).slice(0, 3).map((a) => `[${a.name}="${a.value}"]`).join("");
const aria = ["role", "aria-label", "title", "placeholder", "type"].map((k) => e.getAttribute(k) ? `${k}="${e.getAttribute(k).slice(0, 40)}"` : "").filter(Boolean).join(" ");
const cls = [...e.classList]
.slice(0, 4)
.map((c) => "." + c)
.join("");
const data = [...e.attributes]
.filter((a) => a.name.startsWith("data-") && a.value.length < 40)
.slice(0, 3)
.map((a) => `[${a.name}="${a.value}"]`)
.join("");
const aria = ["role", "aria-label", "title", "placeholder", "type"]
.map((k) => (e.getAttribute(k) ? `${k}="${e.getAttribute(k).slice(0, 40)}"` : ""))
.filter(Boolean)
.join(" ");
const text = own(e);
const r = e.getBoundingClientRect();
const away = r.top >= fold ? " (below the fold)" : "";
@@ -75,8 +94,7 @@ function outline() {
}
async function screenText(page) {
return page.evaluate(() => document.documentElement.innerText + "\n" +
[...document.querySelectorAll("input, textarea")].map((e) => e.value).join("\n")).catch(() => "");
return page.evaluate(() => document.documentElement.innerText + "\n" + [...document.querySelectorAll("input, textarea")].map((e) => e.value).join("\n")).catch(() => "");
}
async function checkLeak(page, where) {
if (!secrets.length) return;
@@ -109,8 +127,12 @@ async function askPlanner(messages) {
if (!res.ok) throw new Error(`planner API ${res.status}: ${(await res.text()).slice(0, 300)}`);
const j = await res.json();
const text = j.choices?.[0]?.message?.content ?? "";
try { return JSON.parse(text.replace(/^```(?:json)?\s*|\s+```$/g, "")); }
catch (e) { last = new Error(`reply not JSON (${j.choices?.[0]?.finish_reason}): ${e.message}`); log(last.message, "— asking again"); }
try {
return JSON.parse(text.replace(/^```(?:json)?\s*|\s+```$/g, ""));
} catch (e) {
last = new Error(`reply not JSON (${j.choices?.[0]?.finish_reason}): ${e.message}`);
log(last.message, "— asking again");
}
}
throw last;
}
@@ -141,16 +163,22 @@ async function readTree(root) {
async function codeContext(diff, src) {
if (!src) return "";
const changed = [...new Set([...diff.matchAll(/^diff --git a\/(\S+) b\/\1$/gm)].map((m) => m[1]))]
.filter((f) => /^apps\/desktop\/src\//.test(f) && !SKIP.test(f.slice("apps/desktop/src/".length - 1)) && !SKIP.test(f));
const changed = [...new Set([...diff.matchAll(/^diff --git a\/(\S+) b\/\1$/gm)].map((m) => m[1]))].filter((f) => /^apps\/desktop\/src\//.test(f) && !SKIP.test(f.slice("apps/desktop/src/".length - 1)) && !SKIP.test(f));
// 1. the changed files
let out = "";
for (const f of changed.slice(0, 12)) {
let body;
try { body = await fs.readFile(path.join(src, f), "utf8"); } catch { continue; }
try {
body = await fs.readFile(path.join(src, f), "utf8");
} catch {
continue;
}
const chunk = `--- ${f}\n${body.length > 8000 ? body.slice(0, 8000) + "\n… (cut)" : body}\n`;
if (out.length + chunk.length > 20e3) { out += "… (cut)\n"; break; }
if (out.length + chunk.length > 20e3) {
out += "… (cut)\n";
break;
}
out += chunk;
}
@@ -170,16 +198,14 @@ async function codeContext(diff, src) {
let entries = "";
for (const name of components.slice(0, 6)) {
const kebab = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
const re = new RegExp(
"import\\s+" + name + "\\b" +
"|from\\s+[\"'][^\"']*/" + name + "(\\.vue)?[\"']" +
"|import\\(\\s*[\"'][^\"']*/" + name + "(\\.vue)?[\"']\\s*\\)" +
"|<" + kebab + "[\\s>/]|<" + name + "[\\s>/]");
const re = new RegExp("import\\s+" + name + "\\b" + "|from\\s+[\"'][^\"']*/" + name + "(\\.vue)?[\"']" + "|import\\(\\s*[\"'][^\"']*/" + name + "(\\.vue)?[\"']\\s*\\)" + "|<" + kebab + "[\\s>/]|<" + name + "[\\s>/]");
const flags = new Map(); // caller file -> Set of line numbers
for (const p of files) {
const t = await read(p);
if (!t || rel(p).endsWith(`/${name}.vue`)) continue;
t.split("\n").forEach((l, i) => { if (re.test(l)) (flags.get(p) ?? flags.set(p, new Set()).get(p)).add(i); });
t.split("\n").forEach((l, i) => {
if (re.test(l)) (flags.get(p) ?? flags.set(p, new Set()).get(p)).add(i);
});
}
for (const [p, lines] of flags) {
// a dynamic import renames the component (const X = defineAsyncComponent):
@@ -194,14 +220,19 @@ async function codeContext(diff, src) {
}
if (aliases.size > 1) {
const tagRe = new RegExp("<(?:" + [...aliases].join("|") + "|" + [...aliases].map((a) => a.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()).join("|") + ")[\\s>/]");
linesArr.forEach((l, i) => { if (tagRe.test(l)) lines.add(i); });
linesArr.forEach((l, i) => {
if (tagRe.test(l)) lines.add(i);
});
}
// the usage with its v-if/v-show/:open condition, and identifiers in it
const span = [];
for (const i of lines) span.push(linesArr.slice(Math.max(0, i - 6), i + 14).join("\n"));
const cond = span.join("\n");
const chunk = `--- ${rel(p)} renders ${name} (how it is shown):\n${cond.slice(0, 2500)}\n`;
if (entries.length + chunk.length > 9e3) { entries += "… (cut)\n"; break; }
if (entries.length + chunk.length > 9e3) {
entries += "… (cut)\n";
break;
}
entries += chunk;
// who flips those conditions: identifiers in v-if / :open / v-model near the usage
const ids = [...new Set([...cond.matchAll(/(?:v-if|v-show|:open|v-model:open|:model-value)="\s*([A-Za-z_$][\w$.]*)/g)].map((m) => m[1]))].slice(0, 4);
@@ -270,9 +301,10 @@ async function glide(page, x, y) {
const n = Math.max(8, Math.round(ms / 16));
const from = { ...mouse };
for (let i = 1; i <= n; i++) {
const t = i / n, e = t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
const t = i / n,
e = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
// a slight arc, not a ruler line
const bend = Math.sin(Math.PI * t) * Math.min(40, d * .08);
const bend = Math.sin(Math.PI * t) * Math.min(40, d * 0.08);
const nx = from.x + (x - from.x) * e - ((y - from.y) / (d || 1)) * bend;
const ny = from.y + (y - from.y) * e + ((x - from.x) / (d || 1)) * bend;
await page.mouse.move(nx, ny);
@@ -319,11 +351,14 @@ async function locate(page, step) {
const el = await hit.evaluateHandle((n, text) => {
for (;;) {
const kids = [...n.children].filter((c) => (c.textContent || "").includes(text) && c.getBoundingClientRect().width > 0);
if (kids.length === 1) n = kids[0]; else return n;
if (kids.length === 1) n = kids[0];
else return n;
}
}, text);
return el.asElement() || hit;
} catch { return hit; }
} catch {
return hit;
}
};
let loc = text ? all.filter({ hasText: text }) : all;
let hit = await visible(loc);
@@ -356,7 +391,6 @@ async function locate(page, step) {
throw new Error(`nothing visible matches ${step.target}${step.text ? ` with "${step.text}"` : ""} (${await loc.count()} in the page)`);
}
// Out of sight is scrolled to as a reader would, with the wheel over what
// scrolls. Each view that hides it, the innermost first, is wheeled in small
// steps until the element is in the middle of it, or at its top when it's
@@ -378,19 +412,22 @@ async function bringIntoView(page, loc) {
const root = s === document.scrollingElement;
const b = root ? clip : s.getBoundingClientRect();
// what of it shows: inside the window and every view around it
let top = Math.max(b.top, 0), bottom = Math.min(b.bottom, innerHeight);
let left = Math.max(b.left, 0), right = Math.min(b.right, innerWidth);
let top = Math.max(b.top, 0),
bottom = Math.min(b.bottom, innerHeight);
let left = Math.max(b.left, 0),
right = Math.min(b.right, innerWidth);
for (let p = s.parentElement; p && !root; p = p.parentElement) {
if (!scrollers.includes(p) || p === document.scrollingElement) continue;
const pb = p.getBoundingClientRect();
top = Math.max(top, pb.top); bottom = Math.min(bottom, pb.bottom);
left = Math.max(left, pb.left); right = Math.min(right, pb.right);
top = Math.max(top, pb.top);
bottom = Math.min(bottom, pb.bottom);
left = Math.max(left, pb.left);
right = Math.min(right, pb.right);
}
const room = bottom - top - 2 * PAD;
const want = r.height <= room ? (r.top + r.bottom) / 2 - (top + bottom) / 2 : r.top - (top + PAD);
const shown = r.top >= top + Math.min(PAD, Math.max(0, room - r.height) / 2) && (r.height <= room ? r.bottom <= bottom - PAD : r.top <= top + PAD * 2);
out.push({ shown, dy: Math.max(-s.scrollTop, Math.min(s.scrollHeight - s.clientHeight - s.scrollTop, want)),
x: (left + right) / 2, y: (top + bottom) / 2, seen: bottom - top > 20 && right - left > 20 });
out.push({ shown, dy: Math.max(-s.scrollTop, Math.min(s.scrollHeight - s.clientHeight - s.scrollTop, want)), x: (left + right) / 2, y: (top + bottom) / 2, seen: bottom - top > 20 && right - left > 20 });
}
return out;
}, PAD);
@@ -416,7 +453,8 @@ async function pointAt(page, loc) {
await bringIntoView(page, loc);
const b = await loc.boundingBox();
if (!b) throw new Error("the element has no box");
const x = b.x + Math.min(b.width / 2, 60), y = b.y + b.height / 2;
const x = b.x + Math.min(b.width / 2, 60),
y = b.y + b.height / 2;
await glide(page, x, y);
return b;
}
@@ -425,10 +463,16 @@ async function pointAt(page, loc) {
// the seeded connections painted in, is ready
async function settle(page) {
await page.waitForLoadState("load").catch(() => {});
await page.waitForFunction(() => {
const txt = document.body.innerText || "";
return txt.includes("新建连接") || txt.includes("New Connection") || document.querySelectorAll("button").length >= 6;
}, null, { timeout: 20000 }).catch(() => {});
await page
.waitForFunction(
() => {
const txt = document.body.innerText || "";
return txt.includes("新建连接") || txt.includes("New Connection") || document.querySelectorAll("button").length >= 6;
},
null,
{ timeout: 20000 },
)
.catch(() => {});
await sleep(1500);
}
@@ -440,13 +484,18 @@ async function runStep(page, step, scene) {
switch (step.do) {
case "click": {
const loc = await locate(page, step);
const label = (await loc.innerText().catch(() => "")) + " " + (await loc.getAttribute("title").catch(() => "") || "");
const label = (await loc.innerText().catch(() => "")) + " " + ((await loc.getAttribute("title").catch(() => "")) || "");
if (FORBIDDEN.test(label)) throw new Error(`won't press "${label.trim().slice(0, 40)}"`);
await pointAt(page, loc);
await sleep(180);
if (step.dblclick) {
await page.mouse.down(); await sleep(60); await page.mouse.up();
await sleep(120); await page.mouse.down(); await sleep(60); await page.mouse.up();
await page.mouse.down();
await sleep(60);
await page.mouse.up();
await sleep(120);
await page.mouse.down();
await sleep(60);
await page.mouse.up();
} else {
await page.mouse.down();
await sleep(90);
@@ -465,7 +514,9 @@ async function runStep(page, step, scene) {
case "type": {
const loc = await locate(page, step);
await pointAt(page, loc);
await page.mouse.down(); await sleep(80); await page.mouse.up();
await page.mouse.down();
await sleep(80);
await page.mouse.up();
await loc.fill("");
await page.keyboard.type(String(step.value ?? ""), { delay: 70 });
await sleep(600);
@@ -517,7 +568,8 @@ async function runStep(page, step, scene) {
if (b) {
// the target with room around it, never so small that a button
// is shown without where it is
const w = Math.min(VIEW.width, Math.max(560, b.width + 48)), h = Math.min(VIEW.height, Math.max(340, b.height + 48));
const w = Math.min(VIEW.width, Math.max(560, b.width + 48)),
h = Math.min(VIEW.height, Math.max(340, b.height + 48));
const x = Math.min(VIEW.width - w, Math.max(0, b.x + b.width / 2 - w / 2));
const y = Math.min(VIEW.height - h, Math.max(0, b.y + b.height / 2 - h / 2));
clip = { x, y, width: w, height: h };
@@ -553,7 +605,9 @@ async function main() {
await lp.goto(BASE);
await settle(lp);
outlines.app = await lp.evaluate(outline);
} catch (e) { outlines.app = `(failed to load: ${e.message})`; }
} catch (e) {
outlines.app = `(failed to load: ${e.message})`;
}
await look.close();
const MAXDIFF = 150e3;
@@ -561,13 +615,18 @@ async function main() {
log(`code context: ${code.length} characters`);
const ask = [
{ role: "system", content: GUIDE },
{ role: "user", content: `PR title: ${env("PR_TITLE", "")}\n\nPR description:\n${body.slice(0, 6000)}\n\nDiff${diff.length > MAXDIFF ? " (cut)" : ""}:\n${diff.slice(0, MAXDIFF)}\n\n` +
`=== outline: app ===\n${outlines.app}` +
(code ? `\n\n=== the changed frontend files (the PR's version) ===\n${code}` : "") },
{
role: "user",
content: `PR title: ${env("PR_TITLE", "")}\n\nPR description:\n${body.slice(0, 6000)}\n\nDiff${diff.length > MAXDIFF ? " (cut)" : ""}:\n${diff.slice(0, MAXDIFF)}\n\n` + `=== outline: app ===\n${outlines.app}` + (code ? `\n\n=== the changed frontend files (the PR's version) ===\n${code}` : ""),
},
];
let plan;
try { plan = env("PLAN_FILE") ? JSON.parse(await fs.readFile(env("PLAN_FILE"), "utf8")) : await askPlanner(ask); }
catch (e) { plan = null; manifest.errors.push(`plan: ${e.message}`); }
try {
plan = env("PLAN_FILE") ? JSON.parse(await fs.readFile(env("PLAN_FILE"), "utf8")) : await askPlanner(ask);
} catch (e) {
plan = null;
manifest.errors.push(`plan: ${e.message}`);
}
if (!plan || plan.ui_change === false || !plan.scenes?.length) {
manifest.summary = plan?.summary || "";
manifest.skipped = plan ? "no visible UI change found in the diff" : "no plan";
@@ -582,7 +641,8 @@ async function main() {
if (plan.scenes.length) {
const rec = await browser.newContext({ ...ctxOpts, recordVideo: { dir: path.join(OUT, "raw"), size: VIEW } });
await rec.addInitScript({ path: path.join(here, "cursor.js") });
let repairs = 0, interactive = false;
let repairs = 0,
interactive = false;
// one take per scene: every scene starts from the app as it opens, and
// its video keeps only what happens once the app has settled — the
// load never reaches the final cut, so a scene change reads as a cut,
@@ -613,8 +673,9 @@ async function main() {
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
if (["click", "hover", "type", "press", "scroll", "upload"].includes(step.do)) interactive = true;
try { await runStep(page, step, scene); }
catch (e) {
try {
await runStep(page, step, scene);
} catch (e) {
if (manifest.leak) break;
manifest.errors.push(`${scene.title} · ${step.do} ${step.target || step.name || ""}: ${e.message.split("\n")[0]}`);
log("step failed:", e.message);
@@ -640,13 +701,27 @@ async function main() {
repairs++;
// the page as it is now, and what went wrong: the rest of the scene again
try {
const fix = await askPlanner([...ask, { role: "assistant", content: JSON.stringify(plan) },
{ role: "user", content: `Step ${i + 1} of scene "${scene.title}" failed: ${e.message.split("\n")[0]}\nThe page now:\n${await page.evaluate(outline)}\n\nIf the failed selector came from the diff, remember the changed-file paths above say which dialog, panel or component holds it — a different one may have to be opened (or closed) first, not just a different selector for this page. Reply with JSON {"steps": [...]}: the steps to do instead of that one and the ones after it in this scene.` }]);
if (Array.isArray(fix.steps)) { steps = [...steps.slice(0, i + 1), ...fix.steps.slice(0, 20)]; log("repaired:", JSON.stringify(fix.steps)); }
} catch (e2) { manifest.errors.push(`repair: ${e2.message}`); }
const fix = await askPlanner([
...ask,
{ role: "assistant", content: JSON.stringify(plan) },
{
role: "user",
content: `Step ${i + 1} of scene "${scene.title}" failed: ${e.message.split("\n")[0]}\nThe page now:\n${await page.evaluate(outline)}\n\nIf the failed selector came from the diff, remember the changed-file paths above say which dialog, panel or component holds it — a different one may have to be opened (or closed) first, not just a different selector for this page. Reply with JSON {"steps": [...]}: the steps to do instead of that one and the ones after it in this scene.`,
},
]);
if (Array.isArray(fix.steps)) {
steps = [...steps.slice(0, i + 1), ...fix.steps.slice(0, 20)];
log("repaired:", JSON.stringify(fix.steps));
}
} catch (e2) {
manifest.errors.push(`repair: ${e2.message}`);
}
}
}
if (manifest.leak) { await page.close().catch(() => {}); break; }
if (manifest.leak) {
await page.close().catch(() => {});
break;
}
await caption(page, "");
await sleep(600);
takes.push({ video: await page.video().path(), from: Math.max(0, (ready - began) / 1000 - 0.2) });
@@ -659,9 +734,7 @@ async function main() {
// the takes joined, each already trimmed of its load
const args = ["-y", "-loglevel", "error"];
const pins = takes.map((t, i) => (args.push("-ss", t.from.toFixed(2), "-i", t.video), `[${i}:v]`));
args.push("-filter_complex", `${pins.join("")}concat=n=${takes.length}:v=1:a=0[v]`, "-map", "[v]",
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30",
path.join(OUT, "preview.mp4"));
args.push("-filter_complex", `${pins.join("")}concat=n=${takes.length}:v=1:a=0[v]`, "-map", "[v]", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30", path.join(OUT, "preview.mp4"));
execFileSync("ffmpeg", args);
manifest.video = "preview.mp4";
manifest.poster = await poster(browser);
@@ -671,7 +744,8 @@ async function main() {
await browser.close();
if (manifest.leak) {
for (const f of await fs.readdir(OUT)) await fs.rm(path.join(OUT, f), { recursive: true, force: true });
manifest.scenes = []; manifest.video = manifest.poster = null;
manifest.scenes = [];
manifest.video = manifest.poster = null;
}
await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2));
log("done:", JSON.stringify({ shots: manifest.scenes.reduce((n, s) => n + s.shots.length, 0), video: manifest.video, errors: manifest.errors.length, leak: manifest.leak }));
+39 -12
View File
@@ -31,14 +31,22 @@ const DBS = [
name: "商店 Shop (SQLite)",
note: "订单、商品与客户示例库",
tables: "customers · products · orders · audit_log(含视图 v_daily_revenue、外键与触发器)",
warm: ["SELECT * FROM orders ORDER BY created_at DESC LIMIT 50", "SELECT c.city, COUNT(*) AS orders, SUM(o.amount) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC", "SELECT category, COUNT(*) AS n, ROUND(AVG(price), 2) AS avg_price\nFROM products WHERE listed = 1 GROUP BY category ORDER BY n DESC"],
warm: [
"SELECT * FROM orders ORDER BY created_at DESC LIMIT 50",
"SELECT c.city, COUNT(*) AS orders, SUM(o.amount) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC",
"SELECT category, COUNT(*) AS n, ROUND(AVG(price), 2) AS avg_price\nFROM products WHERE listed = 1 GROUP BY category ORDER BY n DESC",
],
},
{
file: "dbx-analytics.sqlite",
name: "分析 Analytics (SQLite)",
note: "事件流与每日指标",
tables: "events(5000 行,看分页)· daily_stats(90 天)",
warm: ["SELECT event_type, COUNT(*) AS n, ROUND(AVG(duration_ms)) AS avg_ms\nFROM events GROUP BY event_type ORDER BY n DESC", "SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14", "SELECT * FROM daily_stats ORDER BY stat_date DESC LIMIT 30"],
warm: [
"SELECT event_type, COUNT(*) AS n, ROUND(AVG(duration_ms)) AS avg_ms\nFROM events GROUP BY event_type ORDER BY n DESC",
"SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14",
"SELECT * FROM daily_stats ORDER BY stat_date DESC LIMIT 30",
],
},
];
@@ -112,7 +120,11 @@ async function api(base, p, init = {}) {
});
const text = await res.text();
if (!res.ok) throw new Error(`${init.method || "GET"} ${p}: ${res.status} ${text.slice(0, 300)}`);
try { return JSON.parse(text); } catch { return text; }
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function seedApi() {
@@ -166,8 +178,14 @@ async function seedApi() {
await api(base, "/api/saved-sql", {
method: "POST",
body: JSON.stringify({
...s, connectionId: conns[i % conns.length].id, folderId: folder?.id || "seed-folder-daily",
database: "main", orderIndex: i, openCount: 3 + i, createdAt: iso(36e5 * (70 - i)), updatedAt: iso(36e5 * (24 - i * 6)),
...s,
connectionId: conns[i % conns.length].id,
folderId: folder?.id || "seed-folder-daily",
database: "main",
orderIndex: i,
openCount: 3 + i,
createdAt: iso(36e5 * (70 - i)),
updatedAt: iso(36e5 * (24 - i * 6)),
}),
}).catch((e) => console.log("[ui-preview] saved-sql:", e.message));
}
@@ -188,9 +206,17 @@ async function seedApi() {
method: "POST",
body: JSON.stringify({
entry: {
id: `seed-h-${Math.random().toString(36).slice(2, 10)}`, connection_id: c.id, connection_name: c.name,
database: "main", sql, executed_at: iso(36e5 * (2 + Math.random() * 60)), execution_time_ms: ms, success: ok,
error: ok ? null : "demo entry: would have modified rows (not really run)", activity_kind: "query", source: "editor",
id: `seed-h-${Math.random().toString(36).slice(2, 10)}`,
connection_id: c.id,
connection_name: c.name,
database: "main",
sql,
executed_at: iso(36e5 * (2 + Math.random() * 60)),
execution_time_ms: ms,
success: ok,
error: ok ? null : "demo entry: would have modified rows (not really run)",
activity_kind: "query",
source: "editor",
},
}),
}).catch((e) => console.log("[ui-preview] history:", e.message));
@@ -205,12 +231,13 @@ if (cmd === "dbs") {
python(dir);
// a small importable file, for import/mapping dialogs (record.mjs's
// "upload" step resolves names inside this directory)
fs.writeFileSync(path.join(dir, "sample.csv"),
"name,category,price,stock\n" +
Array.from({ length: 8 }, (_, i) => `键盘 K${i + 100},键盘,${(199 + i * 23).toFixed(2)},${40 - i * 3}`).join("\n") + "\n");
fs.writeFileSync(path.join(dir, "sample.csv"), "name,category,price,stock\n" + Array.from({ length: 8 }, (_, i) => `键盘 K${i + 100},键盘,${(199 + i * 23).toFixed(2)},${40 - i * 3}`).join("\n") + "\n");
console.log("[ui-preview] databases written to", dir);
} else if (cmd === "api") {
seedApi().catch((e) => { console.error(e); process.exit(1); });
seedApi().catch((e) => {
console.error(e);
process.exit(1);
});
} else {
console.error("usage: seed.mjs dbs <dir> | seed.mjs api <base-url> <dir> [--as <server path>]");
process.exit(1);
+42 -6
View File
@@ -1793,12 +1793,48 @@ jobs:
cat RELEASE_NOTES.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
token: ${{ steps.release-token.outputs.token }}
files: release/*
body_path: RELEASE_NOTES.md
make_latest: false
env:
GH_TOKEN: ${{ steps.release-token.outputs.token }}
TAG_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
# Idempotent release publish: create the release only when missing and
# upload only assets it does not have yet, in small spaced batches.
# Re-uploading the full set on every retry trips GitHub secondary
# rate limits and dropped connections strand the whole job.
if gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
# --draft=false: a tag deleted mid-recovery leaves the release as a
# draft, which /releases/tags then 404s for reuse jobs and users.
gh release edit "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
--notes-file RELEASE_NOTES.md --draft=false --latest=false >/dev/null
else
gh release create "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
--verify-tag --title "$TAG_NAME" \
--notes-file RELEASE_NOTES.md --latest=false
fi
mapfile -t existing < <(gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
--json assets --jq '.assets[].name')
declare -A have=()
for name in "${existing[@]:-}"; do
[ -n "$name" ] && have["$name"]=1
done
missing=()
for f in release/*; do
[ -f "$f" ] || continue
[ -n "${have["$(basename "$f")"]:-}" ] || missing+=("$f")
done
echo "release already has ${#existing[@]} assets; uploading ${#missing[@]} missing"
batch_size=5
i=0
while [ "$i" -lt "${#missing[@]}" ]; do
gh release upload "$TAG_NAME" "${missing[@]:$i:$batch_size}" --repo "$GITHUB_REPOSITORY"
i=$((i + batch_size))
if [ "$i" -lt "${#missing[@]}" ]; then sleep 20; fi
done
echo "uploaded ${#missing[@]} assets in batches of $batch_size"
- name: Update agents-latest GitHub release
env:
+175
View File
@@ -0,0 +1,175 @@
name: Cache Keepalive
# The shared Linux target caches (rust-test / packages / fmt-clippy, ~2-3 GB
# each) are only saved from main runs. When the changes filter skips those
# jobs for a few days, the entries go idle and GitHub's 10 GB LRU quota
# evicts them, forcing fully cold builds for every PR (including fork PRs
# that cannot access the S3 sccache backend). This workflow periodically
# refreshes the main-saved caches on schedule.
on:
schedule:
# Every Tuesday 01:30 UTC, ahead of typical PR activity hours.
- cron: "30 1 * * 2"
workflow_dispatch:
permissions:
actions: write
contents: read
concurrency:
group: cache-keepalive
cancel-in-progress: false
env:
RUSTFLAGS: -C debuginfo=line-tables-only
jobs:
rust-test-keepalive:
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
# sccache cannot reuse Cargo incremental artifacts, so avoid generating them in CI.
CARGO_INCREMENTAL: "0"
# Deep async export tests exceed the Rust test harness's default thread stack on Linux.
RUST_MIN_STACK: 8388608
RUSTC_WRAPPER: sccache
# "workspace" matches the full-coverage matrix group used on main pushes.
RUST_TEST_GROUP: workspace
RUST_FEATURE_MODE: full
steps:
- uses: actions/checkout@v7
- name: Install system dependencies
run: |
sudo timeout --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
update
sudo timeout --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
# Pin mold because runner image updates can change bundled C++ links.
# See the ci.yml rust-test note for the pinned upstream build.
- name: Install mold linker
run: |
curl -fsSL https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz \
| sudo tar -C /usr/local --strip-components=1 -xz
mold --version
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
- name: Setup sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: "v0.16.0"
# Mirrors the ci.yml rust-test configuration: S3 when secrets are
# available (they are on schedule runs), otherwise no cross-run caching.
- name: Configure sccache
shell: bash
env:
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
run: |
{
if [[ -n "$CACHE_BUCKET" ]]; then
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
echo "SCCACHE_REGION=${CACHE_REGION}"
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
fi
# Keep the server alive through post-compile test/lint phases so
# "Show sccache stats" reflects the real counters.
echo "SCCACHE_IDLE_TIMEOUT=0"
# Also cache the C/C++ compilation of -sys crates. These jobs are
# native Linux builds, so plain CC/CXX select the target compiler.
echo "CC=${SCCACHE_PATH} cc"
echo "CXX=${SCCACHE_PATH} c++"
} >> "$GITHUB_ENV"
# Same key recipe as ci.yml rust-test so the restored/saved entry is the
# one PR jobs look up. save-if is satisfied: scheduled runs are on main.
- name: Rust cache
env:
RUST_FEATURE_MODE: ""
RUST_TEST_GROUP: ""
uses: swatinem/rust-cache@v2
with:
prefix-key: v1-rust-complete
workspaces: "./ -> target"
shared-key: ci-rust-test-v2-x86_64-unknown-linux-gnu
# Test linking dominates this job and sccache cannot cache those crate types.
cache-workspace-crates: true
cache-on-failure: false
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install nextest
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2.68.13
with:
tool: cargo-nextest@0.9.137
# mold -run wraps the whole process tree (see the ci.yml rust-test note).
- name: Nextest
run: |-
mold -run node .github/scripts/ci-rust.mjs test "$RUST_TEST_GROUP" "$RUST_FEATURE_MODE"
- name: Rust doc tests
run: |-
mold -run node .github/scripts/ci-rust.mjs doctest "$RUST_TEST_GROUP" "$RUST_FEATURE_MODE"
- name: Show sccache stats
if: always()
continue-on-error: true
run: ${SCCACHE_PATH} --show-stats
prune-sccache-entries:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Prune stale sccache GHA cache entries
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# The Windows jobs only enable the GHA sccache backend when S3
# secrets are absent, but release workflows can still accumulate
# thousands of small sccache/* entries that crowd the 10 GB quota.
# The GHA backend keys on content hash, so entries nobody touched
# for a week can be dropped without losing reachable warm caches.
cutoff_days=7
cutoff=$(date -u -d "-${cutoff_days} days" +%s)
page=1
ids=()
while :; do
resp=$(gh api "repos/${GITHUB_REPOSITORY}/actions/caches?key=sccache&per_page=100&page=${page}")
count=$(printf '%s' "$resp" | jq '.actions_caches | length')
[[ "$count" -eq 0 ]] && break
while read -r id accessed; do
[[ "$(date -u -d "$accessed" +%s)" -lt "$cutoff" ]] || continue
ids+=("$id")
done < <(printf '%s' "$resp" | jq -r '.actions_caches[] | "\(.id) \(.last_accessed_at)"')
page=$((page + 1))
done
# Collect first, delete after: removing entries mid-pagination would
# shift later pages and skip candidates.
pruned=0
for id in "${ids[@]}"; do
if gh api -X DELETE "repos/${GITHUB_REPOSITORY}/actions/caches/${id}"; then
pruned=$((pruned + 1))
fi
done
echo "Pruned ${pruned} sccache cache entries idle for over ${cutoff_days} days."
+191 -59
View File
@@ -7,8 +7,9 @@ on:
branches: [ main ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner && format('pr-author-{0}-{1}', github.event.pull_request.user.id, (endsWith(github.event.pull_request.number, '0') || endsWith(github.event.pull_request.number, '2') || endsWith(github.event.pull_request.number, '4') || endsWith(github.event.pull_request.number, '6') || endsWith(github.event.pull_request.number, '8')) && 'even' || 'odd') || github.ref }}
cancel-in-progress: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login == github.repository_owner }}
queue: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner && 'max' || 'single' }}
env:
RUSTFLAGS: -C debuginfo=line-tables-only
@@ -27,12 +28,12 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.27.0
version: 10.34.7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 22.13.0
node-version: 22.23.3
cache: pnpm
- name: Install frontend dependencies
@@ -76,12 +77,12 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.27.0
version: 10.34.7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 22.13.0
node-version: 22.23.3
cache: pnpm
- name: Install frontend dependencies
@@ -96,6 +97,8 @@ jobs:
runs-on: ubuntu-24.04
env:
NPM_CONFIG_LOGLEVEL: error
# Sharded test runs across 900+ files exceed 4 GiB; public Linux runners provide 16 GiB RAM.
NODE_OPTIONS: --max-old-space-size=8192
strategy:
fail-fast: false
matrix:
@@ -107,12 +110,12 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.27.0
version: 10.34.7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 22.13.0
node-version: 22.23.3
cache: pnpm
- name: Install frontend dependencies
@@ -153,10 +156,23 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 22.13.0
node-version: 22.23.3
- name: GitHub script tests
run: node --test .github/scripts/*.test.mjs
env:
FAST_CHECKS: ${{ needs.changes.outputs.fast }}
run: |
tests=()
for file in .github/scripts/*.test.mjs; do
if [[ "$FAST_CHECKS" == true && "${file##*/}" == ci-* ]]; then
continue
fi
tests+=("$file")
done
node --test "${tests[@]}"
- name: Docker mirror config tests
run: python3 .github/scripts/configure-docker-mirror.test.py
- name: PowerShell MCP installer tests
shell: pwsh
@@ -178,12 +194,12 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.27.0
version: 10.34.7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 22.13.0
node-version: 22.23.3
cache: pnpm
- name: Install system dependencies
@@ -217,7 +233,7 @@ jobs:
uses: dtolnay/rust-toolchain@1.97.1
- name: Install nextest
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3 # v2.68.13
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2.68.13
with:
tool: cargo-nextest@0.9.137
@@ -287,14 +303,16 @@ jobs:
windows-standard-check:
needs: changes
if: needs.changes.outputs.windows_win7_bundle == 'true'
if: needs.changes.outputs.windows_standard == 'true'
runs-on: windows-2022
timeout-minutes: 45
env:
CARGO_INCREMENTAL: "0"
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
# Prefer S3 when available; GitHub's 10 GB cache quota evicts the shared
# Linux target caches when thousands of GHA sccache entries pile up.
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
SCCACHE_GHA_VERSION: windows-standard-v1
steps:
- uses: actions/checkout@v7
@@ -306,6 +324,30 @@ jobs:
with:
version: "v0.16.0"
- name: Configure S3 sccache
if: env.SCCACHE_GHA_ENABLED != 'true'
shell: bash
env:
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
run: |
{
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
echo "SCCACHE_REGION=${CACHE_REGION}"
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
# Keep the server alive through post-compile checks so
# "Show sccache stats" reflects the real counters.
echo "SCCACHE_IDLE_TIMEOUT=0"
} >> "$GITHUB_ENV"
- name: Check standard Windows dependency path
run: cargo check --locked --package dbx --no-default-features --target x86_64-pc-windows-msvc
@@ -325,7 +367,9 @@ jobs:
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
# Prefer S3 when available; GitHub's 10 GB cache quota evicts the shared
# Linux target caches when thousands of GHA sccache entries pile up.
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1
SCCACHE_IDLE_TIMEOUT: "0"
# Single source for the job's nightly pin: the toolchain step and the
@@ -337,12 +381,12 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.27.0
version: 10.34.7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 22.13.0
node-version: 22.23.3
cache: pnpm
- name: Install frontend dependencies
@@ -358,6 +402,27 @@ jobs:
with:
version: "v0.16.0"
- name: Configure S3 sccache
if: env.SCCACHE_GHA_ENABLED != 'true'
shell: bash
env:
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
run: |
{
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
echo "SCCACHE_REGION=${CACHE_REGION}"
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
} >> "$GITHUB_ENV"
- name: Restore prebuilt Win7 AWS-LC
id: aws-lc-cache
uses: actions/cache@v4
@@ -588,6 +653,46 @@ jobs:
if-no-files-found: error
retention-days: 7
- name: Measure Agent RPC monomorphization
if: github.head_ref == 'perf/win7-agent-rpc-monomorphization'
shell: pwsh
run: |
$outputDir = Join-Path $env:RUNNER_TEMP "dbx-agent-rpc-mono"
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
$env:TAURI_CONFIG = Get-Content src-tauri/tauri.webview2-win7-fixed.conf.json -Raw
$env:RUSTC_WRAPPER = ""
$timer = [System.Diagnostics.Stopwatch]::StartNew()
cargo -Z host-config -Z target-applies-to-host -Z build-std=std,panic_abort `
--config .github/fixtures/win7-host-repro-config.toml `
rustc --locked --package dbx --lib --release --features custom-protocol `
--target x86_64-win7-windows-msvc -- `
"-Zdump-mono-stats=$outputDir" "-Zdump-mono-stats-format=json"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$timer.Stop()
$monoFile = Get-ChildItem $outputDir -File -Filter "*.mono_items.json" | Select-Object -First 1
if (!$monoFile) {
throw "dbx_lib monomorphization data is missing."
}
$items = Get-Content $monoFile.FullName -Raw | ConvertFrom-Json
"dbx_lib profile: $([math]::Round($timer.Elapsed.TotalSeconds, 2)) seconds" >> $env:GITHUB_STEP_SUMMARY
foreach ($symbol in @("AgentDriverClient::call_typed_with_timeout_and_cancel::{closure#0}", "AgentDriverClient::call_value_with_timeout_and_cancel::{closure#0}", "AgentRuntimeClient::call_typed::{closure#0}")) {
$item = $items | Where-Object { $_.name.EndsWith($symbol) } | Select-Object -First 1
if ($item) {
"$symbol`: $($item.instantiation_count) instances, estimate $($item.total_estimate)" >> $env:GITHUB_STEP_SUMMARY
}
}
- name: Upload Agent RPC monomorphization data
if: github.head_ref == 'perf/win7-agent-rpc-monomorphization'
uses: actions/upload-artifact@v6
with:
name: DBX-win7-agent-rpc-mono
path: ${{ runner.temp }}/dbx-agent-rpc-mono/*.mono_items.json
if-no-files-found: error
retention-days: 7
- name: Measure dbx library command registry expansion
if: vars.WIN7_REGISTRY_AB == 'true'
shell: pwsh
@@ -714,7 +819,9 @@ jobs:
CARGO_TARGET_DIR: ${{ github.workspace }}/target/duckdb-driver
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
# Prefer S3 when available; GitHub's 10 GB cache quota evicts the shared
# Linux target caches when thousands of GHA sccache entries pile up.
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
SCCACHE_GHA_VERSION: duckdb-windows-v1
# libduckdb-sys uses cc-rs for its bundled C++ sources.
CC: "sccache cl.exe"
@@ -741,6 +848,27 @@ jobs:
with:
version: "v0.16.0"
- name: Configure S3 sccache
if: env.SCCACHE_GHA_ENABLED != 'true'
shell: bash
env:
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
run: |
{
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
echo "SCCACHE_REGION=${CACHE_REGION}"
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
} >> "$GITHUB_ENV"
- name: Build DuckDB Windows driver
shell: pwsh
working-directory: agents/drivers/duckdb
@@ -869,6 +997,16 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Configure public Docker Hub cache
if: runner.os == 'Linux'
run: |
source /etc/os-release
[[ "$ID" == ubuntu ]] || exit 0
sudo python3 .github/scripts/configure-docker-mirror.py /etc/docker/daemon.json --validate
timeout 30s sudo systemctl restart docker
timeout 15s docker info --format '{{json .RegistryConfig.Mirrors}}' \
| python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
- name: Install system dependencies
run: |
sudo timeout --kill-after=30s 10m apt-get \
@@ -944,7 +1082,7 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install nextest
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
with:
tool: cargo-nextest@0.9.137
@@ -1077,7 +1215,7 @@ jobs:
changes:
runs-on: ubuntu-24.04
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
frontend: ${{ steps.filter.outputs.frontend == 'true' && steps.plan.outputs.contributor_snapshot_only != 'true' }}
packages: ${{ steps.filter.outputs.packages }}
rust: ${{ steps.plan.outputs.rust }}
rust_full: ${{ steps.plan.outputs.rust_full }}
@@ -1086,7 +1224,8 @@ jobs:
agents: ${{ steps.plan.outputs.agents }}
duckdb_windows: ${{ steps.filter.outputs.duckdb_windows == 'true' || steps.plan.outputs.duckdb_windows == 'true' }}
nix: ${{ steps.filter.outputs.nix }}
windows_win7_bundle: ${{ steps.filter.outputs.windows_win7_bundle }}
windows_standard: ${{ steps.plan.outputs.windows_standard }}
windows_win7_bundle: ${{ steps.plan.outputs.windows_win7_bundle }}
windows_win7_candidate: ${{ steps.plan.outputs.windows_win7_candidate }}
windows_win7_affected_packages: ${{ steps.plan.outputs.windows_win7_affected_packages }}
windows_win7_reasons: ${{ steps.plan.outputs.windows_win7_reasons }}
@@ -1105,14 +1244,15 @@ jobs:
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 1
fetch-depth: 2
- name: Fetch change base
id: change-base
env:
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
BEFORE_SHA: ${{ github.event.before }}
run: |
if [[ -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ]]; then
BASE_SHA="$(node .github/scripts/ci-change-base.mjs)"
if [[ -z "$BASE_SHA" ]]; then
exit 0
fi
git fetch --no-tags --depth=1 origin "$BASE_SHA"
@@ -1122,7 +1262,9 @@ jobs:
uses: dorny/paths-filter@v4
id: filter
with:
token: ''
base: ${{ steps.change-base.outputs.sha }}
ref: ${{ github.sha }}
filters: |
frontend:
- 'apps/desktop/**'
@@ -1205,35 +1347,16 @@ jobs:
- 'flake.lock'
- '.github/workflows/ci.yml'
- '.github/workflows/update-nix-pnpm-hash.yml'
windows_win7_bundle:
- '.github/scripts/assert-win7-pe-compat.ps1'
- '.github/scripts/assert-webview2-win7-loader.ps1'
- '.github/scripts/assert-win7-installer-content.ps1'
- '.github/scripts/assert-webview2-win7-runtime.ps1'
- '.github/scripts/prepare-webview2-win7-loader.ps1'
- '.github/scripts/prepare-webview2-win7-runtime.ps1'
- '.github/workflows/ci.yml'
- '.github/workflows/release.yml'
- 'src-tauri/tauri.webview2-win7-fixed.conf.json'
- 'src-tauri/build.rs'
- 'src-tauri/Cargo.toml'
- 'src-tauri/windows/nsis/**'
- 'src-tauri/src/commands/update.rs'
- 'crates/dbx-core/Cargo.toml'
- 'crates/dbx-drivers/**'
- 'crates/dbx-driver-*/**'
- 'crates/dbx-platform/**'
- 'crates/dbx-core/src/host/update.rs'
- 'Cargo.toml'
- 'Cargo.lock'
- 'vendor/ctor/**'
- 'vendor/dirs-sys/**'
- 'vendor/pageant/**'
- 'vendor/webview2-com-sys/**'
- 'vendor/wry/**'
github_scripts:
- '.github/scripts/**'
- 'docs/scripts/contributor-snapshot.mjs'
- 'docs/scripts/update-contributors.mjs'
- 'docs/public/install-mcp*'
- '.github/workflows/contributors.yml'
- '.github/workflows/issue-claim.yml'
- '.github/workflows/notify.yml'
- '.github/workflows/plugin-dev-host.yml'
- '.github/workflows/spam-comment-guard.yml'
- '.github/workflows/mcp-release.yml'
- '.github/workflows/publish-packages.yml'
- '.github/workflows/plugin-release-reusable.yml'
@@ -1246,7 +1369,6 @@ jobs:
BASE_SHA: ${{ steps.change-base.outputs.sha }}
RUST_CHANGED: ${{ steps.filter.outputs.rust }}
AGENTS_CHANGED: ${{ steps.filter.outputs.agents }}
WIN7_CURRENT: ${{ steps.filter.outputs.windows_win7_bundle }}
run: node .github/scripts/ci-plan.mjs
agents:
@@ -1283,7 +1405,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 22.13.0
node-version: 22.23.3
- name: CI planner and gate tests
run: node --test .github/scripts/ci-*.test.mjs
- name: Core architecture contracts
@@ -1293,7 +1415,7 @@ jobs:
if: needs.changes.outputs.rust == 'true'
uses: pnpm/action-setup@v6
with:
version: 10.27.0
version: 10.34.7
- name: Install generator dependencies
if: needs.changes.outputs.rust == 'true'
run: pnpm --filter dbx... install --frozen-lockfile --ignore-scripts
@@ -1319,7 +1441,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 22.13.0
node-version: 22.23.3
- name: Install packaging tools
run: |
sudo apt-get update
@@ -1439,7 +1561,7 @@ jobs:
cache-on-failure: false
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install nextest
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
with:
tool: cargo-nextest@0.9.137
# mold -run wraps the whole process tree (see the packages job note);
@@ -1492,7 +1614,7 @@ jobs:
run: bash ../.github/scripts/ci-agent-go.sh "$DRIVER" "$BINARY" "$RACE"
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix: ${{ fromJSON(needs.changes.outputs.agent_go) }}
agent-integration:
needs: [changes, fast-checks, agent-checks]
@@ -1503,6 +1625,16 @@ jobs:
working-directory: agents
steps:
- uses: actions/checkout@v7
- name: Configure public Docker Hub cache
if: runner.os == 'Linux'
working-directory: ${{ github.workspace }}
run: |
source /etc/os-release
[[ "$ID" == ubuntu ]] || exit 0
sudo python3 .github/scripts/configure-docker-mirror.py /etc/docker/daemon.json --validate
timeout 30s sudo systemctl restart docker
timeout 15s docker info --format '{{json .RegistryConfig.Mirrors}}' \
| python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
- name: Setup RocketMQ server Java
if: matrix.scenario == 'rocketmq'
uses: actions/setup-java@v6
@@ -1520,7 +1652,7 @@ jobs:
if: matrix.driver == 'tdengine'
- name: Install nextest
if: matrix.driver == 'tdengine'
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
with:
tool: cargo-nextest@0.9.137
- name: TDengine Rust cache
@@ -1540,7 +1672,7 @@ jobs:
name: agent-integration (${{ matrix.scenario }}, ${{ matrix.version }})
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix: ${{ fromJSON(needs.changes.outputs.agent_integration) }}
ci:
needs:
+1 -1
View File
@@ -2,7 +2,7 @@ name: Update Contributors
on:
schedule:
- cron: "0 18 * * *"
- cron: "0 18 * * 0"
workflow_dispatch:
permissions:
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
- name: Validate database environment tooling
run: |
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
- name: Autofill missing i18n entries
env:
+5 -1
View File
@@ -16,7 +16,11 @@ permissions:
jobs:
claim:
runs-on: ubuntu-latest
if: ${{ !github.event.issue.pull_request }}
if: >-
${{ !github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
(contains(github.event.comment.body, '/claim') ||
contains(github.event.comment.body, '/unclaim') ||
contains(github.event.comment.body, '/close')) }}
steps:
- uses: actions/checkout@v5
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
- name: Triage issue labels and title
run: node .github/scripts/label-issue-database.mjs
@@ -56,7 +56,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
- name: Synchronize database labels
run: node .github/scripts/label-issue-database.mjs
+8 -8
View File
@@ -35,7 +35,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
registry-url: https://registry.npmjs.org
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
@@ -159,7 +159,7 @@ jobs:
run: cargo check -p dbx-mcp -p dbx-cli --no-default-features
- name: Install nextest
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3 # v2.68.13
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2.68.13
with:
tool: cargo-nextest@0.9.137
@@ -440,7 +440,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
registry-url: https://registry.npmjs.org
- name: Publish platform package
@@ -486,7 +486,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
registry-url: https://registry.npmjs.org
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
@@ -689,7 +689,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
registry-url: https://registry.npmjs.org
- name: Sign macOS MCP binary
@@ -791,7 +791,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
registry-url: https://registry.npmjs.org
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
@@ -845,7 +845,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
registry-url: https://registry.npmjs.org
- name: Build native release archives from npm packages
@@ -953,7 +953,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
registry-url: https://registry.npmjs.org
- name: Build native CLI release archives from npm packages
+4 -2
View File
@@ -3,13 +3,15 @@ name: Notifications
on:
# issues:
# types: [opened]
pull_request_target:
types: [opened, closed]
# pull_request_target:
# types: [closed]
workflow_dispatch:
# Release 不在此通知:桌面正式版由 publish-packages.yml 在转正后发飞书卡片;
# 组件包(agents-v*/packages-v*)发布不进群。
jobs:
notify:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Build message
+5 -5
View File
@@ -43,7 +43,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
registry-url: https://registry.npmjs.org
- uses: dtolnay/rust-toolchain@stable
@@ -130,7 +130,7 @@ jobs:
fi
- name: Install nextest
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
with:
tool: cargo-nextest@0.9.137
@@ -329,7 +329,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
registry-url: https://registry.npmjs.org
- name: Publish platform package
@@ -357,7 +357,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
registry-url: https://registry.npmjs.org
- name: Prepare development runtime build dependencies
@@ -412,7 +412,7 @@ jobs:
steps:
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
registry-url: https://registry.npmjs.org
- uses: dtolnay/rust-toolchain@stable
+15 -3
View File
@@ -11,8 +11,22 @@ on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci --prefix plugins/sdk/dev-host
- run: npm run lint --prefix plugins/sdk/dev-host
- run: npm run fmt:check --prefix plugins/sdk/dev-host
test:
needs: checks
strategy:
fail-fast: false
matrix:
@@ -29,11 +43,9 @@ jobs:
go-version: '1.22'
cache: false
- run: npm ci --prefix plugins/sdk/dev-host
- run: npm run lint --prefix plugins/sdk/dev-host
- run: npm run fmt:check --prefix plugins/sdk/dev-host
- run: npm test --prefix plugins/sdk/dev-host
timeout-minutes: 5
- uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
- uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
with:
tool: cargo-nextest@0.9.137
- run: cargo nextest run --locked --manifest-path plugins/sdk/cli/Cargo.toml --no-fail-fast
+6 -3
View File
@@ -1,8 +1,11 @@
name: Pull Request Labels
on:
pull_request_target:
types: [opened, edited, synchronize, reopened, ready_for_review]
# Temporarily disabled (2026-10-10): informational labels only, no
# mechanical consumers, fires on every PR event. Uncomment to re-enable.
# pull_request_target:
# types: [opened, edited, synchronize, reopened, ready_for_review]
workflow_dispatch:
concurrency:
group: pull-request-labels-${{ github.event.pull_request.number }}
@@ -50,7 +53,7 @@ jobs:
if: steps.verify_head.outputs.stale != 'true'
uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
- name: Synchronize pull request labels
if: steps.verify_head.outputs.stale != 'true'
+22 -1
View File
@@ -581,7 +581,7 @@ jobs:
CARGO_INCREMENTAL: "0"
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1
SCCACHE_IDLE_TIMEOUT: "0"
VSIGN_CERT_HASH: ${{ vars.VSIGN_CERT_HASH }}
@@ -612,6 +612,27 @@ jobs:
with:
version: "v0.16.0"
- name: Configure S3 sccache
if: env.SCCACHE_GHA_ENABLED != 'true'
shell: bash
env:
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
run: |
{
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
echo "SCCACHE_REGION=${CACHE_REGION}"
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
} >> "$GITHUB_ENV"
- name: Prepare Win7-compatible WebView2 loader
shell: pwsh
run: ./.github/scripts/prepare-webview2-win7-loader.ps1
@@ -47,7 +47,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
- name: Backfill similar issue suggestions
run: node .github/scripts/backfill-similar-issues.mjs
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
- name: Suggest similar issues
run: node .github/scripts/suggest-similar-issues.mjs
+6 -1
View File
@@ -12,7 +12,12 @@ jobs:
delete-malicious-link:
runs-on: ubuntu-latest
# 跳过机器人与仓库 owner,避免误删维护者的正当评论
if: ${{ github.event.comment.user.type != 'Bot' && github.event.comment.user.login != github.repository_owner }}
if: >-
${{ github.event.comment.user.type != 'Bot' && github.event.comment.user.login != github.repository_owner &&
(contains(github.event.comment.body, '.zip') ||
contains(github.event.comment.body, '.7z') ||
contains(github.event.comment.body, '.exe') ||
contains(github.event.comment.body, '.scr')) }}
env:
# owner PAT:GITHUB_TOKEN 非 admin 删不掉别人的评论,必须用 owner 令牌
GH_TOKEN: ${{ secrets.PROJECT_AUTOMATION_TOKEN }}
@@ -22,7 +22,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22.13.0
node-version: 22.23.3
cache: pnpm
- name: Setup Java
+4 -2
View File
@@ -19,8 +19,10 @@ name: UI preview
# PR's own frontend build is what's on screen (DBX_STATIC_DIR).
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled]
# Temporarily disabled (2026-10-10) while jobs queue without a runner;
# manual dispatch below still works. Uncomment to re-enable.
# pull_request_target:
# types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
inputs:
pr:
+1
View File
@@ -83,6 +83,7 @@ issue-assets/
outputs/
target-ai-write-confirmation/
.serena/
.mimosa/
# Code review tool artifacts
review_diff.json
+1 -1
View File
@@ -1 +1 @@
22.13.0
22.23.3
+1 -1
View File
@@ -18,7 +18,7 @@ If you are not sure what to pick, choose an issue with clear reproduction steps,
### Prerequisites
- Node.js >= 22.13.0
- pnpm 10.27.0
- pnpm 10.34.7
- Rust >= 1.88
- Make
+1 -1
View File
@@ -16,7 +16,7 @@
### 环境要求
- Node.js >= 22.13.0
- pnpm 10.27.0
- pnpm 10.34.7
- Rust >= 1.88
- Make
Generated
+47 -17
View File
@@ -262,6 +262,12 @@ dependencies = [
"rustversion",
]
[[package]]
name = "arcstr"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "argon2"
version = "0.5.3"
@@ -1527,9 +1533,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
version = "4.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [
"bytes",
"futures-core",
@@ -2137,7 +2143,7 @@ dependencies = [
[[package]]
name = "dbx"
version = "0.6.38"
version = "0.6.47"
dependencies = [
"anyhow",
"axum",
@@ -2153,6 +2159,7 @@ dependencies = [
"font-kit",
"futures",
"grep",
"gtk",
"ignore",
"libloading 0.8.9",
"log",
@@ -2195,6 +2202,8 @@ dependencies = [
"tokio-postgres",
"tokio-util",
"uuid",
"webview2-com",
"windows-core 0.61.2",
"windows-sys 0.61.2",
"wry",
"zip",
@@ -2220,7 +2229,7 @@ dependencies = [
[[package]]
name = "dbx-cli"
version = "0.4.111"
version = "0.4.122"
dependencies = [
"async-trait",
"dbx-core",
@@ -2428,7 +2437,9 @@ dependencies = [
"base64 0.22.1",
"dbx-driver-support",
"dbx-types",
"futures-util",
"redis",
"rustls 0.23.43",
"serde",
"serde_json",
"tokio",
@@ -2557,7 +2568,7 @@ dependencies = [
[[package]]
name = "dbx-mcp"
version = "0.4.111"
version = "0.4.122"
dependencies = [
"async-trait",
"axum",
@@ -2741,7 +2752,7 @@ dependencies = [
[[package]]
name = "dbx-web"
version = "0.6.38"
version = "0.6.47"
dependencies = [
"aes-gcm 0.10.3",
"argon2",
@@ -5892,7 +5903,7 @@ dependencies = [
"darling 0.23.0",
"heck 0.5.0",
"manyhow",
"num-bigint",
"num-bigint 0.4.8",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
@@ -5947,7 +5958,7 @@ dependencies = [
"getrandom 0.3.4",
"hex",
"mysql-common-derive",
"num-bigint",
"num-bigint 0.4.8",
"num-traits",
"regex",
"rust_decimal",
@@ -6098,7 +6109,7 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-bigint 0.4.8",
"num-complex",
"num-integer",
"num-iter",
@@ -6116,6 +6127,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@@ -6172,7 +6193,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-bigint 0.4.8",
"num-integer",
"num-traits",
]
@@ -7627,22 +7648,24 @@ dependencies = [
[[package]]
name = "redis"
version = "0.32.7"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44"
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
dependencies = [
"arcstr",
"async-lock",
"bytes",
"cfg-if",
"combine",
"crc16",
"futures-sink",
"futures-channel",
"futures-util",
"itoa",
"log",
"num-bigint",
"num-bigint 0.5.1",
"percent-encoding",
"pin-project-lite",
"rand 0.9.5",
"rand 0.10.2",
"rustls 0.23.43",
"rustls-native-certs 0.8.4",
"ryu",
@@ -7652,6 +7675,7 @@ dependencies = [
"tokio-rustls 0.26.4",
"tokio-util",
"url",
"xxhash-rust",
]
[[package]]
@@ -8055,7 +8079,7 @@ dependencies = [
"md5",
"ml-kem",
"module-lattice",
"num-bigint",
"num-bigint 0.4.8",
"p256",
"p384",
"p521",
@@ -9055,7 +9079,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-bigint 0.4.8",
"num-traits",
"thiserror 2.0.19",
"time",
@@ -12112,6 +12136,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "xxhash-rust"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yeslogic-fontconfig-sys"
version = "6.0.1"
+48 -3
View File
@@ -124,6 +124,17 @@
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">Visit HuaLongAI</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-en" target="_blank">
<img src="docs/public/sponsors/hiapi-card.png" alt="HiAPI" width="175" />
</a>
</td>
<td>
HiAPI is a developer-focused API platform for image, video, audio, and text models — GPT Image 2.5 (Flare & Sunburst), OpenAI-compatible text endpoints, and Remote MCP/Agent Skills for Claude Code, Cursor, and more. Pay as you go with no minimum spend; new users get 200 Credits on signup, plus 10% bonus Credits on top-ups.
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-en" target="_blank">Visit HiAPI</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
@@ -135,6 +146,29 @@
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">Visit UCloud</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">
<img src="docs/public/sponsors/packyapi-card.png" alt="PackyCode" width="175" />
</a>
</td>
<td>
PackyCode is a stable, efficient API relay for mainstream AI models. Access them with one API endpoint and one API key — automatic failover keeps access fast and reliable, with dedicated high-speed routes for Codex and Claude Code. Get started with $1 in free credits, a first top-up discount, and up to 80% off eligible routes; pay in RMB with no currency conversion markups or extra top-up fees.
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">Visit PackyCode</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://mate.vip/ee/" target="_blank">
<img src="docs/public/sponsors/taiyi-card.png" alt="太一企业版" width="175" />
</a>
</td>
<td>
太一智能体企业版是面向央国企的私有化智能体平台,采用“1+N+N”架构:1 个平台、N 个终端、N 个场景。<strong>可信</strong>:Java 微服务架构,私有化部署,全程留痕可审计。<strong>可控</strong>:关键操作人工审批,资料按密级管理。<strong>可达</strong>:按单位模板直接生成公文、台账和汇报材料。<strong>可复制</strong>:场景方案包化交付,便于推广和二次开发。欢迎预约演示,或在 GitHub 搜索 MateClaw。
<a href="https://mate.vip/ee/" target="_blank">访问太一企业版</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
@@ -445,6 +479,16 @@ together with `dbx.db`. Persisting `/app/data` is therefore sufficient for a
normal Docker deployment. This key protects the database contents, but cannot
protect the whole data volume if the volume itself is copied or exposed.
Windows portable builds are the exception to the desktop platform-key rule.
To remain portable across computers, they keep the local storage key at
`data/.dbx/secret.key`; move and back up the complete `data` directory. If an
older portable profile already contains ciphertext protected by Windows
Credential Manager, launch the new build once on the original computer so DBX
can verify and copy that key to the USB drive. If the original computer or key
is gone, the ciphertext cannot be recovered automatically; restore the original
key or a pre-migration backup. Keeping the key beside the database means anyone
who obtains or copies the whole drive can also decrypt its credentials.
For production deployments, replace the managed key with a Docker/Kubernetes
Secret by setting `DBX_SECRET_KEY_FILE`, or provide `DBX_SECRET_KEY` through a
secret manager. Explicit keys take precedence and must never be rotated while
@@ -474,9 +518,10 @@ directory in Desktop or Web and complete its upgrade wizard. For headless
hosts without a platform credential store, configure the persistent key.
Cross-device exports use a separate sync passphrase and never contain the
local storage key. A direct `dbx.db` copy is not a cross-platform sync method:
platform keys do not move with the database. Use encrypted export/import so
the target device stores credentials using its own local key.
local storage key. For installed builds, or when copying only `dbx.db`, platform
keys do not move with the database; use encrypted export/import instead. A
Windows portable build must move the complete `data` directory containing both
`dbx.db` and `.dbx/secret.key`.
For the complete design, migration state machine, implementation map,
troubleshooting, and test plan, see
+37 -1
View File
@@ -124,6 +124,17 @@
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">访问 HuaLongAI</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-zh" target="_blank">
<img src="docs/public/sponsors/hiapi-card.png" alt="HiAPI" width="175" />
</a>
</td>
<td>
HiAPI 是面向开发者的图片、视频、音频及文本模型 API 平台——GPT Image 2.5(Flare 与 Sunburst)、OpenAI 兼容文本接口,并可通过 Remote MCP/Agent Skills 接入 Claude Code、Cursor 等编程工具。按量付费、无最低消费;新用户注册赠送 200 Credits,充值送 10% 积分。
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-zh" target="_blank">访问 HiAPI</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
@@ -135,6 +146,29 @@
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">访问 UCloud 优刻得</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">
<img src="docs/public/sponsors/packyapi-card.png" alt="PackyCode" width="175" />
</a>
</td>
<td>
PackyCode 是一家稳定、高效的 API 中转服务商,一句话接入主流大模型。统一域名、统一密钥、智能容灾切换,97% 可用性;人民币 1:1 充值,无汇率无手续费坑,新用户首充立享折扣 + $1 免费体验额度,多分组折扣低至 2 折起,并提供专属 Codex/Claude Code 高速通道。
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">访问 PackyCode</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://mate.vip/ee/" target="_blank">
<img src="docs/public/sponsors/taiyi-card.png" alt="太一企业版" width="175" />
</a>
</td>
<td>
太一智能体企业版是面向央国企的私有化智能体平台,采用“1+N+N”架构:1 个平台、N 个终端、N 个场景。<strong>可信</strong>:Java 微服务架构,私有化部署,全程留痕可审计。<strong>可控</strong>:关键操作人工审批,资料按密级管理。<strong>可达</strong>:按单位模板直接生成公文、台账和汇报材料。<strong>可复制</strong>:场景方案包化交付,便于推广和二次开发。欢迎预约演示,或在 GitHub 搜索 MateClaw。
<a href="https://mate.vip/ee/" target="_blank">访问太一企业版</a>
</td>
</tr>
<tr>
<td align="center" valign="middle" width="200">
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
@@ -439,13 +473,15 @@ volumes:
连接、插件、AI 和 Tunnel 凭据写入 `dbx.db` 前会加密。桌面端使用本机凭据存储(macOS Keychain、Windows Credential Manager 或 Linux Secret Service)。Web/Docker 与直接运行 `dbx-web` 默认使用同一套数据目录托管密钥:`${DBX_DATA_DIR}/.dbx/secret.key`。只有在开始迁移或第一次写入敏感字段时才创建密钥;普通 Docker 部署只需持久化 `/app/data`,并且必须将 `.dbx/secret.key` 与 `dbx.db` 一起备份。该密钥不能防护整个数据卷被复制或泄露。
Windows 便携版例外:为保证 U 盘可跨电脑使用,本地存储密钥保存在便携数据目录的 `data/.dbx/secret.key`,必须与整个 `data` 目录一起移动和备份。已经由旧版使用 Windows Credential Manager 加密过的便携数据,需要先在原电脑启动一次新版;DBX 会验证旧密钥并将其复制到 U 盘。若原电脑或原密钥已经丢失,现有密文无法自动恢复,只能找回原密钥或恢复迁移前备份。密钥与数据库同行意味着复制或丢失整个 U 盘的人也能解密其中凭据,请妥善保护 U 盘。
生产环境可以使用 Docker/Kubernetes Secret 覆盖托管策略:设置 `DBX_SECRET_KEY_FILE`,或由密钥管理系统提供 `DBX_SECRET_KEY`。显式密钥优先,已有密文使用期间不能轮换。密钥不可用时,业务 API 保持阻塞,浏览器显示数据安全升级页面。直接运行二进制时设置 `DBX_DATA_DIR=/var/lib/dbx`,即可使用 `/var/lib/dbx/.dbx/secret.key`。
升级包含历史明文凭据的版本时,桌面端和 Web 会在进入主界面前显示 **数据安全升级向导**。点击 **开始迁移** 后,软件会创建权限受限的备份,迁移旧数据库和 JSON 凭据,并验证密文可读取。失败时保留原始数据和备份,根据向导提示修复后点击 **重试**。成功页面会显示备份路径。确认连接可用后,可点击 **删除迁移备份**,二次确认后删除迁移备份目录和本次迁移生成的旧 JSON `.bak` 文件;其他备份不会删除。没有历史数据的新用户检查后直接进入主界面。
本机 CLI 和独立 MCP 可以复用同一设备已有的平台凭据存储,也可以读取显式配置的 `DBX_SECRET_KEY_FILE` 或 `DBX_SECRET_KEY`。它们不会在启动检查时创建密钥,也不会自动迁移历史数据。遇到 `DATA_MIGRATION_REQUIRED` 时,请先使用桌面端或 Web 打开同一数据目录,完成升级向导。没有平台凭据存储的无界面主机应配置持久化密钥。
跨设备导出使用独立的同步口令,导出包不包含本地存储密钥。直接复制 `dbx.db` 不能作为跨平台同步方式,因为本机平台密钥不会随数据库移动。请使用加密导出/导入,让目标设备使用自己的本地密钥保存凭据。
跨设备导出使用独立的同步口令,导出包不包含本地存储密钥。安装版或只复制 `dbx.db` 时,本机平台密钥不会随数据库移动,不能作为跨平台同步方式;请使用加密导出/导入。Windows 便携版则必须移动包含 `dbx.db` 和 `.dbx/secret.key` 的完整 `data` 目录。
完整的设计、迁移状态、实现模块、排障和测试说明请参阅:[DBX 数据安全升级与迁移](docs/data-security-migration.zh-CN.md)。
+1
View File
@@ -1,5 +1,6 @@
**/libs/*.jar
!drivers/cache/libs/CacheDB.jar
!drivers/gbase8c/libs/gbase8c-jdbc-3.0.0B02.jar
!drivers/oscar/libs/oscarJDBC8.jar
!drivers/sundb/libs/sundb8-1.0.2.jar
!drivers/transwarp/libs/inceptor-sdk-transwarp-6.1.0-SNAPSHOT.jar
+10 -1
View File
@@ -25,6 +25,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
| exasol | Exasol | Exasol JDBC |
| oceanbase-oracle | OceanBase Oracle Mode | OceanBase JDBC |
| gbase8a | GBase 8a | External GBase 8a JDBC |
| gbase8c | GBase 8c | Bundled GBase 8c JDBC |
| gbase8s | GBase 8s | External GBase 8s JDBC |
| oracle | Oracle 10g+ | go-ora native agent |
| oracle-oci | Oracle OCI (thick driver) | godror native agent (Windows x64, CGO; shares `drivers/oracle-go`, built with `-tags oci`) |
@@ -52,11 +53,19 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
| etcd2 | etcd 2.0–2.3, 3.0–3.5 with --enable-v2 (v2 API) | etcd v2 HTTP/JSON native Go agent |
| zookeeper | Apache ZooKeeper | go-zookeeper native agent |
| rabbitmq | RabbitMQ | amqp091-go native agent |
| rocketmq | Apache RocketMQ | rocketmq-admin-go native agent |
| rocketmq | Apache RocketMQ | rocketmq-admin-go (Remoting) and official Go v5 SDK (Proxy gRPC) native agent |
| iris | InterSystems IRIS | InterSystems IRIS JDBC |
| cache | InterSystems Caché | InterSystems Caché JDBC (CacheDB) |
## RocketMQ Connection Modes
The same `rocketmq` Agent supports NameServer/Remoting and the official RocketMQ 5.x Proxy gRPC protocol. Proxy mode requires the handshake feature `rocketmq_proxy_grpc`; configurations without a mode continue to use Remoting.
Proxy messaging uses the official Go v5 SDK's RPC, signing and protocol types with per-connection TLS and dialers. SDK v5.1.4 does not forward the high-level Producer's connection options, so this implementation does not replace global TLS switches or transport constructors. It registers Producer settings through Telemetry before checking Heartbeat, keeps the stream open, and sends ordinary messages with keys, tags and user properties. It does not map consumption to a non-mutating peek. Optional NameServer administration reuses Remoting and reports its own degraded state.
Run `go test ./...` and `go test -race ./...` in the RocketMQ driver directory. The official Proxy integration test is opt-in: prepare a NORMAL topic, set `ROCKETMQ_PROXY_INTEGRATION=1`, `ROCKETMQ_PROXY_ENDPOINT`, `ROCKETMQ_PROXY_TOPIC` and `ROCKETMQ_PROXY_TLS`, then run the test. It publishes a test message. The native Agent does not require Java; Java is only used by the local official-service fixture.
## Multi-JRE Support
Most Java agents target JRE 21. Native agents, such as `cassandra`, `duckdb`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, `rabbitmq`, `rocketmq`, `zookeeper`, `etcd`, and `etcd2`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
+10 -1
View File
@@ -25,6 +25,7 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
| exasol | Exasol | Exasol JDBC |
| oceanbase-oracle | OceanBase Oracle 模式 | OceanBase JDBC |
| gbase8a | GBase 8a | 外部 GBase 8a JDBC |
| gbase8c | GBase 8c | 内置 GBase 8c JDBC |
| gbase8s | GBase 8s | 外部 GBase 8s JDBC |
| oracle | Oracle 10g+ | go-ora 原生 agent |
| h2 | H2 | H2 JDBC |
@@ -50,11 +51,19 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
| etcd | etcd | jetcd |
| zookeeper | Apache ZooKeeper | go-zookeeper 原生 Agent |
| rabbitmq | RabbitMQ | amqp091-go 原生 agent |
| rocketmq | Apache RocketMQ | rocketmq-admin-go 原生 agent |
| rocketmq | Apache RocketMQ | rocketmq-admin-go(Remoting)及官方 Go v5 SDK(Proxy gRPC)原生 agent |
| iris | InterSystems IRIS | InterSystems IRIS JDBC |
| cache | InterSystems Caché | InterSystems Caché JDBC (CacheDB) |
## RocketMQ 接入模式
同一 `rocketmq` Agent 支持原有 NameServer/Remoting 接入与 RocketMQ 5.x 官方 Proxy gRPC 接入。Proxy 模式通过握手能力 `rocketmq_proxy_grpc` 协商;未指定模式的旧配置继续使用 Remoting,无需迁移驱动标识。
Proxy 消息使用官方 Go v5 SDK 的 RPC、签名和协议类型,并为每个连接独立配置 TLS 与拨号器;SDK v5.1.4 高层 Producer 未传递连接选项,因此此处不使用全局 TLS 开关或全局连接构造器替换。消息支持普通发送、Key、Tag 与用户属性,不将 gRPC 消费映射为无副作用预览。可选 NameServer 管理通道复用现有 Remoting 实现,失败时独立标记 degraded。
开发验证可运行 `go test ./...` 与 `go test -race ./...`。官方 Proxy 集成用例默认关闭,需要预先准备普通 Topic,并显式设置 `ROCKETMQ_PROXY_INTEGRATION=1`、`ROCKETMQ_PROXY_ENDPOINT`、`ROCKETMQ_PROXY_TOPIC` 及 `ROCKETMQ_PROXY_TLS`;集成用例会向该 Topic 发送测试消息。原生 Agent 不需要 JRE,Java 仅用于本地官方服务集成 fixture。
## 多 JRE 支持
多数 Java agent 以 JRE 21 为目标。原生 agent(如 `cassandra`、`duckdb`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu`、`rabbitmq`、`rocketmq` 和 `zookeeper`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
+3 -3
View File
@@ -6,7 +6,7 @@ def infrastructureProjects = ['common', 'test-support'] as Set
def legacyStandaloneProjects = ['mongodb', 'kafka'] as Set
def pooledJdbcProjects = [
'access', 'bigquery', 'dameng', 'databend', 'databricks', 'db2', 'exasol',
'firebird', 'gbase8a', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo',
'firebird', 'gbase8a', 'gbase8c', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo',
'ignite', 'ignite3', 'informix', 'iris', 'cache', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
'snowflake', 'spanner', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb',
'vertica', 'yashandb', 'transwarp'
@@ -61,7 +61,7 @@ configure(jdbcAgentProjects) {
configure(agentProjects.findAll { pooledJdbcProjects.contains(it.name) }) {
dependencies {
implementation 'com.zaxxer:HikariCP:7.0.2'
runtimeOnly 'org.slf4j:slf4j-nop:2.0.17'
implementation 'com.zaxxer:HikariCP:7.1.0'
runtimeOnly 'org.slf4j:slf4j-nop:2.0.20'
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
dependencies {
implementation 'com.google.code.gson:gson:2.12.1'
compileOnly 'com.zaxxer:HikariCP:7.0.2'
testImplementation 'com.zaxxer:HikariCP:7.0.2'
implementation 'com.google.code.gson:gson:2.14.0'
compileOnly 'com.zaxxer:HikariCP:7.1.0'
testImplementation 'com.zaxxer:HikariCP:7.1.0'
testImplementation 'com.h2database:h2:2.3.232'
testRuntimeOnly 'org.slf4j:slf4j-nop:2.0.17'
testRuntimeOnly 'org.slf4j:slf4j-nop:2.0.20'
}
@@ -202,7 +202,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
options.getFetchSize(),
options.getTimeoutSecs(),
resultValueReader(),
JdbcExecutor.StatementMessageReader.NONE,
statementMessageReader(),
advancePastUpdateCounts()
);
}
@@ -221,6 +221,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
this::resetSchemaSQL,
options,
resultValueReader(),
statementMessageReader(),
advancePastUpdateCounts()
);
}
@@ -421,7 +422,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
releasePooledConnection(true);
return;
}
if (sessionAffinity || executor.hasOpenSessions() || executor.hasActiveStatements()) {
if (sessionAffinity || executor.hasOpenSessions() || executor.hasActiveStatements() || hasRetainedResultResources()) {
return;
}
releasePooledConnection(!preparePooledConnectionForReturn());
@@ -438,7 +439,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
if (sessionAffinity) {
return;
}
if (executor.hasOpenSessions() || executor.hasActiveStatements()) {
if (executor.hasOpenSessions() || executor.hasActiveStatements() || hasRetainedResultResources()) {
return;
}
releasePooledConnection(!preparePooledConnectionForReturn());
@@ -602,6 +603,10 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
protected void beforeQueryExecution(Connection connection, int timeoutSecs) throws Exception {
}
protected JdbcExecutor.StatementMessageReader statementMessageReader() {
return JdbcExecutor.StatementMessageReader.NONE;
}
/** Called for each session RPC before its SQL can replace the connection's last trace ID. */
protected void beforeAgentMethod(String method, String querySessionId) {
}
@@ -713,7 +718,12 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
);
}
protected boolean hasRetainedResultResources() { return false; }
protected void releaseRetainedResultResources() { }
private void closeCurrentConnection() throws Exception {
releaseRetainedResultResources();
if (pooledLease != null) {
boolean evict = pooledConnectionPoisoned || sessionAffinity || !preparePooledConnectionForReturn();
releasePooledConnection(evict);
@@ -732,7 +742,17 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
return JdbcSchemaSwitcher.resetBeforeReturn(connection);
}
protected final synchronized void discardConnection(Connection failedConnection) throws java.sql.SQLException {
if (pooledLease != null && connection == failedConnection) {
// Evict the checked-out lease before closing its proxy; close alone returns it to the pool.
releasePooledConnection(true);
} else {
failedConnection.close();
}
}
private void releasePooledConnection(boolean evict) {
releaseRetainedResultResources();
JdbcConnectionPoolRegistry.Lease lease = pooledLease;
pooledLease = null;
connection = null;
@@ -2,6 +2,8 @@ package com.dbx.agent;
final class AgentExecutionContext {
private static final ThreadLocal<JdbcExecutor> JDBC_EXECUTOR = new ThreadLocal<>();
private record StatementExecution(int timeoutSecs, JdbcExecutor.NativeOperation operation, AbstractJdbcAgent owner) { }
private static final ThreadLocal<StatementExecution> STATEMENT_EXECUTION = new ThreadLocal<>();
private AgentExecutionContext() {
}
@@ -11,6 +13,35 @@ final class AgentExecutionContext {
return executor == null ? JdbcExecutor.INSTANCE : executor;
}
static int statementTimeoutSecs() {
StatementExecution execution = STATEMENT_EXECUTION.get();
return execution == null ? 0 : execution.timeoutSecs();
}
static JdbcExecutor.NativeOperation statementOperation() {
StatementExecution execution = STATEMENT_EXECUTION.get();
return execution == null ? null : execution.operation();
}
static void discardConnection(java.sql.Connection connection) throws java.sql.SQLException {
StatementExecution execution = STATEMENT_EXECUTION.get();
if (execution != null && execution.owner() != null) execution.owner().discardConnection(connection);
else connection.close();
}
static <T> T withStatementTimeout(int timeoutSecs, JdbcExecutor.NativeOperation operation, AbstractJdbcAgent owner,
DatabaseAgent.ThrowingSupplier<T> supplier) throws Exception {
if (timeoutSecs < 0) throw new IllegalArgumentException("Query timeout cannot be negative");
StatementExecution previous = STATEMENT_EXECUTION.get();
STATEMENT_EXECUTION.set(new StatementExecution(timeoutSecs, operation, owner));
try {
return supplier.get();
} finally {
if (previous == null) STATEMENT_EXECUTION.remove();
else STATEMENT_EXECUTION.set(previous);
}
}
static <T> T withJdbcExecutor(JdbcExecutor executor, DatabaseAgent.ThrowingSupplier<T> supplier) throws Exception {
JdbcExecutor previous = JDBC_EXECUTOR.get();
JDBC_EXECUTOR.set(executor);
@@ -38,6 +38,8 @@ public final class AgentProtocol {
public static final String METHOD_EXECUTE_QUERY_PAGE = "execute_query_page";
public static final String METHOD_FETCH_QUERY_PAGE = "fetch_query_page";
public static final String METHOD_CLOSE_QUERY_SESSION = "close_query_session";
public static final String METHOD_READ_LARGE_VALUE_CHUNK = "read_large_value_chunk";
public static final String METHOD_RELEASE_LARGE_VALUE = "release_large_value";
public static final String METHOD_START_TABLE_READ = "start_table_read";
public static final String METHOD_FETCH_TABLE_READ_PAGE = "fetch_table_read_page";
public static final String METHOD_CLOSE_TABLE_READ_SESSION = "close_table_read_session";
@@ -141,6 +143,7 @@ public final class AgentProtocol {
public static final String CAPABILITY_MONGO_FIND_CURSOR = "mongo_find_cursor";
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1";
public static final String CAPABILITY_BLOB_BIND_STATEMENTS_V1 = "blob_bind_statements_v1";
public static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
CAPABILITY_CONNECT,
@@ -215,6 +218,8 @@ public final class AgentProtocol {
METHOD_EXECUTE_QUERY_PAGE,
METHOD_FETCH_QUERY_PAGE,
METHOD_CLOSE_QUERY_SESSION,
METHOD_READ_LARGE_VALUE_CHUNK,
METHOD_RELEASE_LARGE_VALUE,
METHOD_START_TABLE_READ,
METHOD_FETCH_TABLE_READ_PAGE,
METHOD_CLOSE_TABLE_READ_SESSION,
@@ -267,6 +272,7 @@ public final class AgentProtocol {
List<String> jdbcAllCapabilities = new java.util.ArrayList<>(MULTI_SESSION_ALL_CAPABILITIES);
jdbcAllCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1);
jdbcAllCapabilities.add(CAPABILITY_BLOB_BIND_STATEMENTS_V1);
MULTI_SESSION_JDBC_ALL_CAPABILITIES = Collections.unmodifiableList(jdbcAllCapabilities);
List<String> methods = new java.util.ArrayList<>(COMMON_METHODS);
@@ -383,6 +389,13 @@ public final class AgentProtocol {
);
}
public static HandshakeResult multiSessionJdbcHandshakeResult(boolean blobBindStatements) {
if (!blobBindStatements) return multiSessionJdbcHandshakeResult();
java.util.ArrayList<String> capabilities = new java.util.ArrayList<>(MULTI_SESSION_JDBC_CAPABILITIES);
capabilities.add(CAPABILITY_BLOB_BIND_STATEMENTS_V1);
return new HandshakeResult(MULTI_SESSION_PROTOCOL_VERSION, MULTI_SESSION_PROTOCOL_VERSION, capabilities);
}
public static final class HandshakeResult {
private final int protocolVersion;
private final int agentProtocolVersion;
@@ -57,6 +57,11 @@ final class AgentRpcError extends RuntimeException {
);
}
static AgentRpcError rollbackConfirmedSql(SQLException error) {
return new AgentRpcError(message(error), "sql", false, "keep", "execute", "unknown",
safeSqlState(error.getSQLState()), error.getErrorCode(), safeClassName(error), error);
}
static AgentRpcError backpressure(String stage, Throwable cause) {
return new AgentRpcError(
"Agent request capacity is temporarily exhausted",
@@ -48,7 +48,14 @@ public abstract class BaseDatabaseAgent implements DatabaseAgent {
// Table comment is optional; DDL generation should still succeed without it.
}
return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, Collections.emptyList(), false, false, tableComment);
return DdlBuilder.buildTableDdl(
schema, table, getColumns(schema, table), indexes, foreignKeys,
Collections.emptyList(), false, includeColumnCommentsInTableDdl(), tableComment
);
}
protected boolean includeColumnCommentsInTableDdl() {
return false;
}
@Override
@@ -30,25 +30,35 @@ public final class BatchExecutor {
Supplier<String> resetSchemaSql
) {
return unchecked(() -> {
long start = System.currentTimeMillis();
applySchema(conn, schema, setSchemaSql, resetSchemaSql);
Long batchAffected = tryExecuteBatch(conn, statements);
long totalAffected = batchAffected == null ? executeIndividually(conn, statements) : batchAffected;
return new QueryResult(
Collections.emptyList(),
Collections.emptyList(),
totalAffected,
System.currentTimeMillis() - start,
false
);
try (JdbcExecutor.NativeOperation operation = JdbcExecutor.current().beginNativeOperation()) {
long start = System.currentTimeMillis();
operation.checkCancelled();
applySchema(conn, schema, setSchemaSql, resetSchemaSql);
operation.checkCancelled();
Long batchAffected = tryExecuteBatch(conn, statements, operation);
long totalAffected = batchAffected == null ? executeIndividually(conn, statements, operation) : batchAffected;
operation.checkCancelled();
return new QueryResult(
Collections.emptyList(),
Collections.emptyList(),
totalAffected,
System.currentTimeMillis() - start,
false
);
}
});
}
private static Long tryExecuteBatch(Connection conn, List<String> statements) throws Exception {
try (Statement stmt = conn.createStatement()) {
private static Long tryExecuteBatch(Connection conn, List<String> statements, JdbcExecutor.NativeOperation operation) throws Exception {
try (var tracked = JdbcExecutor.current().trackStatement(conn.createStatement())) {
Statement stmt = tracked.statement();
if (AgentExecutionContext.statementTimeoutSecs() > 0) {
stmt.setQueryTimeout(AgentExecutionContext.statementTimeoutSecs());
}
try {
int statementCount = 0;
for (String statement : statements) {
operation.checkCancelled();
String trimmed = JdbcExecutor.trimSql(statement);
if (trimmed.isEmpty()) {
continue;
@@ -56,7 +66,8 @@ public final class BatchExecutor {
stmt.addBatch(trimmed);
statementCount++;
}
return statementCount == 0 ? 0L : affectedRows(executeBatch(stmt));
operation.checkCancelled();
return statementCount == 0 ? 0L : affectedRows(executeBatch(stmt, operation));
} catch (BatchUpdateException e) {
long[] counts = e.getLargeUpdateCounts();
int failedIndex = counts == null ? 1 : counts.length + 1;
@@ -67,11 +78,16 @@ public final class BatchExecutor {
}
}
private static long executeIndividually(Connection conn, List<String> statements) throws Exception {
private static long executeIndividually(Connection conn, List<String> statements, JdbcExecutor.NativeOperation operation) throws Exception {
long totalAffected = 0;
int statementIndex = 0;
try (Statement stmt = conn.createStatement()) {
try (var tracked = JdbcExecutor.current().trackStatement(conn.createStatement())) {
Statement stmt = tracked.statement();
if (AgentExecutionContext.statementTimeoutSecs() > 0) {
stmt.setQueryTimeout(AgentExecutionContext.statementTimeoutSecs());
}
for (String statement : statements) {
operation.checkCancelled();
String trimmed = JdbcExecutor.trimSql(statement);
if (trimmed.isEmpty()) {
continue;
@@ -107,10 +123,11 @@ public final class BatchExecutor {
return total;
}
private static long[] executeBatch(Statement stmt) throws Exception {
private static long[] executeBatch(Statement stmt, JdbcExecutor.NativeOperation operation) throws Exception {
try {
return stmt.executeLargeBatch();
} catch (SQLFeatureNotSupportedException | UnsupportedOperationException | AbstractMethodError e) {
operation.checkCancelled();
int[] counts = stmt.executeBatch();
long[] largeCounts = new long[counts.length];
for (int i = 0; i < counts.length; i++) {
@@ -0,0 +1,208 @@
package com.dbx.agent;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Savepoint;
import java.sql.SQLException;
import java.sql.SQLTransientConnectionException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.function.Function;
import java.util.function.Supplier;
public final class BlobBoundExecutor {
private BlobBoundExecutor() { }
@FunctionalInterface
public interface PreparedStatementConfigurer {
void configure(PreparedStatement statement) throws SQLException;
default void discardConnection(Connection connection) throws SQLException { connection.close(); }
default boolean isRollbackConfirmedBusinessError(SQLException error) { return false; }
default boolean isUnsupportedSavepointRelease(SQLException error) { return false; }
}
public static QueryResult execute(Connection conn, List<String> previews, List<BlobBoundStatement> statements,
String schema, Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql,
int timeoutSecs, boolean transaction) {
return execute(conn, previews, statements, schema, setSchemaSql, resetSchemaSql, timeoutSecs,
transaction, statement -> { });
}
public static QueryResult execute(Connection conn, List<String> previews, List<BlobBoundStatement> statements,
String schema, Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql,
int timeoutSecs, boolean transaction, PreparedStatementConfigurer configurer) {
BlobBoundStatement.validate(previews, statements);
if (timeoutSecs < 0) throw new IllegalArgumentException("BLOB binding timeout must be non-negative");
try (JdbcExecutor.NativeOperation operation = JdbcExecutor.current().beginNativeOperation()) {
operation.checkCancelled();
boolean autoCommit = conn.getAutoCommit();
if (transaction && !autoCommit) {
throw new IllegalStateException("Cannot start a one-shot transaction while a manual transaction is open");
}
// Typed grid saves are atomic even when execute_batch omits useTransaction.
// Existing manual transactions keep their batch-only savepoint boundary.
boolean oneShot = transaction || autoCommit;
if (oneShot && !conn.getMetaData().supportsTransactions()) {
throw new UnsupportedOperationException("Transactions are not supported by this JDBC driver");
}
if (oneShot) conn.setAutoCommit(false);
Savepoint savepoint = !oneShot ? conn.setSavepoint() : null;
boolean finished = !oneShot;
boolean discarded = false;
boolean commitAttempted = false;
try {
long start = System.currentTimeMillis();
long affected = executeAll(conn, statements, schema, setSchemaSql, resetSchemaSql, timeoutSecs, operation, configurer);
operation.checkCancelled();
if (oneShot) { commitAttempted = true; conn.commit(); }
if (savepoint != null) releaseSavepoint(conn, savepoint, configurer);
finished = true;
return new QueryResult(Collections.emptyList(), Collections.emptyList(), affected,
System.currentTimeMillis() - start, false);
} catch (Exception failure) {
if (oneShot) {
try { conn.rollback(); finished = true; }
catch (Exception rollbackFailure) {
discarded = true;
throw rollbackUnconfirmed(conn, failure, rollbackFailure, configurer);
}
if (!commitAttempted && failure instanceof SQLException sqlFailure
&& configurer.isRollbackConfirmedBusinessError(sqlFailure)) {
throw AgentRpcError.rollbackConfirmedSql(sqlFailure);
}
}
if (savepoint != null) {
try { conn.rollback(savepoint); }
catch (Exception rollbackFailure) { throw rollbackUnconfirmed(conn, failure, rollbackFailure, configurer); }
try { releaseSavepoint(conn, savepoint, configurer); }
catch (Exception releaseFailure) {
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
"BLOB savepoint cleanup failed after batch rollback; discard this connection and treat transaction state as unknown", "08007", releaseFailure);
unsafe.addSuppressed(failure);
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
throw unsafe;
}
if (failure instanceof SQLException sqlFailure && configurer.isRollbackConfirmedBusinessError(sqlFailure)) {
throw AgentRpcError.rollbackConfirmedSql(sqlFailure);
}
}
throw failure;
} finally {
if (oneShot) {
if (finished) {
try { conn.setAutoCommit(autoCommit); }
catch (Exception resetFailure) {
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
"BLOB transaction connection reset failed; committed writes may exist and operation outcome is unknown", "08007", resetFailure);
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
throw unsafe;
}
} else if (!discarded) configurer.discardConnection(conn); // Never enable auto-commit after an unconfirmed rollback.
}
}
} catch (RuntimeException failure) {
throw failure;
} catch (Exception failure) {
throw new RuntimeException(failure);
}
}
private static SQLTransientConnectionException rollbackUnconfirmed(Connection conn, Exception failure,
Exception rollbackFailure, PreparedStatementConfigurer configurer) {
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
"BLOB binding rollback was not confirmed; discard this connection and treat the operation outcome as unknown", "08007", failure);
unsafe.addSuppressed(rollbackFailure);
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
return unsafe;
}
private static void releaseSavepoint(Connection conn, Savepoint savepoint,
PreparedStatementConfigurer configurer) throws SQLException {
try { conn.releaseSavepoint(savepoint); }
catch (SQLException error) {
if (!configurer.isUnsupportedSavepointRelease(error)) throw error;
}
}
private static long executeAll(Connection conn, List<BlobBoundStatement> statements, String schema,
Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql, int timeoutSecs,
JdbcExecutor.NativeOperation operation, PreparedStatementConfigurer configurer) throws Exception {
JdbcExecutor executor = JdbcExecutor.current();
try (BoundResources resources = new BoundResources()) {
operation.checkCancelled();
JdbcSchemaSwitcher.apply(conn, schema, setSchemaSql, resetSchemaSql);
for (BlobBoundStatement bound : statements) {
operation.checkCancelled();
String sql = JdbcExecutor.trimSql(bound.sql());
Statement statement = bound.blobParameters().isEmpty() ? conn.createStatement()
: isAnonymousBlock(sql) ? conn.prepareCall(sql) : conn.prepareStatement(sql);
resources.statements.add(executor.trackStatement(statement));
statement.setQueryTimeout(timeoutSecs);
if (statement instanceof PreparedStatement prepared) {
configurer.configure(prepared);
int index = 1;
for (String hex : bound.blobParameters()) {
InputStream stream = new HexStream(hex);
resources.streams.add(stream);
prepared.setBlob(index++, stream, (long) hex.length() / 2);
}
}
}
// Prepare and bind the complete batch before the first write.
long affected = 0;
for (int i = 0; i < statements.size(); i++) {
operation.checkCancelled();
Statement statement = resources.statements.get(i).statement();
boolean result = statement instanceof PreparedStatement prepared ? prepared.execute()
: statement.execute(JdbcExecutor.trimSql(statements.get(i).sql()));
if (!result) affected += Math.max(0, statement.getUpdateCount());
operation.checkCancelled();
}
return affected;
}
}
private static final class BoundResources implements AutoCloseable {
final List<JdbcExecutor.TrackedStatement<Statement>> statements = new ArrayList<>();
final List<InputStream> streams = new ArrayList<>();
@Override public void close() throws Exception {
Exception failure = null;
List<AutoCloseable> resources = new ArrayList<>(statements);
resources.addAll(streams);
for (AutoCloseable resource : resources) {
try { resource.close(); }
catch (Exception error) {
if (failure == null) failure = error;
else failure.addSuppressed(error);
}
}
if (failure != null) throw failure;
}
}
private static boolean isAnonymousBlock(String sql) {
String normalized = sql.toUpperCase(Locale.ROOT);
return normalized.startsWith("DECLARE") || normalized.startsWith("BEGIN");
}
/** Decode already-validated hex without retaining a second full byte array. */
private static final class HexStream extends InputStream {
private final String hex;
private int offset;
private boolean closed;
HexStream(String hex) { this.hex = hex; }
@Override public int available() { return closed ? 0 : (hex.length() - offset) / 2; }
@Override public int read() throws java.io.IOException {
if (closed) throw new java.io.IOException("BLOB binding stream is closed");
if (offset == hex.length()) return -1;
int value = Character.digit(hex.charAt(offset), 16) * 16 + Character.digit(hex.charAt(offset + 1), 16);
offset += 2;
return value;
}
@Override public void close() { closed = true; }
}
}
@@ -0,0 +1,93 @@
package com.dbx.agent;
import java.util.List;
/** Optional BLOB-only binding payload; preview text stays authoritative. */
public final class BlobBoundStatement {
private String previewSql;
private String sql;
private List<String> blobParameters;
public BlobBoundStatement(String previewSql, String sql, List<String> blobParameters) {
this.previewSql = previewSql;
this.sql = sql;
this.blobParameters = blobParameters;
}
public String sql() { return sql; }
public List<String> blobParameters() { return blobParameters; }
public static void validate(List<String> statements, List<BlobBoundStatement> boundStatements) {
if (statements == null || boundStatements == null || statements.size() != boundStatements.size()) {
throw new IllegalArgumentException("BLOB bound statements must match the complete statement batch");
}
for (int i = 0; i < statements.size(); i++) {
BlobBoundStatement bound = boundStatements.get(i);
if (bound == null || statements.get(i) == null || !statements.get(i).equals(bound.previewSql)
|| bound.sql == null || bound.sql.trim().isEmpty() || bound.blobParameters == null) {
throw new IllegalArgumentException("BLOB binding preview mismatch at statement " + (i + 1));
}
if (bound.blobParameters.isEmpty() && !bound.sql.equals(bound.previewSql)) {
throw new IllegalArgumentException("Unbound statement SQL must match its preview");
}
SqlScan scan = scanSql(bound.sql);
String visible = scan.visible().stripLeading().toUpperCase(java.util.Locale.ROOT);
if (!visible.matches("(?s)^(?:UPDATE|INSERT|DELETE)\\b.*")
&& !visible.matches("(?s)^(?:DECLARE\\b.*?)?BEGIN\\s+(?:UPDATE|INSERT|DELETE)\\b.*")
|| java.util.regex.Pattern.compile("\\b(?:CREATE|ALTER|DROP|TRUNCATE|COMMIT|ROLLBACK|SAVEPOINT|GRANT|REVOKE|EXECUTE)\\b").matcher(visible).find()) {
throw new IllegalArgumentException("BLOB binding only supports data-grid DML, without DDL or transaction control");
}
if (!bound.blobParameters.isEmpty() && scan.parameters() != bound.blobParameters.size()) {
throw new IllegalArgumentException("BLOB parameter count does not match statement " + (i + 1));
}
for (String hex : bound.blobParameters) {
if (hex == null || (hex.length() & 1) != 0) {
throw new IllegalArgumentException("BLOB parameters must contain even-length hexadecimal bytes");
}
for (int j = 0; j < hex.length(); j++) {
if (Character.digit(hex.charAt(j), 16) < 0 || hex.charAt(j) > 127) {
throw new IllegalArgumentException("BLOB parameters must contain hexadecimal bytes only");
}
}
}
}
}
private record SqlScan(int parameters, String visible) { }
private static SqlScan scanSql(String sql) {
int count = 0;
StringBuilder visible = new StringBuilder();
for (int i = 0; i < sql.length(); i++) {
char ch = sql.charAt(i);
char next = i + 1 < sql.length() ? sql.charAt(i + 1) : '\0';
if (ch == '-' && next == '-') {
visible.append(' ');
while (i + 1 < sql.length() && sql.charAt(i + 1) != '\n') i++;
} else if (ch == '/' && next == '*') {
visible.append(' ');
int end = sql.indexOf("*/", i + 2);
i = end < 0 ? sql.length() : end + 1;
} else if ((ch == 'q' || ch == 'Q') && next == '\'' && i + 2 < sql.length()) {
visible.append(' ');
char open = sql.charAt(i + 2);
char close = open == '[' ? ']' : open == '(' ? ')' : open == '{' ? '}' : open == '<' ? '>' : open;
int end = sql.indexOf("" + close + '\'', i + 3);
i = end < 0 ? sql.length() : end + 1;
} else if (ch == '\'' || ch == '"') {
visible.append(' ');
char quote = ch;
while (++i < sql.length()) {
if (sql.charAt(i) == quote) {
if (i + 1 < sql.length() && sql.charAt(i + 1) == quote) i++;
else break;
}
}
} else {
visible.append(ch);
if (ch == '?') count++;
}
}
return new SqlScan(count, visible.toString());
}
}
@@ -15,6 +15,9 @@ public final class ColumnInfo {
private Integer character_maximum_length;
private String character_set;
private String collation;
private String resolved_schema;
private String resolved_table;
private String resolved_object_type;
public ColumnInfo() {
this("", "", true, null, false);
@@ -128,6 +131,18 @@ public final class ColumnInfo {
return collation;
}
public String getResolved_schema() {
return resolved_schema;
}
public String getResolved_table() {
return resolved_table;
}
public String getResolved_object_type() {
return resolved_object_type;
}
public void setName(String name) {
this.name = name;
}
@@ -176,6 +191,18 @@ public final class ColumnInfo {
this.collation = collation;
}
public void setResolved_schema(String resolved_schema) {
this.resolved_schema = resolved_schema;
}
public void setResolved_table(String resolved_table) {
this.resolved_table = resolved_table;
}
public void setResolved_object_type(String resolved_object_type) {
this.resolved_object_type = resolved_object_type;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
@@ -192,7 +219,10 @@ public final class ColumnInfo {
&& Objects.equals(numeric_scale, that.numeric_scale)
&& Objects.equals(character_maximum_length, that.character_maximum_length)
&& Objects.equals(character_set, that.character_set)
&& Objects.equals(collation, that.collation);
&& Objects.equals(collation, that.collation)
&& Objects.equals(resolved_schema, that.resolved_schema)
&& Objects.equals(resolved_table, that.resolved_table)
&& Objects.equals(resolved_object_type, that.resolved_object_type);
}
@Override
@@ -209,7 +239,10 @@ public final class ColumnInfo {
numeric_scale,
character_maximum_length,
character_set,
collation
collation,
resolved_schema,
resolved_table,
resolved_object_type
);
}
@@ -227,6 +260,9 @@ public final class ColumnInfo {
+ ", character_maximum_length=" + character_maximum_length
+ ", character_set=" + character_set
+ ", collation=" + collation
+ ", resolved_schema=" + resolved_schema
+ ", resolved_table=" + resolved_table
+ ", resolved_object_type=" + resolved_object_type
+ ")";
}
}
@@ -9,6 +9,8 @@ public final class CompletionAssistantCandidate {
private final String parent_name;
private final String comment;
private final String data_type;
private final String signature;
private final String routine_id;
public CompletionAssistantCandidate(
String name,
@@ -19,6 +21,21 @@ public final class CompletionAssistantCandidate {
String parentName,
String comment,
String dataType
) {
this(name, kind, database, schema, parentSchema, parentName, comment, dataType, null, null);
}
public CompletionAssistantCandidate(
String name,
CompletionAssistantCandidateKind kind,
String database,
String schema,
String parentSchema,
String parentName,
String comment,
String dataType,
String signature,
String routineId
) {
this.name = name;
this.kind = kind;
@@ -28,6 +45,8 @@ public final class CompletionAssistantCandidate {
this.parent_name = parentName;
this.comment = comment;
this.data_type = dataType;
this.signature = signature;
this.routine_id = routineId;
}
public String getName() { return name; }
@@ -38,4 +57,6 @@ public final class CompletionAssistantCandidate {
public String getParent_name() { return parent_name; }
public String getComment() { return comment; }
public String getData_type() { return data_type; }
public String getSignature() { return signature; }
public String getRoutine_id() { return routine_id; }
}
@@ -6,11 +6,17 @@ public final class CompletionAssistantResponse {
private final List<CompletionAssistantCandidate> candidates;
private final boolean incomplete;
private final boolean fallback_used;
private final Boolean routine_search_supported;
public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed) {
this(candidates, incomplete, fallbackUsed, null);
}
public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed, Boolean routineSearchSupported) {
this.candidates = candidates;
this.incomplete = incomplete;
this.fallback_used = fallbackUsed;
this.routine_search_supported = routineSearchSupported;
}
public List<CompletionAssistantCandidate> getCandidates() { return candidates; }
@@ -5,6 +5,25 @@ import java.util.List;
/** Shared wire shape; null state properties mean the database did not report them. */
public record ConstraintInfo(
String name, String constraint_type, String definition, List<String> columns,
Boolean deferrable, Boolean initially_deferred, Boolean enabled, Boolean valid
Boolean deferrable, Boolean initially_deferred, Boolean enabled, Boolean valid,
OracleConstraintMetadata oracle_metadata
) {
public ConstraintInfo(
String name, String constraint_type, String definition, List<String> columns,
Boolean deferrable, Boolean initially_deferred, Boolean enabled, Boolean valid
) {
this(name, constraint_type, definition, columns, deferrable, initially_deferred, enabled, valid, null);
}
public record OracleConstraintMetadata(
boolean metadataComplete, String owner, String constraintType, String status, String validated,
String deferrable, String deferred, String rely, String indexOwner, String indexName,
String indexSharing, OracleConstraintIndexMetadata index
) {}
public record OracleConstraintIndexMetadata(
boolean metadataComplete, String owner, String tableOwner, String tableName, String indexType,
String uniqueness, String status, boolean partitioned, String visibility, String secondary,
List<String> columns, List<String> directions, List<Boolean> keyIsExpression, List<String> includedColumns
) {}
}
@@ -14,6 +14,13 @@ public interface DatabaseAgent {
/** Opt in to request-boundary timing, including pooled connection lifecycle. */
default boolean supportsQueryTiming() { return false; }
default boolean supportsBlobBindStatements() { return false; }
default QueryResult executeBlobBoundStatements(List<String> previews, List<BlobBoundStatement> statements,
String schema, int timeoutSecs, boolean transaction) {
throw new UnsupportedOperationException("BLOB statement binding is not supported by this agent");
}
/** A dedicated manual transaction must never migrate to a replacement connection. */
default boolean permitsAutomaticReconnect() { return true; }
@@ -86,6 +93,10 @@ public interface DatabaseAgent {
List<ColumnInfo> getColumns(String schema, String table);
default List<ColumnInfo> getColumnsInContext(String schema, String table, String currentSchema) {
return getColumns(schema, table);
}
default ObjectSource getObjectSource(String schema, String name, String objectType) {
throw new UnsupportedOperationException("Object source is not supported");
}
@@ -204,6 +215,14 @@ public interface DatabaseAgent {
return AgentExecutionContext.jdbcExecutor().closeQuerySession(sessionId);
}
default Object readLargeValueChunk(String valueRef, long offset, int limit) {
throw new UnsupportedOperationException("LOB snapshot chunks are not supported by this agent");
}
default boolean releaseLargeValue(String valueRef) { return false; }
default void invalidateLargeValues() { }
default QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
Connection conn = getConnection();
if (conn == null) {
@@ -7,16 +7,33 @@ public final class ForeignKeyInfo {
private String column;
private String ref_table;
private String ref_column;
private String ref_schema;
private String on_delete;
public ForeignKeyInfo() {
this("", "", "", "");
}
public ForeignKeyInfo(String name, String column, String ref_table, String ref_column) {
this(name, column, ref_table, ref_column, null, null);
}
public ForeignKeyInfo(String name, String column, String ref_table, String ref_column,
String ref_schema, String on_delete) {
this.name = name;
this.column = column;
this.ref_table = ref_table;
this.ref_column = ref_column;
this.ref_schema = ref_schema;
this.on_delete = on_delete;
}
public String getRef_schema() {
return ref_schema;
}
public String getOn_delete() {
return on_delete;
}
public String getName() {
@@ -59,12 +76,14 @@ public final class ForeignKeyInfo {
return Objects.equals(name, that.name)
&& Objects.equals(column, that.column)
&& Objects.equals(ref_table, that.ref_table)
&& Objects.equals(ref_column, that.ref_column);
&& Objects.equals(ref_column, that.ref_column)
&& Objects.equals(ref_schema, that.ref_schema)
&& Objects.equals(on_delete, that.on_delete);
}
@Override
public int hashCode() {
return Objects.hash(name, column, ref_table, ref_column);
return Objects.hash(name, column, ref_table, ref_column, ref_schema, on_delete);
}
@Override
@@ -18,6 +18,9 @@ public final class IndexInfo {
// doesn't track this (provenance unknown for that dialect/path). Mirrors
// crate::types::IndexInfo::key_is_expression on the Rust side (#6312 review).
private List<Boolean> key_is_expression;
private List<String> key_directions = Collections.emptyList();
private String visibility;
private boolean key_metadata_complete;
private List<Integer> key_options = Collections.emptyList();
// True when this index is the object behind a table constraint (PRIMARY KEY / UNIQUE)
// instead of a standalone index. Dameng lists both kinds in ALL_INDEXES but only accepts
@@ -105,6 +108,18 @@ public final class IndexInfo {
return key_is_expression;
}
public List<String> getKey_directions() {
return key_directions;
}
public String getVisibility() {
return visibility;
}
public boolean getKey_metadata_complete() {
return key_metadata_complete;
}
public List<Integer> getKey_options() {
return key_options;
}
@@ -149,6 +164,18 @@ public final class IndexInfo {
this.key_is_expression = key_is_expression;
}
public void setKey_directions(List<String> key_directions) {
this.key_directions = key_directions == null ? Collections.emptyList() : key_directions;
}
public void setVisibility(String visibility) {
this.visibility = visibility;
}
public void setKey_metadata_complete(boolean key_metadata_complete) {
this.key_metadata_complete = key_metadata_complete;
}
public void setKey_options(List<Integer> key_options) {
this.key_options = key_options == null ? Collections.emptyList() : key_options;
}
@@ -172,6 +199,9 @@ public final class IndexInfo {
&& Objects.equals(included_columns, that.included_columns)
&& Objects.equals(comment, that.comment)
&& Objects.equals(key_is_expression, that.key_is_expression)
&& Objects.equals(key_directions, that.key_directions)
&& Objects.equals(visibility, that.visibility)
&& key_metadata_complete == that.key_metadata_complete
&& Objects.equals(key_options, that.key_options);
}
@@ -187,6 +217,9 @@ public final class IndexInfo {
included_columns,
comment,
key_is_expression,
key_directions,
visibility,
key_metadata_complete,
key_options,
constraint_backed
);
@@ -203,6 +236,9 @@ public final class IndexInfo {
+ ", included_columns=" + included_columns
+ ", comment=" + comment
+ ", key_is_expression=" + key_is_expression
+ ", key_directions=" + key_directions
+ ", visibility=" + visibility
+ ", key_metadata_complete=" + key_metadata_complete
+ ", key_options=" + key_options
+ ", constraint_backed=" + constraint_backed
+ ")";
@@ -162,6 +162,17 @@ public class JdbcAgentProfile {
return catalogFallbackEnabled;
}
/**
* Whether table metadata lookups should retry with a unique case-insensitive table name match.
*
* <p>Most JDBC drivers treat the table pattern passed to {@code DatabaseMetaData} as a
* pattern with driver-defined case rules. Drivers that expose case-insensitive SQL name
* resolution but case-sensitive metadata patterns can opt into this fallback.</p>
*/
public boolean getCaseInsensitiveTableMetadata() {
return false;
}
public boolean getNativeTableDdlSupported() {
return nativeTableDdlSupported;
}
@@ -15,6 +15,7 @@ import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@@ -47,11 +48,13 @@ public final class JdbcExecutor {
public final class NativeOperation implements AutoCloseable {
private final java.util.concurrent.atomic.AtomicBoolean cancelled = new java.util.concurrent.atomic.AtomicBoolean();
private final NativeOperation parent = AgentExecutionContext.statementOperation();
private NativeOperation() {
}
public void checkCancelled() {
if (parent != null) parent.checkCancelled();
if (cancelled.get()) {
throw new java.util.concurrent.CancellationException("Native JDBC operation cancelled");
}
@@ -62,6 +65,12 @@ public final class JdbcExecutor {
nativeOperations.remove(this);
}
}
/** Apply the current RPC timeout to a native-driver statement. */
public void applyStatementTimeout(Statement statement) throws SQLException {
int timeoutSecs = AgentExecutionContext.statementTimeoutSecs();
if (timeoutSecs > 0) statement.setQueryTimeout(timeoutSecs);
}
/** Register native-driver statements with this session's cancellation boundary. */
public <S extends Statement> TrackedStatement<S> trackStatement(S statement) {
java.util.Objects.requireNonNull(statement, "statement");
@@ -292,6 +301,7 @@ public final class JdbcExecutor {
try (ResultSet rs = stmt.getResultSet()) {
result = readResultSet(rs, elapsed, effectiveMaxRows, valueReader);
}
appendStatementReaderMessages(result, stmt, statementMessageReader);
// `elapsed` stops at `stmt.execute()`. Retrieving and
// converting the rows above is frequently the dominant
// cost of a statement, so report the whole duration the
@@ -308,7 +318,9 @@ public final class JdbcExecutor {
false
);
}
return withStatementMessages(result, stmt, effectiveMaxRows, statementMessageReader);
QueryResult completed = withStatementMessages(result, stmt, effectiveMaxRows, statementMessageReader);
completed.setExecution_time_ms(System.currentTimeMillis() - start);
return completed;
} finally {
activeStatements.remove(stmt);
}
@@ -403,6 +415,21 @@ public final class JdbcExecutor {
return executePage(conn, sql, schema, setSchemaSql, () -> "", options, valueReader, statementMessageReader, sessions);
}
public QueryPageResult executePage(
Connection conn,
String sql,
String schema,
Function<String, String> setSchemaSql,
Supplier<String> resetSchemaSql,
QueryPageOptions options,
ResultValueReader valueReader,
StatementMessageReader statementMessageReader,
boolean advancePastUpdateCounts
) {
return executePage(conn, sql, schema, setSchemaSql, resetSchemaSql, options, valueReader,
statementMessageReader, sessions, advancePastUpdateCounts);
}
public QueryPageResult executePage(
Connection conn,
String sql,
@@ -463,6 +490,20 @@ public final class JdbcExecutor {
StatementMessageReader.NONE, sessions, false, true);
}
public QueryPageResult executeBoundedPage(
Connection conn,
String sql,
String schema,
Function<String, String> setSchemaSql,
Supplier<String> resetSchemaSql,
QueryPageOptions options,
ResultValueReader valueReader,
StatementMessageReader statementMessageReader
) {
return executePage(conn, sql, schema, setSchemaSql, resetSchemaSql, options, valueReader,
statementMessageReader, sessions, false, true);
}
public QueryPageResult startTableRead(
Connection conn,
String sql,
@@ -589,10 +630,13 @@ public final class JdbcExecutor {
Math.max(options.getMaxRows(), 1),
statementMessageReader
);
} else {
appendStatementWarnings(result, stmt);
}
result.setExecution_time_ms(System.currentTimeMillis() - start);
activeStatements.remove(stmt);
stmt.close();
return new QueryPageResult(
QueryPageResult page = new QueryPageResult(
result.getColumns(),
result.getColumn_types(),
result.getRows(),
@@ -602,6 +646,8 @@ public final class JdbcExecutor {
null,
false
);
page.setMessages(result.getMessages());
return page;
}
phaseStarted = System.nanoTime();
@@ -630,7 +676,8 @@ public final class JdbcExecutor {
sqlTypeByIndex,
typeNameByIndex,
Math.max(options.getMaxRows(), 1),
valueReader
valueReader,
statementMessageReader
);
createdSession = session;
targetSessions.put(sessionId, session);
@@ -926,8 +973,9 @@ public final class JdbcExecutor {
while (rows.size() < effectivePageSize && session.rowsRead < session.maxRows) {
if (!session.resultSet.next()) {
closeSession(targetSessions, session.id);
return sessionPageResult(session, rows, executionTimeMs, false, null, false);
QueryPageResult page = sessionPageResult(session, rows, executionTimeMs, false, null, false);
appendStatementReaderMessages(page, closeSessionMessages(targetSessions, session.id));
return page;
}
rows.add(rowValues(session.resultSet, session.valueReader, session.sqlTypeByIndex, session.typeNameByIndex));
session.rowsRead += 1;
@@ -935,14 +983,16 @@ public final class JdbcExecutor {
if (session.rowsRead >= session.maxRows) {
boolean truncated = session.resultSet.next();
closeSession(targetSessions, session.id);
return sessionPageResult(session, rows, executionTimeMs, truncated, null, false);
QueryPageResult page = sessionPageResult(session, rows, executionTimeMs, truncated, null, false);
appendStatementReaderMessages(page, closeSessionMessages(targetSessions, session.id));
return page;
}
boolean hasMore = session.resultSet.next();
if (!hasMore) {
closeSession(targetSessions, session.id);
return sessionPageResult(session, rows, executionTimeMs, false, null, false);
QueryPageResult page = sessionPageResult(session, rows, executionTimeMs, false, null, false);
appendStatementReaderMessages(page, closeSessionMessages(targetSessions, session.id));
return page;
}
session.pendingRow = rowValues(session.resultSet, session.valueReader, session.sqlTypeByIndex, session.typeNameByIndex);
@@ -960,6 +1010,9 @@ public final class JdbcExecutor {
boolean hasMore
) {
QueryPageResult result = new QueryPageResult(session.columns, session.columnTypes, rows, 0L, executionTimeMs, truncated, sessionId, hasMore);
QueryResult messages = new QueryResult();
appendStatementWarnings(messages, session.statement);
result.setMessages(messages.getMessages());
result.setCursor_rows_read(session.rowsRead);
return result;
}
@@ -972,9 +1025,13 @@ public final class JdbcExecutor {
}
private boolean closeSession(ConcurrentHashMap<String, QuerySession> targetSessions, String sessionId) {
return closeSessionMessages(targetSessions, sessionId) != null;
}
private List<String> closeSessionMessages(ConcurrentHashMap<String, QuerySession> targetSessions, String sessionId) {
QuerySession session = targetSessions.remove(sessionId);
if (session == null) {
return false;
return null;
}
synchronized (session) {
activeStatements.remove(session.statement);
@@ -982,12 +1039,17 @@ public final class JdbcExecutor {
session.resultSet.close();
} catch (Exception ignored) {
}
List<String> messages = Collections.emptyList();
try {
messages = session.statementMessageReader.close(session.statement);
} catch (Exception ignored) {
}
try {
session.statement.close();
} catch (Exception ignored) {
}
return messages == null ? Collections.emptyList() : messages;
}
return true;
}
static String safeColumnTypeName(ResultSetMetaData meta, int columnIndex) {
@@ -1066,6 +1128,7 @@ public final class JdbcExecutor {
StatementMessageReader statementMessageReader
) {
if (!result.getColumns().isEmpty() || !result.getRows().isEmpty()) {
appendStatementWarnings(result, stmt);
return result;
}
@@ -1082,6 +1145,7 @@ public final class JdbcExecutor {
break;
}
rows.add(Collections.singletonList(message));
result.addInformationalMessage(message, warning.getSQLState());
}
}
stmt.clearWarnings();
@@ -1102,6 +1166,7 @@ public final class JdbcExecutor {
break;
}
rows.add(Collections.singletonList(message));
result.addInformationalMessage(message, null);
}
}
} catch (Exception ignored) {
@@ -1111,7 +1176,7 @@ public final class JdbcExecutor {
if (rows.isEmpty()) {
return result;
}
return new QueryResult(
QueryResult messageResult = new QueryResult(
Collections.singletonList("Message"),
Collections.singletonList("nvarchar"),
rows,
@@ -1119,6 +1184,34 @@ public final class JdbcExecutor {
result.getExecution_time_ms(),
truncated
);
messageResult.setMessages(result.getMessages());
return messageResult;
}
private static void appendStatementReaderMessages(
QueryResult result,
Statement statement,
StatementMessageReader reader
) {
try {
List<String> messages = reader.close(statement);
if (messages != null) {
for (String message : messages) {
if (message != null) result.addInformationalMessage(message, null);
}
}
} catch (Exception ignored) {
// Driver-specific informational output is advisory.
}
}
private static void appendStatementReaderMessages(QueryPageResult page, List<String> messages) {
if (messages == null || messages.isEmpty()) return;
List<Map<String, Object>> result = new ArrayList<>(page.getMessages());
for (String message : messages) {
if (message != null) result.add(Map.of("severity", "INFO", "message", message));
}
page.setMessages(result);
}
private QueryResult emptyQueryResult(long start) {
@@ -1194,6 +1287,10 @@ public final class JdbcExecutor {
StatementMessageReader NONE = statement -> Collections.emptyList();
List<String> read(Statement statement) throws SQLException;
default List<String> close(Statement statement) throws SQLException {
return Collections.emptyList();
}
}
/**
@@ -1202,6 +1299,20 @@ public final class JdbcExecutor {
* agents to convert vendor-specific column types (e.g. PostGIS
* {@code geometry}) without re-querying the metadata.
*/
public <T> T withActiveStatement(Statement statement, SqlOperation<T> operation) throws SQLException {
activeStatements.add(statement);
try {
return operation.run();
} finally {
activeStatements.remove(statement);
}
}
@FunctionalInterface
public interface SqlOperation<T> {
T run() throws SQLException;
}
public interface ColumnAwareResultValueReader extends ResultValueReader {
Object read(ResultSet rs, int index, int sqlType, String columnTypeName) throws SQLException;
@@ -1225,6 +1336,7 @@ public final class JdbcExecutor {
private final String[] typeNameByIndex;
private final int maxRows;
private final ResultValueReader valueReader;
private final StatementMessageReader statementMessageReader;
private int rowsRead;
private List<Object> pendingRow;
private long lastAccessedAtMillis;
@@ -1238,7 +1350,8 @@ public final class JdbcExecutor {
int[] sqlTypeByIndex,
String[] typeNameByIndex,
int maxRows,
ResultValueReader valueReader
ResultValueReader valueReader,
StatementMessageReader statementMessageReader
) {
this.id = id;
this.statement = statement;
@@ -1249,6 +1362,7 @@ public final class JdbcExecutor {
this.typeNameByIndex = typeNameByIndex;
this.maxRows = maxRows;
this.valueReader = valueReader;
this.statementMessageReader = statementMessageReader;
this.lastAccessedAtMillis = System.currentTimeMillis();
}
}
@@ -152,6 +152,7 @@ public final class JsonRpcServer {
void cancelActiveStatements() {
jdbcExecutor.cancelActiveStatements();
agent.invalidateLargeValues();
AbstractJdbcAgent jdbcAgent = pooledJdbcAgent();
if (jdbcAgent != null) {
jdbcAgent.releaseIdlePooledConnection(jdbcExecutor);
@@ -265,7 +266,9 @@ public final class JsonRpcServer {
}
if (AgentProtocol.METHOD_GET_COLUMNS.equals(method)) {
switchCatalog(params);
return agent.getColumns(params.get("schema").getAsString(), params.get("table").getAsString());
String currentSchema = params.has("current_schema") && !params.get("current_schema").isJsonNull()
? params.get("current_schema").getAsString() : null;
return agent.getColumnsInContext(params.get("schema").getAsString(), params.get("table").getAsString(), currentSchema);
}
if (AgentProtocol.METHOD_LIST_INDEXES.equals(method)) {
switchCatalog(params);
@@ -323,6 +326,13 @@ public final class JsonRpcServer {
if (AgentProtocol.METHOD_CLOSE_QUERY_SESSION.equals(method)) {
return agent.closeQuerySession(params.get("sessionId").getAsString());
}
if (AgentProtocol.METHOD_READ_LARGE_VALUE_CHUNK.equals(method)) {
return agent.readLargeValueChunk(params.get("valueRef").getAsString(),
params.get("offset").getAsLong(), intOrDefault(params, "limit", 4096));
}
if (AgentProtocol.METHOD_RELEASE_LARGE_VALUE.equals(method)) {
return agent.releaseLargeValue(params.get("valueRef").getAsString());
}
if (AgentProtocol.METHOD_START_TABLE_READ.equals(method)) {
return agent.startTableRead(
params.get("sql").getAsString(),
@@ -359,23 +369,36 @@ public final class JsonRpcServer {
return result;
}
if (AgentProtocol.METHOD_EXECUTE_TRANSACTION.equals(method)) {
agent.invalidateLargeValues();
Type statementsType = new TypeToken<List<String>>() {}.getType();
List<String> statements = gson.fromJson(params.get("statements"), statementsType);
return agent.executeTransaction(statements, stringOrNull(params, "schema"));
if (params.has("boundStatements")) {
return executeBlobBoundStatements(params, statements, true);
}
return executeStatementsWithTimeout(intOrDefault(params, "timeoutSecs", 0),
() -> agent.executeTransaction(statements, stringOrNull(params, "schema")));
}
if (AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION.equals(method)) {
agent.invalidateLargeValues();
return agent.beginManualTransaction(stringOrNull(params, "schema"));
}
if (AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION.equals(method)) {
agent.invalidateLargeValues();
return agent.commitManualTransaction();
}
if (AgentProtocol.METHOD_ROLLBACK_MANUAL_TRANSACTION.equals(method)) {
agent.invalidateLargeValues();
return agent.rollbackManualTransaction();
}
if (AgentProtocol.METHOD_EXECUTE_BATCH.equals(method)) {
agent.invalidateLargeValues();
Type statementsType = new TypeToken<List<String>>() {}.getType();
List<String> statements = gson.fromJson(params.get("statements"), statementsType);
return agent.executeBatch(statements, stringOrNull(params, "schema"));
if (params.has("boundStatements")) {
return executeBlobBoundStatements(params, statements, false);
}
return executeStatementsWithTimeout(intOrDefault(params, "timeoutSecs", 0),
() -> agent.executeBatch(statements, stringOrNull(params, "schema")));
}
if (AgentProtocol.METHOD_DISCONNECT.equals(method)) {
jdbcExecutor.closeAllQuerySessions();
@@ -394,6 +417,52 @@ public final class JsonRpcServer {
throw new IllegalArgumentException("Unknown method: " + method);
}
private QueryResult executeStatementsWithTimeout(int timeoutSecs, DatabaseAgent.ThrowingSupplier<QueryResult> execute) throws Exception {
try (JdbcExecutor.NativeOperation operation = jdbcExecutor.beginNativeOperation()) {
return AgentExecutionContext.withStatementTimeout(timeoutSecs, operation,
agent instanceof AbstractJdbcAgent jdbcAgent ? jdbcAgent : null, () -> {
operation.checkCancelled();
if (agent instanceof AbstractJdbcAgent jdbcAgent) {
jdbcAgent.beforeQueryExecution(jdbcAgent.requireConnected(), timeoutSecs);
}
operation.checkCancelled();
return execute.get();
});
}
}
private QueryResult executeBlobBoundStatements(JsonObject params, List<String> previews, boolean transaction) {
if (!agent.supportsBlobBindStatements()) {
throw new UnsupportedOperationException("Agent does not support blob_bind_statements_v1");
}
if (!params.get("boundStatements").isJsonArray()) {
throw new IllegalArgumentException("BLOB bound statements must be an array");
}
for (com.google.gson.JsonElement entry : params.getAsJsonArray("boundStatements")) {
if (!entry.isJsonObject()) throw new IllegalArgumentException("BLOB bound statement must be an object");
JsonObject item = entry.getAsJsonObject();
for (String field : java.util.Arrays.asList("previewSql", "sql")) {
if (!item.has(field) || !item.get(field).isJsonPrimitive()
|| !item.get(field).getAsJsonPrimitive().isString()) {
throw new IllegalArgumentException("BLOB bound statement text fields must be strings");
}
}
if (!item.has("blobParameters") || !item.get("blobParameters").isJsonArray()) {
throw new IllegalArgumentException("BLOB parameters must be an array of hexadecimal strings");
}
for (com.google.gson.JsonElement value : item.getAsJsonArray("blobParameters")) {
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
throw new IllegalArgumentException("BLOB parameter must be a hexadecimal string");
}
}
}
Type boundType = new TypeToken<List<BlobBoundStatement>>() {}.getType();
List<BlobBoundStatement> statements = gson.fromJson(params.get("boundStatements"), boundType);
BlobBoundStatement.validate(previews, statements);
return agent.executeBlobBoundStatements(previews, statements, stringOrNull(params, "schema"),
intOrDefault(params, "queryTimeoutSecs", 0), transaction);
}
private void switchCatalog(JsonObject params) throws Exception {
String database = stringOrNull(params, "database");
if (database != null && !database.trim().isEmpty() && agent.getConnection() != null) {
@@ -45,6 +45,7 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
private final Object maintenanceLock = new Object();
private final AtomicBoolean closed = new AtomicBoolean();
private ScheduledExecutorService maintenance;
private boolean blobBindStatements;
public MultiSessionJsonRpcServer(Supplier<? extends DatabaseAgent> agentFactory) {
this(agentFactory, new JdbcConnectionPoolRegistry(), RuntimeLimits.defaults());
@@ -91,6 +92,12 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
return new MultiSessionJsonRpcServer(sessionHandlerFactory, true);
}
public static MultiSessionJsonRpcServer withBlobBindings(Supplier<? extends DatabaseAgent> agentFactory) {
MultiSessionJsonRpcServer server = new MultiSessionJsonRpcServer(agentFactory);
server.blobBindStatements = true;
return server;
}
public void run() {
synchronized (outputLock) {
protocolOutput.println("{\"ready\":true}");
@@ -138,7 +145,9 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
try {
Object result;
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
result = sessionHandlerFactory == null ? AgentProtocol.multiSessionJdbcHandshakeResult() : customHandshake();
result = sessionHandlerFactory == null
? AgentProtocol.multiSessionJdbcHandshakeResult(blobBindStatements)
: customHandshake();
} else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
result = openSession(requiredSessionId(params), params);
} else if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)) {
@@ -8,6 +8,7 @@ public final class ObjectInfo {
private String schema;
private String comment;
private Boolean valid;
private String parent_name;
public ObjectInfo() {
this("", "", null, null, null);
@@ -69,6 +70,14 @@ public final class ObjectInfo {
this.valid = valid;
}
public String getParent_name() {
return parent_name;
}
public void setParent_name(String parentName) {
this.parent_name = parentName;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
@@ -78,12 +87,13 @@ public final class ObjectInfo {
&& Objects.equals(object_type, that.object_type)
&& Objects.equals(schema, that.schema)
&& Objects.equals(comment, that.comment)
&& Objects.equals(parent_name, that.parent_name)
&& Objects.equals(valid, that.valid);
}
@Override
public int hashCode() {
return Objects.hash(name, object_type, schema, comment, valid);
return Objects.hash(name, object_type, schema, comment, valid, parent_name);
}
@Override
@@ -19,6 +19,15 @@ public final class QueryPageResult {
private boolean truncated;
private String session_id;
private boolean has_more;
private List<java.util.Map<String, Object>> messages;
public List<java.util.Map<String, Object>> getMessages() {
return messages == null ? Collections.emptyList() : messages;
}
public void setMessages(List<java.util.Map<String, Object>> messages) {
this.messages = messages;
}
public QueryPageResult() {
this(Collections.emptyList(), Collections.emptyList(), 0L, 0L, false, null, false);
@@ -27,6 +27,10 @@ public final class QueryResult {
return messages == null ? Collections.emptyList() : messages;
}
public void setMessages(List<Map<String, Object>> messages) {
this.messages = messages;
}
public void addInformationalMessage(String message, String code) {
if (messages == null) messages = new ArrayList<>();
if (messages.size() >= 512) return;

Some files were not shown because too many files have changed in this diff Show More