Compare commits

..
Author SHA1 Message Date
james.maes 8425e6c673 Merge pull request 'build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)' (#45) from deps/align-gitea into develop
build / maven (push) Successful in 1m36s
maven / build (push) Successful in 1m36s
2026-11-07 19:50:25 +00:00
James MaesandClaude Opus 5.5 39c86cdcae refactor(help): register the help bundle with core HelpBundles
build / maven (push) Successful in 1m3s
maven / build (push) Successful in 1m3s
Replace the MarketingHelp STAND-IN (qqq#1198), which applied
help/qbit-marketing.yaml straight onto app and table metadata, with
core HelpBundles.register in the producer. Core's startup sync and
HelpTextRenderer now apply and resolve the bundle; the QBIT layer keeps
host help winning.

HelpBundleCoverageTest now runs core HelpBundleAssertions over the
qbit's own objects (QQQ's generated bulk processes stay core's), plus
widget/possible-value-source coverage and non-blank content, which the
core gate does not require. The empty-state help guardrail checks the
registered bundle as rendered by core, and a HOST-layer fixture proves
host help is not overridden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:45:24 -06:00
james.maesandClaude Opus 5.5 ec6e382b69 build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)
build / maven (push) Canceled after 1m10s
maven / build (push) Canceled after 1m10s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.6 -> 4.1.0-RC.26
pom.xml: qbit-assistant.version: 0.1.0-RC.12 -> 0.1.0-RC.25
pom.xml: qbit-email.version: 0.1.0-RC.3 -> 0.1.0-RC.11
pom.xml: added qqq.version=4.1.0-RC.26 (parent BOM import)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:57:07 +00:00
james.maes f4d3f2c105 Merge pull request 'ci: pin shared workflow to v1' (#44) from ci/pin-v1-develop into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 13s
QRun-IO/ci-policy: native / native-result (push) Successful in 12s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 10s
build / maven (push) Successful in 5m7s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 5m7s
2026-11-06 16:08:31 +00:00
james.maesandClaude Opus 5.5 5d687b37a7 ci: pin shared workflow to v1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
build / maven (push) Successful in 3m7s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Waiting to run
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
maven / build (push) Successful in 3m7s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:10:55 +00:00
james.maes a369e3c7b0 Merge pull request 'ci: use shared ci-workflows Maven workflow' (#43) from ci/ci-workflows into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
build / maven (push) Successful in 2m19s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 6s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 2m18s
2026-11-05 23:17:07 +00:00
james.maesandClaude Opus 5.5 9e465d7b7c ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m41s
build / maven (push) Successful in 2m32s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 2s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 16s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Failing after 7m14s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 2m33s
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:06:02 +00:00
forge-recovery fe4970e10c ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 37s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 32s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
Shared Java Maven CI / verify (push) Successful in 1m53s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 1m41s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
qrun-release / postpublish (push) Successful in 4m39s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
Shared Java Maven CI / gate (push) Successful in 12s
qrun-release / release (push) Successful in 48m37s
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 48m54s
Shared QRun source version / version (push) Successful in 4m38s
2026-11-03 18:09:54 +00:00
forge-recovery f90a1192b4 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 3s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 1m29s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 47s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 14s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 1h13m54s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
2026-11-02 22:34:49 +00:00
forge-recoveryandCodex 4c79c330b4 ci: enroll shared Gitea release-candidate lifecycle
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 31s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m48s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 19s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 10m45s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 9s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:17:48 +00:00
9b01e6087c ci: add the isolated postpublish job to the QRun release caller
Shared Java Maven CI / secrets (push) Failing after 18s
Shared Java Maven CI / verify (push) Canceled after 0s
qrun-release / postpublish (push) Canceled after 0s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 21s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m20s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 21m41s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
Release candidate / release-rc (pull_request) Skipped
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h36m23s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:50:17 +00:00
5eabd24f9b ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Canceled after 0s
Shared Java Maven CI / verify (push) Canceled after 0s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
Shared Java Maven CI / secrets (push) Failing after 38s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 30s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 2m33s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Canceled after 48m23s
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 48m51s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:18:52 +00:00
forge-recoveryandCodex fd061f6e8c ci: add the identical QRun release caller
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-7 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 2s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 2s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Failing after 2m8s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:12:59 +00:00
james.maesandCodex 4849764e2d ci: adopt managed orb 0.8 patch track (#37)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:35:08 -05:00
james.maesandCodex 4761a0242b ci: adopt shared orb 0.7.18 (#36)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:46:20 -05:00
james.maesandCodex 1bb2371d75 ci: adopt shared orb 0.7.17 (#35)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:15:49 -05:00
james.maesandCodex a01ad9487f ci: centralize QBit source scanning (#32)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:41:48 -05:00
james.maesandCodex 95ae9ff23d ci: adopt central orb 0.7.12 and stable gates (#31)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:53:54 -05:00
james.maesandCodex eab599edac ci: centralize rc release completion in qqq orb (#30)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:25:24 -05:00
james.maesandCodex bd0f76cad5 ci: adopt shared orb 0.7.11 (#29)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:59:21 -05:00
james.maes f8d67b28c9 Merge pull request #28 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:07:02 -05:00
james.maesandCodex e4bb261ead ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:02:59 -05:00
james.maes 6906859648 Merge pull request #27 from QRun-IO/feature/marketing-business-home
feat(widgets): business-only Marketing home; omit widgets without read
2026-10-16 10:19:33 -05:00
james.maesandClaude Sonnet 5.5 1c3d163104 feat(widgets): business-only Marketing home; omit widgets without read
Take Configuration Status and Agent Activity (provider configuration, model/tool
calls and tokens) off the home; expose them as MarketingWidgets.ADMIN_WIDGETS
gated on marketing.admin.read. Needs Attention gains a business-worded row per
active channel that cannot send. Home and record widgets now require
marketing.read via MarketingPermissionChecker (HIDDEN) and Base.render no longer
returns a "You need marketing.read" shell.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:12:20 -05:00
james.maes 3803e3e398 Merge pull request #26 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:04:21 -05:00
james.maesandClaude Opus 5.5 cb15fc0c27 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:21:02 -05:00
james.maes 171112162a Merge pull request #25 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
16 changed files with 515 additions and 655 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+7
View File
@@ -0,0 +1,7 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+62 -13
View File
@@ -1,25 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+3 -1
View File
@@ -6,10 +6,12 @@ All notable changes to this project are documented here. The format follows Keep
### Added
- Check Readiness warns when a channel's `rulesVerifiedDate` is older than the new `staleChannelRulesDays` (default 90), on a campaign whose content uses the channel, like `staleSourceDays` does for evidence sources. The warning, and the "rules not verified" one, now quote the channel's `rulesSourceUrl`. The agent's campaign view also shows the lead qualification note.
- The help bundle is now applied to the app and tables at run time (`MarketingHelp`, a stand-in for the core registry), so it shows when a table is empty. Every table says "How rows get created" and names **Load Verified Starter Data** where it seeds the table. The bundle version is `1`.
- The help bundle is registered with the QQQ core help registry (`HelpBundles.register`, qqq#1198), replacing the `MarketingHelp` stand-in, so it shows when a table is empty on hosts with core help content. `HelpBundleCoverageTest` uses core `HelpBundleAssertions`. Every table says "How rows get created" and names **Load Verified Starter Data** where it seeds the table. The bundle version is `1`.
- `MarketingTablesGuardrailTest` and `ChannelRulesStalenessTest`.
### Changed
- The Marketing home is business content only: **Configuration Status** and **Agent Activity** (provider configuration, model/tool calls and tokens) are off the home and exposed as `MarketingWidgets.ADMIN_WIDGETS` for a host to place on its Admin area; they need `marketing.admin.read` (hidden without it). **Needs Attention** gains one row per active channel that cannot send ("Sends on <channel> are paused - ask an admin"), linking to the channel.
- Home and record widgets require `marketing.read` through `MarketingPermissionChecker` (HIDDEN): a user with app access but no read no longer sees a "You need marketing.read ..." shell; the widgets are omitted.
- `QbitCrmGateway` uses qbit-crm 0.2.0's `crm_*` snake_case table names (`crm_contact`, ...). qbit-crm 0.1.0's camelCase tables are no longer supported.
- Make the agent limits distributed (#4): `maxConcurrentAgentRuns` counts Running `marketing_agent_run` rows in the database (a new `heartbeatAt` column, updated on each model call, lets runs whose server stopped be marked Failed first), and `maxModelCallsPerMinute` counts model-call steps in the last 60 seconds and makes a run wait rather than fail. The in-memory semaphore and rate window are gone; the settings and Marketing Policy override keep their names and defaults (the policy `maxConcurrentAgentRuns` override now applies). Hosts add `heartbeat_at` to `marketing_agent_run`. The count-then-insert window is still racy; a conditional-update claim (qqq#1178) is a follow-up.
- Move to the assistant provider SPI `0.1.0-RC.12` (package `com.kingsrook.qbits.assistant.spi`) and `qbit-build-parent` `2.1.2`, and drop the per-repository guardrail allow-lists. `qbit-assistant-provider-openai` becomes a test dependency; hosts that register it add it themselves.
+1 -1
View File
@@ -102,7 +102,7 @@ Set any existing Marketing Policy provider override to `openai-compatible` as we
The help text ships with the code it describes, as a QQQ help bundle at `help/qbit-marketing.yaml` (a classpath resource, layer `QBIT`). It has 346 entries: the Marketing app, every `marketing_*` table and each of its non-trivial fields, every record and table action, every widget (the dashboard widgets and the child lists) and every possible-value source. Each entry has a one-line summary for the tooltip and a few sentences of plain help. Labels, maximum lengths and enum values are `{{...}}` references, so the help does not repeat what the metadata already says.
Until the core registry (qqq#1198) ships, `MarketingHelp` (marked `STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8)`) applies the app and table entries to the metadata at the end of the producer, resolving the `{{...}}` references, so the table help shows when a table is empty. It only fills an empty help slot, so a host's own help wins. When the core API exists the producer will register the bundle and `MarketingHelp` goes away. Every table entry says **How rows get created** and names the **Load Verified Starter Data** action (Marketing Product) for the eight tables it seeds. `HelpBundleCoverageTest` checks that the bundle is complete, that every key and reference resolves, and that summaries stay short and plain.
The producer registers the bundle with the QQQ core help registry (`HelpBundles.register`, qqq#1198). Registration is metadata-only; on hosts that install core help content (`HelpContentMetaDataProvider`), core's startup sync writes the entries to `helpContent` and core resolves the `{{...}}` references when it serves them, so the table help shows when a table is empty. Because the bundle is at the `QBIT` layer, a host bundle or host-edited help for the same key wins. Every table entry says **How rows get created** and names the **Load Verified Starter Data** action (Marketing Product) for the eight tables it seeds. `HelpBundleCoverageTest` runs core's `HelpBundleAssertions` gate (complete coverage, every key and reference resolves, short plain summaries) and also checks widget and possible-value-source coverage.
## Starter data and setup
+5 -4
View File
@@ -18,22 +18,23 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<!-- The provider SPI (com.kingsrook.qbits.assistant.spi) only: no assistant runtime. -->
<qbit-assistant.version>0.1.0-RC.12</qbit-assistant.version>
<qbit-assistant.version>0.1.0-RC.25</qbit-assistant.version>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<qbit-email.version>0.1.0-RC.3</qbit-email.version>
<qbit-email.version>0.1.0-RC.11</qbit-email.version>
<h2.version>2.3.232</h2.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.1.2</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -208,7 +209,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -23,7 +23,6 @@ import java.util.List;
import java.util.Properties;
import com.kingsrook.qbits.marketing.agent.KernelToolRegistration;
import com.kingsrook.qbits.marketing.agent.tools.MarketingTools;
import com.kingsrook.qbits.marketing.metadata.MarketingHelp;
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
import com.kingsrook.qbits.marketing.processes.MarketingActions;
import com.kingsrook.qbits.marketing.processes.MarketingTableProcesses;
@@ -33,6 +32,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
@@ -71,6 +71,7 @@ public class MarketingQBitProducer implements QBitMetaDataProducer<MarketingQBit
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-marketing";
public static final String VERSION = loadVersion();
public static final String HELP_BUNDLE = "help/qbit-marketing.yaml";
private MarketingQBitConfig qBitConfig;
private String namespace;
@@ -201,7 +202,11 @@ public class MarketingQBitProducer implements QBitMetaDataProducer<MarketingQBit
}
qInstance.addApp(builder.buildApp(MarketingWidgets.HOME_WIDGETS));
MarketingHelp.apply(qInstance);
///////////////////////////////////////////////////////////////////////////
// help: register the bundle with core; registration is metadata-only and //
// core's startup sync applies it on hosts that have the helpContent table //
///////////////////////////////////////////////////////////////////////////
HelpBundles.register(qInstance, HELP_BUNDLE, MarketingQBitProducer.class.getClassLoader());
////////////////////////////////////
// runtime and kernel tools //
@@ -1,189 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.metadata;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-marketing.yaml (the QQQ help-bundle format) to the
** Marketing app and its marketing_* tables: what each table is for and
** how its rows get created, shown on the table screen and when it is empty.
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned
** QQQ release includes it; the bundle file stays the same.
*******************************************************************************/
public final class MarketingHelp
{
public static final String RESOURCE = "/help/qbit-marketing.yaml";
public static final String SLOT = "help";
private static final Pattern REFERENCE = Pattern.compile("\\{\\{([A-Za-z]+):([^}]*)}}");
/*******************************************************************************
** Utility class.
*******************************************************************************/
private MarketingHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = MarketingHelp.class.getResourceAsStream(RESOURCE))
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-marketing".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
}
}
/*******************************************************************************
** Apply the help to the app and every marketing_* table in the instance.
** A host's own help on a table is kept (the bundle only fills the slot).
*******************************************************************************/
public static void apply(QInstance instance) throws QException
{
Map<String, JsonNode> entries = entries();
for(QTableMetaData table : instance.getTables().values())
{
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(instance, entry));
}
}
QAppMetaData app = instance.getApp(MarketingMetaDataBuilder.APP_NAME);
JsonNode appEntry = entries.get("app:marketing;slot:" + SLOT);
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(instance, appEntry));
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(QInstance instance, JsonNode entry)
{
return (new QHelpContent().withContentAsMarkdown(resolve(instance, entry.path("summary").asText().strip()) + "\n\n" + resolve(instance, entry.path("content").asText().strip())));
}
/*******************************************************************************
** Resolve the bundle's {{label:..}}, {{default:..}}, {{maxLength:..}} and
** {{values:..}} references against the instance, so the text is plain.
*******************************************************************************/
static String resolve(QInstance instance, String text)
{
Matcher matcher = REFERENCE.matcher(text);
StringBuilder out = new StringBuilder();
while(matcher.find())
{
matcher.appendReplacement(out, Matcher.quoteReplacement(referenceText(instance, matcher.group(1), matcher.group(2))));
}
matcher.appendTail(out);
return (out.toString());
}
/*******************************************************************************
** One reference's text; unresolvable ones keep their target name.
*******************************************************************************/
private static String referenceText(QInstance instance, String kind, String target)
{
String[] tableAndField = target.split("\\.", 2);
QTableMetaData table = instance.getTable(tableAndField[0]);
QFieldMetaData field = table == null || tableAndField.length < 2 ? null : table.getFields().get(tableAndField[1]);
switch(kind)
{
case "label":
if(field != null)
{
return (field.getLabel());
}
if(table != null)
{
return (table.getLabel());
}
if(instance.getProcess(target) != null)
{
return (instance.getProcess(target).getLabel());
}
return (instance.getApp(target) != null ? instance.getApp(target).getLabel() : target);
case "default":
return (field == null || field.getDefaultValue() == null ? target : String.valueOf(field.getDefaultValue()));
case "maxLength":
return (field == null || field.getMaxLength() == null ? target : String.valueOf(field.getMaxLength()));
case "values":
QPossibleValueSource source = instance.getPossibleValueSource(target);
return (source == null || source.getEnumValues() == null ? target : source.getEnumValues().stream().map(value -> String.valueOf(value.getLabel())).collect(Collectors.joining(", ")));
default:
return (target);
}
}
}
@@ -0,0 +1,122 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.marketing.security;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionSubType;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithName;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Gates a widget on one exact permission name (marketing.read for the home
** widgets, marketing.admin.read for the admin-placeable ones). QQQ's widget
** rules can only express "<base>.hasAccess", so this checker is how a widget
** requires a read permission. Denied widgets are HIDDEN: omitted from the
** dashboard rather than shown locked.
*******************************************************************************/
public class MarketingPermissionChecker implements CustomPermissionChecker, InitializableViaCodeReference
{
public static final String PERMISSION_PROPERTY = "permissionName";
private String permissionName;
/*******************************************************************************
** Permission rules requiring the named permission; hidden when missing.
*******************************************************************************/
public static QPermissionRules rules(String permissionName)
{
return (new QPermissionRules()
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withDenyBehavior(DenyBehavior.HIDDEN)
.withCustomPermissionChecker(new QCodeReferenceWithProperties(MarketingPermissionChecker.class, Map.of(PERMISSION_PROPERTY, permissionName))));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void initialize(QCodeReference codeReference)
{
if(codeReference instanceof QCodeReferenceWithProperties withProperties)
{
this.permissionName = ValueUtils.getValueAsString(withProperties.getProperties().get(PERMISSION_PROPERTY));
}
}
/*******************************************************************************
** Deny unless the session holds the permission (fail closed). The system
** user (scheduler) passes.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
if(session instanceof QSystemUserSession)
{
return;
}
if(permissionName == null || session == null || !session.hasPermission(permissionName))
{
throw (new QPermissionDeniedException("Permission denied."));
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean handlesBuildAvailablePermission()
{
return (true);
}
/*******************************************************************************
** The exact permission name.
*******************************************************************************/
@Override
public AvailablePermission buildAvailablePermission(QPermissionRules rules, PermissionSubType permissionSubType, String baseName, MetaDataWithName metaDataWithName, String objectType)
{
AvailablePermission permission = buildBaseAvailablePermission(rules, permissionSubType, metaDataWithName, objectType);
return (permission == null || permissionName == null ? null : permission.withName(permissionName));
}
}
@@ -48,11 +48,14 @@ import com.kingsrook.qbits.marketing.model.enums.ContentStatus;
import com.kingsrook.qbits.marketing.model.enums.ExecutionStatus;
import com.kingsrook.qbits.marketing.model.enums.FindingSeverity;
import com.kingsrook.qbits.marketing.model.enums.FindingStatus;
import com.kingsrook.qbits.marketing.security.MarketingPermissionChecker;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
@@ -75,15 +78,19 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
** The Marketing widgets: the app home dashboard (the one Marketing
** overview - other apps only link to it) and two record widgets.
**
** - Home: Needs Attention, Campaigns, Agent Activity, Configuration Status.
** - Home: Needs Attention, Campaigns (business content only).
** - Admin-placeable (not on the home): Agent Activity (model/tool calls and
** tokens) and Configuration Status (provider configuration) are system
** content; a host places ADMIN_WIDGETS on its Admin area. They need
** marketing.admin.read.
** - Campaign record: Results (per stage; "Not measured" is never shown as 0).
** - Content record: Proposed Publication (the exact text, destination,
** recipients and schedule a reviewer approves).
**
** All are full-width tables of at most three columns (or one HTML block),
** so they read at every width without inner scrolling. All data is real;
** with none, they say so. Renderers require marketing.read and read with
** the viewer's record locks.
** with none, they say so. Home widgets require marketing.read (omitted, never
** shown locked, without it) and read with the viewer's record locks.
*******************************************************************************/
public final class MarketingWidgets
{
@@ -96,7 +103,12 @@ public final class MarketingWidgets
private static final DateTimeFormatter WHEN_FORMAT = DateTimeFormatter.ofPattern("MMM d, yyyy h:mm a z", Locale.US);
public static final List<String> HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS, AGENT_ACTIVITY, CONFIGURATION);
public static final List<String> HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS);
/////////////////////////////////////////////////////////////////////////
// system content: for a host to place on its Admin area, not the home //
/////////////////////////////////////////////////////////////////////////
public static final List<String> ADMIN_WIDGETS = List.of(AGENT_ACTIVITY, CONFIGURATION);
@@ -115,12 +127,12 @@ public final class MarketingWidgets
public static List<QWidgetMetaData> buildWidgets()
{
return (List.of(
widget(NEEDS_ATTENTION, "Needs Attention", WidgetType.TABLE, NeedsAttention.class),
widget(CAMPAIGNS, "Campaigns", WidgetType.TABLE, Campaigns.class),
widget(AGENT_ACTIVITY, "Agent Activity", WidgetType.TABLE, AgentActivity.class),
widget(CONFIGURATION, "Configuration Status", WidgetType.TABLE, Configuration.class),
widget(CAMPAIGN_RESULTS, "Results", WidgetType.TABLE, CampaignResults.class),
widget(CONTENT_PROPOSAL, "Proposed Publication", WidgetType.HTML, ContentProposal.class)));
widget(NEEDS_ATTENTION, "Needs Attention", WidgetType.TABLE, NeedsAttention.class, MarketingPermissions.READ),
widget(CAMPAIGNS, "Campaigns", WidgetType.TABLE, Campaigns.class, MarketingPermissions.READ),
widget(AGENT_ACTIVITY, "Agent Activity", WidgetType.TABLE, AgentActivity.class, MarketingPermissions.ADMIN_READ),
widget(CONFIGURATION, "Configuration Status", WidgetType.TABLE, Configuration.class, MarketingPermissions.ADMIN_READ),
widget(CAMPAIGN_RESULTS, "Results", WidgetType.TABLE, CampaignResults.class, MarketingPermissions.READ),
widget(CONTENT_PROPOSAL, "Proposed Publication", WidgetType.HTML, ContentProposal.class, MarketingPermissions.READ)));
}
@@ -128,7 +140,7 @@ public final class MarketingWidgets
/*******************************************************************************
**
*******************************************************************************/
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer)
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer, String permission)
{
return (new QWidgetMetaData()
.withName(name)
@@ -136,7 +148,7 @@ public final class MarketingWidgets
.withType(type.getType())
.withGridColumns(12)
.withIsCard(true)
.withPermissionRules(MarketingPermissions.hasAccessRules(MarketingPermissions.BASE))
.withPermissionRules(MarketingPermissionChecker.rules(permission))
.withCodeReference(new QCodeReference(renderer)));
}
@@ -147,16 +159,26 @@ public final class MarketingWidgets
*******************************************************************************/
abstract static class Base extends AbstractWidgetRenderer
{
/*******************************************************************************
** The permission this widget needs (rendering is refused without it, behind
** the widget's own rules: no "you need ..." text is ever returned).
*******************************************************************************/
String requiredPermission()
{
return (MarketingPermissions.READ);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
QSession session = QContext.getQSession();
if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(MarketingPermissions.READ)))
if(!MarketingPermissions.has(requiredPermission()))
{
return (new RenderWidgetOutput(new RawHTML(null, "<p>You need " + MarketingPermissions.READ + " to see marketing data.</p>")));
throw (new QPermissionDeniedException("Permission denied."));
}
return (renderFor(input));
}
@@ -283,9 +305,33 @@ public final class MarketingWidgets
"what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", when(r.getValue("createDate"))));
}
///////////////////////////////////////////////////////////////////////////////////
// a channel that cannot send: said in business words (the provider detail is in //
// the admin-only Configuration Status widget) //
///////////////////////////////////////////////////////////////////////////////////
for(QRecord r : MarketingStore.query(MarketingChannel.TABLE_NAME, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)).withOrderBy(new QFilterOrderBy("id")).withLimit(50)))
{
if(StringUtils.hasContent(r.getValueString("transportName")) && !canSend(r.getValueString("transportName")))
{
rows.add(row("item", link(MarketingChannel.TABLE_NAME, r.getValue("id"), r.getValueString("name")), "what", "Sends on " + r.getValueString("name") + " are paused - ask an admin",
"since", ""));
}
}
return (new RenderWidgetOutput(table(List.of(column("Item", "item", true), column("What", "what", false), column("Since", "since", false)), rows,
"Nothing needs attention.")));
}
/*******************************************************************************
** Can the named transport send (registered, and operational or test-only)?
*******************************************************************************/
private static boolean canSend(String transportName) throws QException
{
MarketingTransport transport = MarketingRuntime.current().getTransport(transportName);
return (transport != null && transport.getStatus().isUsable());
}
}
@@ -338,6 +384,17 @@ public final class MarketingWidgets
*******************************************************************************/
public static class AgentActivity extends Base
{
/*******************************************************************************
** System content: marketing.admin.read.
*******************************************************************************/
@Override
String requiredPermission()
{
return (MarketingPermissions.ADMIN_READ);
}
/*******************************************************************************
**
*******************************************************************************/
@@ -379,6 +436,17 @@ public final class MarketingWidgets
*******************************************************************************/
public static class Configuration extends Base
{
/*******************************************************************************
** System content: marketing.admin.read.
*******************************************************************************/
@Override
String requiredPermission()
{
return (MarketingPermissions.ADMIN_READ);
}
/*******************************************************************************
**
*******************************************************************************/
@@ -29,13 +29,16 @@ import com.kingsrook.qbits.marketing.model.MarketingContent;
import com.kingsrook.qbits.marketing.model.MarketingExecution;
import com.kingsrook.qbits.marketing.model.MarketingFinding;
import com.kingsrook.qbits.marketing.model.MarketingProduct;
import com.kingsrook.qbits.marketing.model.enums.AiContentPolicy;
import com.kingsrook.qbits.marketing.model.enums.CampaignGoal;
import com.kingsrook.qbits.marketing.model.enums.ChannelType;
import com.kingsrook.qbits.marketing.model.enums.ClaimStatus;
import com.kingsrook.qbits.marketing.model.enums.FindingSeverity;
import com.kingsrook.qbits.marketing.model.enums.FindingStatus;
import com.kingsrook.qbits.marketing.model.enums.FindingType;
import com.kingsrook.qbits.marketing.processes.MarketingActions;
import com.kingsrook.qbits.marketing.readiness.ReadinessChecker;
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
import com.kingsrook.qbits.marketing.seed.MarketingStarterData;
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
import com.kingsrook.qbits.marketing.transport.QqqMessagingEmailTransport;
@@ -49,8 +52,11 @@ import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolContext;
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolInput;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
@@ -64,6 +70,7 @@ import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
@@ -117,7 +124,11 @@ class ComponentsTest extends BaseMarketingTest
assertThat(campaigns.getRows()).hasSize(4);
assertThat(campaigns.getColumns()).hasSizeLessThanOrEqualTo(3);
assertThat(((TableData) render(MarketingWidgets.AGENT_ACTIVITY, null).getWidgetData()).getNoRowsFoundHTML()).contains("not run yet");
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows()).isEmpty();
//////////////////////////////////////////////////////////////////////////////////////////
// the starter email channel's transport is configured but not verified: sends paused //
//////////////////////////////////////////////////////////////////////////////////////////
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows())
.singleElement().satisfies(r -> assertThat(String.valueOf(r.get("what"))).endsWith("are paused - ask an admin"));
TableData results = (TableData) render(MarketingWidgets.CAMPAIGN_RESULTS, campaignId).getWidgetData();
assertThat(results.getRows()).anySatisfy(r -> assertThat(String.valueOf(r.get("result"))).startsWith("0 unique actors"));
@@ -127,8 +138,81 @@ class ComponentsTest extends BaseMarketingTest
RawHTML proposal = (RawHTML) render(MarketingWidgets.CONTENT_PROPOSAL, contentId).getWidgetData();
assertThat(proposal.getHtml()).contains("&lt;script&gt;").doesNotContain("<script>").contains("Not approved");
as(user("no-read", com.kingsrook.qbits.marketing.security.MarketingPermissions.APP_ACCESS));
assertThat(((RawHTML) render(MarketingWidgets.CAMPAIGNS, null).getWidgetData()).getHtml()).contains("marketing.read");
}
/*******************************************************************************
** The business home lists only business widgets: no configuration status
** and no agent activity (token and model usage), which are admin-placeable.
*******************************************************************************/
@Test
void testHomeHasNoSystemWidgets()
{
List<String> home = qInstance.getApp("marketing").getWidgets();
assertThat(home).containsExactly(MarketingWidgets.NEEDS_ATTENTION, MarketingWidgets.CAMPAIGNS)
.doesNotContain(MarketingWidgets.CONFIGURATION, MarketingWidgets.AGENT_ACTIVITY);
assertThat(MarketingWidgets.ADMIN_WIDGETS).containsExactly(MarketingWidgets.AGENT_ACTIVITY, MarketingWidgets.CONFIGURATION);
assertThat(qInstance.getWidget(MarketingWidgets.CONFIGURATION)).isNotNull();
assertThat(qInstance.getWidget(MarketingWidgets.AGENT_ACTIVITY)).isNotNull();
}
/*******************************************************************************
** App access without marketing.read: every read-requiring widget is omitted
** (no permission) and never renders a "You need ..." shell. Admins still
** get the moved widgets; read-only users do not.
*******************************************************************************/
@Test
void testWidgetsAreOmittedWithoutRead() throws QException
{
List<String> readWidgets = List.of(MarketingWidgets.NEEDS_ATTENTION, MarketingWidgets.CAMPAIGNS, MarketingWidgets.CAMPAIGN_RESULTS, MarketingWidgets.CONTENT_PROPOSAL);
List<String> adminWidgets = MarketingWidgets.ADMIN_WIDGETS;
as(user("no-read", MarketingPermissions.APP_ACCESS));
for(String name : CollectionUtils.mergeLists(readWidgets, adminWidgets))
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isFalse();
assertThatThrownBy(() -> render(name, null)).as(name).isInstanceOf(QPermissionDeniedException.class);
}
as(user("reader", MarketingPermissions.APP_ACCESS, MarketingPermissions.READ));
for(String name : readWidgets)
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isTrue();
}
for(String name : adminWidgets)
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isFalse();
}
as(admin());
for(String name : adminWidgets)
{
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isTrue();
assertThat(render(name, null).getWidgetData()).as(name).isInstanceOf(TableData.class);
}
}
/*******************************************************************************
** A channel that cannot send adds one business-worded row linking to the
** channel; nothing when every channel can send.
*******************************************************************************/
@Test
void testNeedsAttentionSaysWhenAChannelCannotSend() throws QException
{
testChannel();
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows()).isEmpty();
Integer blocked = channel("Newsletter", ChannelType.TEST_SINK, "no-such-transport", AiContentPolicy.ALLOWED, true);
List<Map<String, Object>> rows = ((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows();
assertThat(rows).hasSize(1);
assertThat(String.valueOf(rows.get(0).get("what"))).isEqualTo("Sends on Newsletter are paused - ask an admin");
assertThat(String.valueOf(rows.get(0).get("item"))).contains("marketing_channel").contains(String.valueOf(blocked)).contains("Newsletter");
assertThat(String.valueOf(rows.get(0))).doesNotContain("transport").doesNotContain("Not configured");
}
@@ -17,404 +17,108 @@
package com.kingsrook.qbits.marketing;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.actions.help.HelpTarget;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundle;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleEntry;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleLayer;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with
** HelpBundleAssertions.forInstance(qInstance).bundles("help/qbit-marketing.yaml").owning(...).assertComplete()
** once qqq#1198 ships.
**
** The help bundle (help/qbit-marketing.yaml) covers everything the qbit
** produces, and every entry is sound:
** - the app, every marketing_* table, every non-trivial field (all but the
** primary key, createDate, modifyDate and hidden fields), every process,
** every widget and every possible-value source has an entry;
** - every entry key resolves to a real object;
** - summaries are at most 120 characters, with no markup;
** - every {{...}} reference resolves.
**
** Each test fails with the full list of missing or broken keys.
** produces, and every entry is sound. Core's HelpBundleAssertions checks
** that the app, every marketing_* table, every non-trivial field and every
** process has help, that every key and {{...}} reference resolves and that
** summaries are short plain text. Core does not require widget or
** possible-value-source coverage, so those are checked here too. QQQ's
** generated bulk table processes are core's to document and are not owned.
*******************************************************************************/
class HelpBundleCoverageTest extends BaseMarketingTest
{
private static final String BUNDLE_RESOURCE = "help/qbit-marketing.yaml";
private static final int MAX_SUMMARY_LENGTH = 120;
private static final String MARKUP_CHARACTERS = "<>*_`[]#";
private static final Pattern REFERENCE = Pattern.compile("\\{\\{([A-Za-z]+):([^}]*)}}");
/*******************************************************************************
** One bundle entry.
*******************************************************************************/
private record Entry(String key, String summary, String content)
{
}
/*******************************************************************************
** The bundle header names this qbit, at its layer.
** The producer registers exactly this qbit's bundle, at the QBIT layer.
*******************************************************************************/
@Test
void testHeader() throws IOException
void testRegisteredBundle()
{
Map<String, Object> bundle = loadBundle();
assertThat(bundle.get("bundle")).isEqualTo("qbit-marketing");
assertThat(bundle.get("layer")).isEqualTo("QBIT");
assertThat(bundle.get("version")).isNotNull();
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-marketing");
assertThat(bundle.getLayer()).isEqualTo(HelpBundleLayer.QBIT);
assertThat(bundle.getVersion()).isEqualTo(MarketingQBitProducer.VERSION);
});
}
/*******************************************************************************
** Every object the qbit owns has an entry.
** Core coverage gate over everything the qbit owns.
*******************************************************************************/
@Test
void testEveryOwnedObjectHasAnEntry() throws IOException
void testCoreCoverageGate()
{
Set<String> keys = new LinkedHashSet<>();
List<String> problems = new ArrayList<>();
for(Entry entry : loadEntries())
{
if(!keys.add(entry.key()))
{
problems.add("duplicate entry: " + entry.key());
}
}
for(String expected : expectedKeys())
{
if(!keys.contains(expected))
{
problems.add("missing entry: " + expected);
}
}
assertNoProblems(problems);
HelpBundleAssertions.forInstance(qInstance)
.bundles(MarketingQBitProducer.HELP_BUNDLE)
.owning(HelpBundleCoverageTest::ownedByMarketing)
.assertComplete();
}
/*******************************************************************************
** Every entry key names something that exists.
** The qbit owns its app, marketing_* objects and the processes it produces.
** QQQ's generated bulk table processes (no source qbit) are core's to document.
*******************************************************************************/
private static boolean ownedByMarketing(HelpTarget target)
{
if(target.owner() instanceof QProcessMetaData process && process.getSourceQBitName() == null)
{
return (false);
}
return (target.name().equals("marketing") || target.name().startsWith("marketing_") || target.name().startsWith("Marketing"));
}
/*******************************************************************************
** Every widget and possible-value source has an entry, and every entry has
** a summary and content.
*******************************************************************************/
@Test
void testEveryKeyResolves() throws IOException
void testWidgetsSourcesAndContent()
{
List<String> problems = new ArrayList<>();
for(Entry entry : loadEntries())
Map<HelpBundles.EntryKey, HelpBundles.WinningEntry> entries = HelpBundles.of(qInstance).resolveWinningEntries();
List<String> problems = new ArrayList<>();
TreeSet<String> expected = new TreeSet<>();
qInstance.getWidgets().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> expected.add("widget:" + n + ";slot:label"));
qInstance.getPossibleValueSources().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> expected.add("possibleValueSource:" + n + ";slot:help"));
for(String key : expected)
{
String problem = resolveKey(entry.key());
if(problem != null)
if(!entries.containsKey(new HelpBundles.EntryKey(key, "ALL_SCREENS")))
{
problems.add(entry.key() + ": " + problem);
problems.add("missing entry: " + key);
}
}
assertNoProblems(problems);
}
/*******************************************************************************
** Summaries are short, plain text; every entry has content.
*******************************************************************************/
@Test
void testSummariesAndContent() throws IOException
{
List<String> problems = new ArrayList<>();
for(Entry entry : loadEntries())
HelpBundle bundle = HelpBundles.of(qInstance).getBundles().getFirst();
for(HelpBundleEntry entry : bundle.getEntries())
{
String summary = entry.summary();
if(summary == null || summary.isBlank())
if(entry.getSummary() == null || entry.getSummary().isBlank())
{
problems.add(entry.key() + ": no summary");
continue;
problems.add(entry.getKey() + ": no summary");
}
if(summary.length() > MAX_SUMMARY_LENGTH)
if(entry.getContent() == null || entry.getContent().isBlank())
{
problems.add(entry.key() + ": summary is " + summary.length() + " characters (limit " + MAX_SUMMARY_LENGTH + ")");
}
for(char c : MARKUP_CHARACTERS.toCharArray())
{
if(summary.indexOf(c) >= 0)
{
problems.add(entry.key() + ": summary contains the markup character " + c);
}
}
if(entry.content() == null || entry.content().isBlank())
{
problems.add(entry.key() + ": no content");
problems.add(entry.getKey() + ": no content");
}
}
assertNoProblems(problems);
}
/*******************************************************************************
** Every {{...}} reference resolves.
*******************************************************************************/
@Test
void testEveryReferenceResolves() throws IOException
{
List<String> problems = new ArrayList<>();
for(Entry entry : loadEntries())
{
for(String text : new String[] { entry.summary(), entry.content() })
{
if(text == null)
{
continue;
}
Matcher matcher = REFERENCE.matcher(text);
while(matcher.find())
{
String problem = resolveReference(matcher.group(1), matcher.group(2));
if(problem != null)
{
problems.add(entry.key() + ": {{" + matcher.group(1) + ":" + matcher.group(2) + "}} " + problem);
}
}
if(text.replaceAll(REFERENCE.pattern(), "").contains("{{"))
{
problems.add(entry.key() + ": malformed {{ reference");
}
}
}
assertNoProblems(problems);
}
/*******************************************************************************
** The keys the bundle must contain, from the produced instance.
*******************************************************************************/
private Set<String> expectedKeys()
{
Set<String> keys = new TreeSet<>();
keys.add("app:" + MarketingMetaDataBuilder.APP_NAME + ";slot:help");
for(QTableMetaData table : qInstance.getTables().values())
{
if(!table.getName().startsWith("marketing_"))
{
continue;
}
keys.add("table:" + table.getName() + ";slot:help");
for(QFieldMetaData field : table.getFields().values())
{
if(isNonTrivial(table, field))
{
keys.add("table:" + table.getName() + ";field:" + field.getName());
}
}
}
/////////////////////////////////////////////////////////////////////////
// the qbit's own processes (QQQ also generates bulk table processes) //
/////////////////////////////////////////////////////////////////////////
for(QProcessMetaData process : qInstance.getProcesses().values())
{
if(process.getSourceQBitName() != null)
{
keys.add("process:" + process.getName() + ";slot:help");
}
}
qInstance.getWidgets().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> keys.add("widget:" + n + ";slot:label"));
qInstance.getPossibleValueSources().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> keys.add("possibleValueSource:" + n + ";slot:help"));
return (keys);
}
/*******************************************************************************
** Every field except the primary key, createDate, modifyDate and hidden ones.
*******************************************************************************/
private static boolean isNonTrivial(QTableMetaData table, QFieldMetaData field)
{
String name = field.getName();
return (!name.equals(table.getPrimaryKeyField()) && !name.equals("createDate") && !name.equals("modifyDate") && !Boolean.TRUE.equals(field.getIsHidden()));
}
/*******************************************************************************
** Null if the key names a real object, else why not.
*******************************************************************************/
private String resolveKey(String key)
{
Map<String, String> parts = new LinkedHashMap<>();
for(String part : key.split(";"))
{
int colon = part.indexOf(':');
if(colon < 1)
{
return ("malformed key part [" + part + "]");
}
parts.put(part.substring(0, colon), part.substring(colon + 1));
}
if(parts.containsKey("app"))
{
return (qInstance.getApp(parts.get("app")) == null ? "no such app" : slotProblem(parts, "help"));
}
if(parts.containsKey("table"))
{
QTableMetaData table = qInstance.getTable(parts.get("table"));
if(table == null)
{
return ("no such table");
}
if(parts.containsKey("field"))
{
return (table.getFields().containsKey(parts.get("field")) ? null : "no such field");
}
if(parts.containsKey("section"))
{
return (table.getSections() != null && table.getSections().stream().map(QFieldSection::getName).anyMatch(parts.get("section")::equals) ? null : "no such section");
}
return (slotProblem(parts, "help"));
}
if(parts.containsKey("process"))
{
QProcessMetaData process = qInstance.getProcess(parts.get("process"));
if(process == null)
{
return ("no such process");
}
if(parts.containsKey("step"))
{
return (process.getStep(parts.get("step")) == null ? "no such step" : null);
}
return (parts.containsKey("field") ? "process fields are not covered by this bundle" : slotProblem(parts, "help"));
}
if(parts.containsKey("widget"))
{
return (qInstance.getWidget(parts.get("widget")) == null ? "no such widget" : slotProblem(parts, "label"));
}
if(parts.containsKey("possibleValueSource"))
{
return (qInstance.getPossibleValueSource(parts.get("possibleValueSource")) == null ? "no such possible-value source" : slotProblem(parts, "help"));
}
return ("unsupported key");
}
/*******************************************************************************
** The slot must be the expected one.
*******************************************************************************/
private static String slotProblem(Map<String, String> parts, String expectedSlot)
{
return (expectedSlot.equals(parts.get("slot")) ? null : "expected slot:" + expectedSlot);
}
/*******************************************************************************
** Null if the live reference resolves, else why not.
*******************************************************************************/
private String resolveReference(String kind, String target)
{
String[] tableAndField = target.split("\\.", 2);
QTableMetaData table = qInstance.getTable(tableAndField[0]);
QFieldMetaData field = table == null || tableAndField.length < 2 ? null : table.getFields().get(tableAndField[1]);
switch(kind)
{
case "label":
{
if(tableAndField.length == 2)
{
return (field == null ? "does not resolve to a table field" : null);
}
boolean found = table != null || qInstance.getProcess(target) != null || qInstance.getApp(target) != null;
return (found ? null : "does not resolve to a table, process or app");
}
case "default":
{
return (field == null ? "does not resolve to a table field" : (field.getDefaultValue() == null ? "the field has no default value" : null));
}
case "maxLength":
{
return (field == null ? "does not resolve to a table field" : (field.getMaxLength() == null ? "the field has no maximum length" : null));
}
case "values":
{
QPossibleValueSource source = qInstance.getPossibleValueSource(target);
return (source == null ? "does not resolve to a possible-value source" : (source.getType() == QPossibleValueSourceType.ENUM ? null : "is not an enum source"));
}
default:
{
return ("unsupported reference kind");
}
}
}
/*******************************************************************************
** The bundle, as parsed YAML.
*******************************************************************************/
private static Map<String, Object> loadBundle() throws IOException
{
try(InputStream inputStream = HelpBundleCoverageTest.class.getClassLoader().getResourceAsStream(BUNDLE_RESOURCE))
{
assertThat(inputStream).as("classpath resource " + BUNDLE_RESOURCE).isNotNull();
@SuppressWarnings("unchecked")
Map<String, Object> bundle = new ObjectMapper(new YAMLFactory()).readValue(inputStream, Map.class);
return (bundle);
}
}
/*******************************************************************************
** The bundle's entries.
*******************************************************************************/
@SuppressWarnings("unchecked")
private static List<Entry> loadEntries() throws IOException
{
List<Entry> entries = new ArrayList<>();
for(Map<String, Object> row : (List<Map<String, Object>>) loadBundle().get("entries"))
{
entries.add(new Entry(String.valueOf(row.get("key")), (String) row.get("summary"), (String) row.get("content")));
}
return (entries);
}
/*******************************************************************************
** Fail with every problem at once.
*******************************************************************************/
private static void assertNoProblems(List<String> problems)
{
assertThat(problems).as(problems.size() + " help bundle problem(s):\n" + String.join("\n", problems)).isEmpty();
}
@@ -26,10 +26,10 @@ import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import com.kingsrook.qbits.marketing.metadata.MarketingHelp;
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
import com.kingsrook.qbits.marketing.processes.MarketingTableProcesses;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.help.HelpTextRenderer;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -131,9 +131,8 @@ class MarketingTablesGuardrailTest extends BaseMarketingTest
String action = qInstance.getProcess(MarketingTableProcesses.LOAD_STARTER_DATA).getLabel();
for(String table : marketingTables())
{
List<QHelpContent> help = help(table);
assertThat(help).as(table + " table help").isNotEmpty();
String content = help.getFirst().getContent();
String content = help("table:" + table + ";slot:help");
assertThat(content).as(table + " table help").isNotBlank();
assertThat(content).as(table + " help says how rows get created").contains("How rows get created");
assertThat(content).as(table + " help has no unresolved reference").doesNotContain("{{");
if(SEEDED_BY_STARTER_DATA.contains(table))
@@ -141,36 +140,45 @@ class MarketingTablesGuardrailTest extends BaseMarketingTest
assertThat(content).as(table + " help names the starter-data action").contains(action);
}
}
assertThat(qInstance.getApp(MarketingMetaDataBuilder.APP_NAME).getHelpContent()).containsKey(MarketingHelp.SLOT);
assertThat(MarketingHelp.entries().keySet()).anyMatch(key -> key.startsWith("table:marketing_content;slot:"));
assertThat(help("marketing_content").getFirst().getContent()).as("enum references become the values").contains("Draft");
assertThat(help("app:" + MarketingMetaDataBuilder.APP_NAME + ";slot:help")).isNotBlank();
assertThat(help("table:marketing_content;slot:help")).as("enum references become the values").contains("Draft");
}
/*******************************************************************************
** A host's own help on a table is kept.
** The bundle is registered at the QBIT layer, so a host bundle for the same
** key wins and the host's own help is kept.
*******************************************************************************/
@Test
void hostHelpIsNotOverridden() throws Exception
void hostHelpIsNotOverridden()
{
qInstance.getTable("marketing_claim").getHelpContent().put(MarketingHelp.SLOT, List.of(new QHelpContent().withContentAsMarkdown("host text")));
MarketingHelp.apply(qInstance);
assertThat(help("marketing_claim").getFirst().getContent()).isEqualTo("host text");
HelpBundles.register(qInstance, "help/test-host-override.yaml");
assertThat(help("table:marketing_claim;slot:help")).isEqualTo("host text");
assertThat(help("table:marketing_product;slot:help")).contains("How rows get created");
}
/*******************************************************************************
**
** The winning bundle text for a key (ALL_SCREENS role), rendered by core
** against the instance the way the frontend sees it.
*******************************************************************************/
private List<QHelpContent> help(String table)
private String help(String key)
{
return (qInstance.getTable(table).getHelpContent() == null ? null : qInstance.getTable(table).getHelpContent().get(MarketingHelp.SLOT));
HelpBundles.WinningEntry winner = HelpBundles.of(qInstance).resolveWinningEntries().get(new HelpBundles.EntryKey(key, "ALL_SCREENS"));
if(winner == null)
{
return (null);
}
HelpTextRenderer renderer = new HelpTextRenderer(qInstance);
String summary = winner.entry().getSummary() == null ? "" : renderer.render(winner.entry().getSummary().strip());
return ((summary + "\n\n" + renderer.render(winner.entry().getContent().strip())).strip());
}
/*******************************************************************************
**
*******************************************************************************/
@@ -0,0 +1,6 @@
bundle: test-host-override
version: "1"
layer: HOST
entries:
- key: table:marketing_claim;slot:help
content: host text