From e4bb261ead998060431c775760691427bbf125ab Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 20 Oct 2026 19:02:59 -0500 Subject: [PATCH 01/16] ci: adopt verified central QQQ orb 0.7.3 Co-Authored-By: Codex --- .circleci/config.yml | 68 +++++++------------------------------------- .qqq-ci.yml | 16 +++++++++++ 2 files changed, 27 insertions(+), 57 deletions(-) create mode 100644 .qqq-ci.yml diff --git a/.circleci/config.yml b/.circleci/config.yml index aec59b2..113ce37 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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.7.3 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.7.3 + filters: + branches: + ignore: automation/qbit-rc-pending + tags: + only: /^v\d+\.\d+\.\d+$/ diff --git a/.qqq-ci.yml b/.qqq-ci.yml new file mode 100644 index 0000000..3f2b2d1 --- /dev/null +++ b/.qqq-ci.yml @@ -0,0 +1,16 @@ +profile: qbit +orb_version: 0.7.3 +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: false -- 2.54.0 From bd0f76cad5d6195ea82e3dffa1040c16b6e0bac3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 00:59:21 -0500 Subject: [PATCH 02/16] ci: adopt shared orb 0.7.11 (#29) Co-authored-by: Codex --- .circleci/config.yml | 4 ++-- .qqq-ci.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 113ce37..def7f95 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,12 +1,12 @@ version: 2.1 setup: true orbs: - qqq-orb: kingsrook/qqq-orb@0.7.3 + qqq-orb: kingsrook/qqq-orb@0.7.11 workflows: setup: jobs: - qqq-orb/generate_pipeline: - orb_version: 0.7.3 + orb_version: 0.7.11 filters: branches: ignore: automation/qbit-rc-pending diff --git a/.qqq-ci.yml b/.qqq-ci.yml index 3f2b2d1..7b032d6 100644 --- a/.qqq-ci.yml +++ b/.qqq-ci.yml @@ -1,5 +1,5 @@ profile: qbit -orb_version: 0.7.3 +orb_version: 0.7.11 default_branch: develop java_version: '21' browsers: true -- 2.54.0 From eab599edac82469ccfde010073cbeb441d341207 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 02:25:24 -0500 Subject: [PATCH 03/16] ci: centralize rc release completion in qqq orb (#30) Co-authored-by: Codex --- .github/workflows/release-rc.yml | 76 ++++++++++++++++++++++++++------ .qqq-ci.yml | 2 +- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml index a342ba8..0158aa2 100644 --- a/.github/workflows/release-rc.yml +++ b/.github/workflows/release-rc.yml @@ -1,26 +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 - secrets: inherit + 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@7d73ac90b6b5a6acb61b4d3fa56625c5b6dcc652 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 diff --git a/.qqq-ci.yml b/.qqq-ci.yml index 7b032d6..a8863c3 100644 --- a/.qqq-ci.yml +++ b/.qqq-ci.yml @@ -13,4 +13,4 @@ publish: release_candidate: true release: true asciidoc: false -release_completion: false +release_completion: true -- 2.54.0 From 95ae9ff23d2f811ba87f2179b991ec1f42a15fb3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 08:53:54 -0500 Subject: [PATCH 04/16] ci: adopt central orb 0.7.12 and stable gates (#31) Co-authored-by: Codex --- .circleci/config.yml | 4 ++-- .github/workflows/release-rc.yml | 2 +- .qqq-ci.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index def7f95..c9cb461 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,12 +1,12 @@ version: 2.1 setup: true orbs: - qqq-orb: kingsrook/qqq-orb@0.7.11 + qqq-orb: kingsrook/qqq-orb@0.7.12 workflows: setup: jobs: - qqq-orb/generate_pipeline: - orb_version: 0.7.11 + orb_version: 0.7.12 filters: branches: ignore: automation/qbit-rc-pending diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml index 0158aa2..b48a32a 100644 --- a/.github/workflows/release-rc.yml +++ b/.github/workflows/release-rc.yml @@ -53,7 +53,7 @@ jobs: || (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@7d73ac90b6b5a6acb61b4d3fa56625c5b6dcc652 + uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44 with: dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} diff --git a/.qqq-ci.yml b/.qqq-ci.yml index a8863c3..be6666b 100644 --- a/.qqq-ci.yml +++ b/.qqq-ci.yml @@ -1,5 +1,5 @@ profile: qbit -orb_version: 0.7.11 +orb_version: 0.7.12 default_branch: develop java_version: '21' browsers: true -- 2.54.0 From a01ad9487fd72083e3ec0ffaa32104ce6222aa8d Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 10:41:48 -0500 Subject: [PATCH 05/16] ci: centralize QBit source scanning (#32) Co-authored-by: Codex --- .github/workflows/codeql.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..bd3ceaf --- /dev/null +++ b/.github/workflows/codeql.yml @@ -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 -- 2.54.0 From 1bb2371d753cae20b003059697dd48cc78150a5b Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 14:15:49 -0500 Subject: [PATCH 06/16] ci: adopt shared orb 0.7.17 (#35) Co-authored-by: Codex --- .circleci/config.yml | 4 ++-- .qqq-ci.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c9cb461..1d4fa0d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,12 +1,12 @@ version: 2.1 setup: true orbs: - qqq-orb: kingsrook/qqq-orb@0.7.12 + qqq-orb: kingsrook/qqq-orb@0.7.17 workflows: setup: jobs: - qqq-orb/generate_pipeline: - orb_version: 0.7.12 + orb_version: 0.7.17 filters: branches: ignore: automation/qbit-rc-pending diff --git a/.qqq-ci.yml b/.qqq-ci.yml index be6666b..a9e9f43 100644 --- a/.qqq-ci.yml +++ b/.qqq-ci.yml @@ -1,5 +1,5 @@ profile: qbit -orb_version: 0.7.12 +orb_version: 0.7.17 default_branch: develop java_version: '21' browsers: true -- 2.54.0 From 4761a0242ba6ff27855380a935cb4691ff8c563f Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 14:46:20 -0500 Subject: [PATCH 07/16] ci: adopt shared orb 0.7.18 (#36) Co-authored-by: Codex --- .circleci/config.yml | 4 ++-- .qqq-ci.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1d4fa0d..42b73df 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,12 +1,12 @@ version: 2.1 setup: true orbs: - qqq-orb: kingsrook/qqq-orb@0.7.17 + qqq-orb: kingsrook/qqq-orb@0.7.18 workflows: setup: jobs: - qqq-orb/generate_pipeline: - orb_version: 0.7.17 + orb_version: 0.7.18 filters: branches: ignore: automation/qbit-rc-pending diff --git a/.qqq-ci.yml b/.qqq-ci.yml index a9e9f43..967a18e 100644 --- a/.qqq-ci.yml +++ b/.qqq-ci.yml @@ -1,5 +1,5 @@ profile: qbit -orb_version: 0.7.17 +orb_version: 0.7.18 default_branch: develop java_version: '21' browsers: true -- 2.54.0 From 4849764e2d39c0c060b163b5aa122690683ef671 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 21 Oct 2026 15:35:08 -0500 Subject: [PATCH 08/16] ci: adopt managed orb 0.8 patch track (#37) Co-authored-by: Codex --- .circleci/config.yml | 4 ++-- .qqq-ci.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 42b73df..f890c28 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,12 +1,12 @@ version: 2.1 setup: true orbs: - qqq-orb: kingsrook/qqq-orb@0.7.18 + qqq-orb: kingsrook/qqq-orb@0.8 workflows: setup: jobs: - qqq-orb/generate_pipeline: - orb_version: 0.7.18 + orb_version: '0.8' filters: branches: ignore: automation/qbit-rc-pending diff --git a/.qqq-ci.yml b/.qqq-ci.yml index 967a18e..6777ede 100644 --- a/.qqq-ci.yml +++ b/.qqq-ci.yml @@ -1,5 +1,5 @@ profile: qbit -orb_version: 0.7.18 +orb_version: '0.8' default_branch: develop java_version: '21' browsers: true -- 2.54.0 From fd061f6e8c698972bbfd5e697f244572a571fad7 Mon Sep 17 00:00:00 2001 From: Forge Recovery Date: Mon, 2 Nov 2026 04:12:59 +0000 Subject: [PATCH 09/16] ci: add the identical QRun release caller Co-Authored-By: Codex --- .gitea/workflows/qrun-release.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .gitea/workflows/qrun-release.yaml diff --git a/.gitea/workflows/qrun-release.yaml b/.gitea/workflows/qrun-release.yaml new file mode 100644 index 0000000..122f7e9 --- /dev/null +++ b/.gitea/workflows/qrun-release.yaml @@ -0,0 +1,12 @@ +# Identical in every QRun Maven application: do not edit. It only delegates to the protected qrun-release-v1 channel. +name: qrun-release +on: + push: + branches: [main, develop, 'release/**', 'hotfix/**'] +permissions: + contents: read +jobs: + release: + uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-maven.yaml@qrun-release-v1 + with: + runner: linux-small -- 2.54.0 From 5eabd24f9b46648e9a9cb45ad86bd0790f0f6adb Mon Sep 17 00:00:00 2001 From: Forge Recovery Date: Mon, 2 Nov 2026 06:18:52 +0000 Subject: [PATCH 10/16] ci: move the QRun release caller to the thin v2 contract Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Codex --- .gitea/workflows/qrun-release.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/qrun-release.yaml b/.gitea/workflows/qrun-release.yaml index 122f7e9..711441c 100644 --- a/.gitea/workflows/qrun-release.yaml +++ b/.gitea/workflows/qrun-release.yaml @@ -1,12 +1,15 @@ -# Identical in every QRun Maven application: do not edit. It only delegates to the protected qrun-release-v1 channel. +# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository. name: qrun-release on: - push: - branches: [main, develop, 'release/**', 'hotfix/**'] + push: {} + workflow_dispatch: {} permissions: contents: read + actions: read jobs: release: uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-maven.yaml@qrun-release-v1 with: runner: linux-small + browsers: true + browser-runner: browser-build -- 2.54.0 From 9b01e6087c8c330b94f8ec96f2aa5e65da4c0625 Mon Sep 17 00:00:00 2001 From: Forge Recovery Date: Mon, 2 Nov 2026 15:50:17 +0000 Subject: [PATCH 11/16] ci: add the isolated postpublish job to the QRun release caller Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Codex --- .gitea/workflows/qrun-release.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitea/workflows/qrun-release.yaml b/.gitea/workflows/qrun-release.yaml index 711441c..cb745b2 100644 --- a/.gitea/workflows/qrun-release.yaml +++ b/.gitea/workflows/qrun-release.yaml @@ -13,3 +13,12 @@ jobs: runner: linux-small browsers: true browser-runner: browser-build + postpublish: + if: ${{ needs.release.outputs.capture == 'true' }} + needs: release + permissions: + contents: write + actions: write + uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1 + with: + runner: linux-small -- 2.54.0 From 4c79c330b4a200abaa78bc7ed45a9441dd318ad7 Mon Sep 17 00:00:00 2001 From: Forge Recovery Date: Mon, 2 Nov 2026 20:17:48 +0000 Subject: [PATCH 12/16] ci: enroll shared Gitea release-candidate lifecycle Co-Authored-By: Codex --- .gitea/workflows/release-rc.yaml | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .gitea/workflows/release-rc.yaml diff --git a/.gitea/workflows/release-rc.yaml b/.gitea/workflows/release-rc.yaml new file mode 100644 index 0000000..12ec9fc --- /dev/null +++ b/.gitea/workflows/release-rc.yaml @@ -0,0 +1,72 @@ +name: Release candidate +'on': + workflow_dispatch: + inputs: + dry_run: + 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 release run id (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 +permissions: + contents: read +jobs: + release-rc: + # The old qbit-rc-published repository_dispatch is replaced by the already-proven workflow_dispatch + # completion: the publisher's own ActionsWRITE job dispatches this workflow with finalize=true plus + # merge_sha/bump_sha/branch/version (same inputs as the manual finalize retry). + permissions: + contents: write + actions: read + pull-requests: write + if: github.event_name == 'workflow_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: self:KofTwentyTwo/platform-ci/.gitea/workflows/qbit-release-rc.yaml@qrun-release-v1 + with: + dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false + }} + finalize: ${{ 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: ${{ inputs.merge_sha || '' }} + bump_sha: ${{ inputs.bump_sha || '' }} + branch: ${{ inputs.branch || '' }} + version: ${{ inputs.version || '' }} + secrets: + QRUN_TAG_SIGNING_KEY: ${{ secrets.QRUN_TAG_SIGNING_KEY }} -- 2.54.0 From 9e465d7b7c44f32943ba44f9df4a6961159feb66 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 5 Nov 2026 22:06:02 +0000 Subject: [PATCH 13/16] ci: use shared ci-workflows Maven workflow 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 --- .gitea/workflows/build.yaml | 7 +++ .gitea/workflows/qrun-release.yaml | 24 ---------- .gitea/workflows/release-rc.yaml | 72 ------------------------------ 3 files changed, 7 insertions(+), 96 deletions(-) create mode 100644 .gitea/workflows/build.yaml delete mode 100644 .gitea/workflows/qrun-release.yaml delete mode 100644 .gitea/workflows/release-rc.yaml diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..ed42608 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,7 @@ +name: build +on: + push: +jobs: + maven: + uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@main + secrets: inherit diff --git a/.gitea/workflows/qrun-release.yaml b/.gitea/workflows/qrun-release.yaml deleted file mode 100644 index cb745b2..0000000 --- a/.gitea/workflows/qrun-release.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository. -name: qrun-release -on: - push: {} - workflow_dispatch: {} -permissions: - contents: read - actions: read -jobs: - release: - uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-maven.yaml@qrun-release-v1 - with: - runner: linux-small - browsers: true - browser-runner: browser-build - postpublish: - if: ${{ needs.release.outputs.capture == 'true' }} - needs: release - permissions: - contents: write - actions: write - uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1 - with: - runner: linux-small diff --git a/.gitea/workflows/release-rc.yaml b/.gitea/workflows/release-rc.yaml deleted file mode 100644 index 12ec9fc..0000000 --- a/.gitea/workflows/release-rc.yaml +++ /dev/null @@ -1,72 +0,0 @@ -name: Release candidate -'on': - workflow_dispatch: - inputs: - dry_run: - 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 release run id (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 -permissions: - contents: read -jobs: - release-rc: - # The old qbit-rc-published repository_dispatch is replaced by the already-proven workflow_dispatch - # completion: the publisher's own ActionsWRITE job dispatches this workflow with finalize=true plus - # merge_sha/bump_sha/branch/version (same inputs as the manual finalize retry). - permissions: - contents: write - actions: read - pull-requests: write - if: github.event_name == 'workflow_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: self:KofTwentyTwo/platform-ci/.gitea/workflows/qbit-release-rc.yaml@qrun-release-v1 - with: - dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false - }} - finalize: ${{ 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: ${{ inputs.merge_sha || '' }} - bump_sha: ${{ inputs.bump_sha || '' }} - branch: ${{ inputs.branch || '' }} - version: ${{ inputs.version || '' }} - secrets: - QRUN_TAG_SIGNING_KEY: ${{ secrets.QRUN_TAG_SIGNING_KEY }} -- 2.54.0 From 5d687b37a7ec616fd6fdd6a0aeb6f95c396e7b28 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 6 Nov 2026 14:10:55 +0000 Subject: [PATCH 14/16] ci: pin shared workflow to v1 Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index ed42608..ce00351 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -3,5 +3,5 @@ on: push: jobs: maven: - uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@main + uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1 secrets: inherit -- 2.54.0 From ec6e382b69bc5443fb4cbb4d04500b662a55b384 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 7 Nov 2026 17:57:07 +0000 Subject: [PATCH 15/16] build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26) 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 --- pom.xml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 740b26a..d682cfa 100644 --- a/pom.xml +++ b/pom.xml @@ -18,22 +18,23 @@ + 4.1.0-RC.26 0.1.0-SNAPSHOT 21 21 0.8.15 - 0.1.0-RC.12 + 0.1.0-RC.25 true true - 0.1.0-RC.3 + 0.1.0-RC.11 2.3.232 com.kingsrook qbit-build-parent - 2.1.2 + 2.2.1 com.kingsrook.qbits @@ -208,7 +209,7 @@ true - 4.1.0-RC.6 + 4.1.0-RC.26 -- 2.54.0 From 39c86cdcae6b3cae96c81e776c05ff1358d39839 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 7 Nov 2026 13:45:24 -0600 Subject: [PATCH 16/16] refactor(help): register the help bundle with core HelpBundles 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 --- CHANGELOG.md | 2 +- README.md | 2 +- .../marketing/MarketingQBitProducer.java | 9 +- .../marketing/metadata/MarketingHelp.java | 189 -------- .../marketing/HelpBundleCoverageTest.java | 412 +++--------------- .../MarketingTablesGuardrailTest.java | 40 +- .../resources/help/test-host-override.yaml | 6 + 7 files changed, 97 insertions(+), 563 deletions(-) delete mode 100644 src/main/java/com/kingsrook/qbits/marketing/metadata/MarketingHelp.java create mode 100644 src/test/resources/help/test-host-override.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c146e..eaa7ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ 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 diff --git a/README.md b/README.md index b15878c..ef6f8e4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/main/java/com/kingsrook/qbits/marketing/MarketingQBitProducer.java b/src/main/java/com/kingsrook/qbits/marketing/MarketingQBitProducer.java index 4f4b1cd..6b6af04 100644 --- a/src/main/java/com/kingsrook/qbits/marketing/MarketingQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/marketing/MarketingQBitProducer.java @@ -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 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 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 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); - } - } - -} diff --git a/src/test/java/com/kingsrook/qbits/marketing/HelpBundleCoverageTest.java b/src/test/java/com/kingsrook/qbits/marketing/HelpBundleCoverageTest.java index 0a66b13..7eccbf7 100644 --- a/src/test/java/com/kingsrook/qbits/marketing/HelpBundleCoverageTest.java +++ b/src/test/java/com/kingsrook/qbits/marketing/HelpBundleCoverageTest.java @@ -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 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 keys = new LinkedHashSet<>(); - List 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 problems = new ArrayList<>(); - for(Entry entry : loadEntries()) + Map entries = HelpBundles.of(qInstance).resolveWinningEntries(); + List problems = new ArrayList<>(); + TreeSet 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 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 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 expectedKeys() - { - Set 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 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 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 loadBundle() throws IOException - { - try(InputStream inputStream = HelpBundleCoverageTest.class.getClassLoader().getResourceAsStream(BUNDLE_RESOURCE)) - { - assertThat(inputStream).as("classpath resource " + BUNDLE_RESOURCE).isNotNull(); - @SuppressWarnings("unchecked") - Map bundle = new ObjectMapper(new YAMLFactory()).readValue(inputStream, Map.class); - return (bundle); - } - } - - - - /******************************************************************************* - ** The bundle's entries. - *******************************************************************************/ - @SuppressWarnings("unchecked") - private static List loadEntries() throws IOException - { - List entries = new ArrayList<>(); - for(Map row : (List>) 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 problems) - { assertThat(problems).as(problems.size() + " help bundle problem(s):\n" + String.join("\n", problems)).isEmpty(); } diff --git a/src/test/java/com/kingsrook/qbits/marketing/MarketingTablesGuardrailTest.java b/src/test/java/com/kingsrook/qbits/marketing/MarketingTablesGuardrailTest.java index 8850c16..5ef7320 100644 --- a/src/test/java/com/kingsrook/qbits/marketing/MarketingTablesGuardrailTest.java +++ b/src/test/java/com/kingsrook/qbits/marketing/MarketingTablesGuardrailTest.java @@ -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 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 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()); } + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/test/resources/help/test-host-override.yaml b/src/test/resources/help/test-host-override.yaml new file mode 100644 index 0000000..0f90afc --- /dev/null +++ b/src/test/resources/help/test-host-override.yaml @@ -0,0 +1,6 @@ +bundle: test-host-override +version: "1" +layer: HOST +entries: + - key: table:marketing_claim;slot:help + content: host text -- 2.54.0