From 386148536d72b212d629de69242b057b77fae059 Mon Sep 17 00:00:00 2001 From: gibson9583 Date: Tue, 17 Feb 2026 09:55:00 -0500 Subject: [PATCH 1/3] Add deploy changed/new flags Signed-off-by: gibson9583 --- src/mirthsync/apis.clj | 63 ++++++++++++++++- src/mirthsync/cli.clj | 10 +++ src/mirthsync/core.clj | 18 ++++- src/mirthsync/http_client.clj | 8 +++ test/mirthsync/apis_test.clj | 129 ++++++++++++++++++++++++++++++++++ test/mirthsync/cli_test.clj | 27 +++++++ 6 files changed, 251 insertions(+), 4 deletions(-) diff --git a/src/mirthsync/apis.clj b/src/mirthsync/apis.clj index c9668b8..461bfa6 100644 --- a/src/mirthsync/apis.clj +++ b/src/mirthsync/apis.clj @@ -236,6 +236,10 @@ (let [channel-id (mi/find-id api (:el-loc app-conf))] (log/infof "Collecting channel ID for bulk deployment: %s" channel-id) (swap! (:bulk-deploy-channels app-conf) conj channel-id))) + ;; Track pushed channel IDs for --deploy-new + (when (:pushed-channel-ids app-conf) + (let [channel-id (mi/find-id api (:el-loc app-conf))] + (swap! (:pushed-channel-ids app-conf) conj channel-id))) true) (do (log/error (str "Unable to save the channel." (when-not (app-conf :force) " There may be remote changes or the remote version does not match the local version. If you want to push the local changes anyway you can use the \"-f\" flag to force an overwrite."))) @@ -252,9 +256,7 @@ (when (seq channel-ids) (log/infof "Deploying %d channels in bulk: %s" (count channel-ids) (pr-str channel-ids)) (try+ - (let [channel-set (apply str "" - (map #(str "" % "") channel-ids) - "")] + (let [channel-set (str "" (apply str (map #(str "" % "") channel-ids)) "")] (mhttp/post-xml app-conf "/channels/_deploy" @@ -266,6 +268,61 @@ (log/error (str "Error during bulk channel deployment: " body)) false))))) +(defn deploy-changed-channels + "Query the server for channel deployment statuses and deploy only channels + that have a non-zero deployedRevisionDelta or, when code templates were + pushed, where codeTemplatesChanged is true. When --deploy-new is active, + also deploy any pushed channels not found in the dashboard statuses." + [{:keys [code-templates-pushed pushed-channel-ids] :as app-conf}] + (log/info "Checking for channels with pending deployment changes...") + (try+ + (let [body (mhttp/get-xml app-conf "/channels/statuses") + statuses-zip (mx/to-zip body) + dashboard-statuses (cdzx/xml-> statuses-zip :dashboardStatus) + deployed-ids (set (map #(cdzx/xml1-> % :channelId cdzx/text) dashboard-statuses)) + ;; Find changed channels from dashboard statuses + changed-channels + (reduce + (fn [acc status-loc] + (let [channel-id (cdzx/xml1-> status-loc :channelId cdzx/text) + channel-name (cdzx/xml1-> status-loc :name cdzx/text) + delta-text (cdzx/xml1-> status-loc :deployedRevisionDelta cdzx/text) + delta (when delta-text + (try (Integer/parseInt delta-text) + (catch NumberFormatException _ nil))) + code-templates-changed (= "true" (cdzx/xml1-> status-loc :codeTemplatesChanged cdzx/text)) + revision-changed (and delta (not= 0 delta)) + needs-deploy (or revision-changed + (and code-templates-pushed code-templates-changed))] + (log/debugf "Channel '%s' (%s): revisionDelta=%s, codeTemplatesChanged=%s" + channel-name channel-id (or delta-text "nil") code-templates-changed) + (when needs-deploy + (log/infof "Collecting channel for deployment: %s (%s)" channel-name channel-id)) + (if needs-deploy + (conj acc channel-id) + acc))) + [] + dashboard-statuses) + ;; Find undeployed channels (pushed but not in dashboard) + new-channels (when pushed-channel-ids + (let [pushed-ids @pushed-channel-ids + undeployed (remove deployed-ids pushed-ids)] + (when (seq undeployed) + (doseq [id undeployed] + (log/infof "Collecting undeployed channel for deployment: %s" id)) + undeployed))) + all-channels (distinct (concat changed-channels new-channels))] + (if (seq all-channels) + (do (log/infof "Deploying %d channel(s): %s" (count all-channels) (pr-str all-channels)) + (let [channel-set (str "" (apply str (map #(str "" % "") all-channels)) "")] + (mhttp/post-xml app-conf "/channels/_deploy" channel-set + {:returnErrors "true" :debug "false"} false) + (log/info "Deploy-changed completed successfully"))) + (log/info "No channels have pending deployment changes"))) + (catch Object {:keys [body]} + (log/error (str "Error during deploy-changed: " body)) + false))) + (defmethod mi/pre-node-action :default [_ app-conf] app-conf) (defmethod mi/pre-node-action :code-template-libraries [_ app-conf] (pre-node-action :server-codelibs app-conf)) diff --git a/src/mirthsync/cli.clj b/src/mirthsync/cli.clj index b2cdc0f..49a150a 100644 --- a/src/mirthsync/cli.clj +++ b/src/mirthsync/cli.clj @@ -86,6 +86,16 @@ During a push, collect all channel IDs and deploy them together at the end, allowing Mirth's dependency logic to control order."] + [nil "--deploy-changed" "Deploy only changed channels after push + After all channels are saved, query the server for channel statuses + and deploy only those with a non-zero deployedRevisionDelta or + where codeTemplatesChanged is true."] + + [nil "--deploy-new" "Deploy channels that are not currently deployed + Use with --deploy-changed. During push, tracks which channels were + saved. After push, any saved channel not found in the dashboard + statuses (i.e. not currently deployed) will also be deployed."] + ["-I" "--interactive" " Allow for console prompts for user input"] diff --git a/src/mirthsync/core.clj b/src/mirthsync/core.clj index 913dc7d..b8c5c8a 100644 --- a/src/mirthsync/core.clj +++ b/src/mirthsync/core.clj @@ -34,6 +34,11 @@ app-conf (http/with-authentication app-conf (fn [] + (when (and (= "push" action) + (> (count (filter identity [(:deploy app-conf) (:deploy-all app-conf) (:deploy-changed app-conf)])) 1)) + (log/warn "Multiple deploy flags specified. This may cause redundant deployment operations.")) + (when (and (= "push" action) (:deploy-new app-conf) (not (:deploy-changed app-conf))) + (log/warn "--deploy-new has no effect without --deploy-changed")) (let [preprocessed-conf (api/iterate-apis app-conf (api/apis app-conf) api/preprocess-api) ;; For pull operations, always capture local files before pull for orphan detection conf-with-pre-pull (if (= "pull" action) @@ -43,10 +48,21 @@ conf-with-bulk-deploy (if (and (= "push" action) (:deploy-all conf-with-pre-pull)) (assoc conf-with-pre-pull :bulk-deploy-channels (atom [])) conf-with-pre-pull) - processed-conf (api/iterate-apis conf-with-bulk-deploy (api/apis conf-with-bulk-deploy) action-fn)] + ;; Initialize pushed-channel-ids atom for --deploy-new tracking (requires --deploy-changed) + conf-with-tracking (if (and (= "push" action) (:deploy-new conf-with-bulk-deploy) (:deploy-changed conf-with-bulk-deploy)) + (assoc conf-with-bulk-deploy :pushed-channel-ids (atom [])) + conf-with-bulk-deploy) + processed-conf (api/iterate-apis conf-with-tracking (api/apis conf-with-tracking) action-fn)] ;; After push with --deploy-all, deploy all channels (when (and (= "push" action) (:deploy-all processed-conf)) (api/deploy-all-channels processed-conf)) + ;; After push with --deploy-changed, deploy only channels with revision delta + (when (and (= "push" action) (:deploy-changed processed-conf)) + (let [pushed-apis (set (api/apis processed-conf)) + code-templates-pushed (or (contains? pushed-apis :code-template-libraries) + (contains? pushed-apis :code-templates))] + (api/deploy-changed-channels + (assoc processed-conf :code-templates-pushed code-templates-pushed)))) ;; After pull, always check for orphaned files (if (= "pull" action) (act/cleanup-orphaned-files-with-pre-pull processed-conf (api/apis processed-conf)) diff --git a/src/mirthsync/http_client.clj b/src/mirthsync/http_client.clj index f3faed6..3282e6d 100644 --- a/src/mirthsync/http_client.clj +++ b/src/mirthsync/http_client.clj @@ -101,3 +101,11 @@ :body mxml/to-zip find-elements)) + +(defn get-xml + "Authenticated GET to the given path, returns the response body string." + [{:keys [server ignore-cert-warnings]} path] + (-> (client/get (str server path) + {:headers (build-headers) + :insecure? ignore-cert-warnings}) + :body)) diff --git a/test/mirthsync/apis_test.clj b/test/mirthsync/apis_test.clj index 818710a..abb8f88 100644 --- a/test/mirthsync/apis_test.clj +++ b/test/mirthsync/apis_test.clj @@ -267,6 +267,135 @@ (ct/is (= false (mirthsync.interfaces/after-push api (assoc app-conf :el-loc el-loc) result))) (ct/is (empty? @(:bulk-deploy-channels app-conf)))))) +(defn- find-changed-channel-ids + "Helper to extract channel IDs that need deployment from dashboard statuses. + A channel needs deployment if deployedRevisionDelta is non-zero or codeTemplatesChanged is true." + [dashboard-statuses] + (reduce + (fn [acc status-loc] + (let [channel-id (cdzx/xml1-> status-loc :channelId cdzx/text) + delta-text (cdzx/xml1-> status-loc :deployedRevisionDelta cdzx/text) + delta (when delta-text + (try (Integer/parseInt delta-text) + (catch NumberFormatException _ nil))) + code-templates-changed (= "true" (cdzx/xml1-> status-loc :codeTemplatesChanged cdzx/text)) + needs-deploy (or (and delta (not= 0 delta)) + code-templates-changed)] + (if needs-deploy + (conj acc channel-id) + acc))) + [] + dashboard-statuses)) + +(ct/deftest deploy-changed-channels-tests + (ct/testing "Channels with non-zero revision delta are selected" + (let [sample-xml " + + 1aa2c102-3167-4122-9467-dd989ce3d189 + Channel A + 1 + false + + + b7e8f4a1-52d3-4c9e-a1b6-7f3e2d1c0a98 + Channel B + 0 + false + + + c3d4e5f6-7890-4abc-def1-234567890abc + Channel C + 3 + false + + " + statuses-zip (mx/to-zip sample-xml) + changed-ids (find-changed-channel-ids (cdzx/xml-> statuses-zip :dashboardStatus))] + (ct/is (= ["1aa2c102-3167-4122-9467-dd989ce3d189" "c3d4e5f6-7890-4abc-def1-234567890abc"] changed-ids)) + (ct/is (not (some #{"b7e8f4a1-52d3-4c9e-a1b6-7f3e2d1c0a98"} changed-ids))))) + + (ct/testing "Channels with codeTemplatesChanged=true are selected" + (let [sample-xml " + + 1aa2c102-3167-4122-9467-dd989ce3d189 + Channel A + 0 + true + + + b7e8f4a1-52d3-4c9e-a1b6-7f3e2d1c0a98 + Channel B + 0 + false + + " + statuses-zip (mx/to-zip sample-xml) + changed-ids (find-changed-channel-ids (cdzx/xml-> statuses-zip :dashboardStatus))] + (ct/is (= ["1aa2c102-3167-4122-9467-dd989ce3d189"] changed-ids)))) + + (ct/testing "Both delta and codeTemplatesChanged trigger deploy" + (let [sample-xml " + + 1aa2c102-3167-4122-9467-dd989ce3d189 + Channel A + 2 + true + + " + statuses-zip (mx/to-zip sample-xml) + changed-ids (find-changed-channel-ids (cdzx/xml-> statuses-zip :dashboardStatus))] + (ct/is (= ["1aa2c102-3167-4122-9467-dd989ce3d189"] changed-ids)))) + + (ct/testing "All channels up to date results in empty list" + (let [sample-xml " + + 1aa2c102-3167-4122-9467-dd989ce3d189 + Channel A + 0 + false + + " + statuses-zip (mx/to-zip sample-xml) + changed-ids (find-changed-channel-ids (cdzx/xml-> statuses-zip :dashboardStatus))] + (ct/is (empty? changed-ids)))) + + (ct/testing "Empty dashboard status list" + (let [sample-xml "" + statuses-zip (mx/to-zip sample-xml) + dashboard-statuses (cdzx/xml-> statuses-zip :dashboardStatus)] + (ct/is (empty? dashboard-statuses))))) + +(ct/deftest deploy-new-tracking-tests + (ct/testing "Channel after-push tracks pushed IDs when pushed-channel-ids atom is present" + (let [app-conf {:pushed-channel-ids (atom [])} + api :channels + el-loc (mx/to-zip "d4e5f6a7-8901-4bcd-ef23-456789abcdefNew Channel") + result {:status 200 :body "true"}] + (with-redefs [mirthsync.interfaces/find-id (fn [_ _] "d4e5f6a7-8901-4bcd-ef23-456789abcdef")] + (mirthsync.interfaces/after-push api (assoc app-conf :el-loc el-loc) result) + (ct/is (= ["d4e5f6a7-8901-4bcd-ef23-456789abcdef"] @(:pushed-channel-ids app-conf)))))) + + (ct/testing "Channel after-push does not track when pushed-channel-ids atom is absent" + (let [app-conf {} + api :channels + el-loc (mx/to-zip "d4e5f6a7-8901-4bcd-ef23-456789abcdefChannel") + result {:status 200 :body "true"}] + (with-redefs [mirthsync.interfaces/find-id (fn [_ _] "d4e5f6a7-8901-4bcd-ef23-456789abcdef")] + (mirthsync.interfaces/after-push api (assoc app-conf :el-loc el-loc) result) + (ct/is (nil? (:pushed-channel-ids app-conf)))))) + + (ct/testing "Undeployed pushed channels are identified correctly" + (let [pushed-ids (atom ["1aa2c102-3167-4122-9467-dd989ce3d189" "e5f6a7b8-9012-4cde-f345-6789abcdef01"]) + deployed-ids #{"1aa2c102-3167-4122-9467-dd989ce3d189" "b7e8f4a1-52d3-4c9e-a1b6-7f3e2d1c0a98"} + undeployed (remove deployed-ids @pushed-ids)] + (ct/is (= ["e5f6a7b8-9012-4cde-f345-6789abcdef01"] (vec undeployed))))) + + (ct/testing "All pushed channels already deployed results in no new channels" + (let [pushed-ids (atom ["1aa2c102-3167-4122-9467-dd989ce3d189" "b7e8f4a1-52d3-4c9e-a1b6-7f3e2d1c0a98"]) + deployed-ids #{"1aa2c102-3167-4122-9467-dd989ce3d189" "b7e8f4a1-52d3-4c9e-a1b6-7f3e2d1c0a98" "c3d4e5f6-7890-4abc-def1-234567890abc"} + undeployed (remove deployed-ids @pushed-ids)] + (ct/is (empty? undeployed))))) + (comment (ct/deftest iterate-apis (ct/is (= "target/foo/blah.xm" (local-path-str "foo/blah.xml" "target"))))) diff --git a/test/mirthsync/cli_test.clj b/test/mirthsync/cli_test.clj index 308fe24..34c73f8 100644 --- a/test/mirthsync/cli_test.clj +++ b/test/mirthsync/cli_test.clj @@ -101,6 +101,33 @@ (is (and (:deploy conf-with-deploy) (nil? (:deploy-all conf-with-deploy)))) (is (and (:deploy-all conf-with-deploy-all) (nil? (:deploy conf-with-deploy-all)))))) + (testing "Deploy-changed flag is parsed correctly" + (let [conf (config ["-s" "https://localhost:8443/api" "-u" "admin" "-p" "password" "-t" "foo" "--deploy-changed" "push"])] + (is (= true (:deploy-changed conf))))) + + (testing "Deploy-changed defaults to nil" + (let [conf (config ["-s" "https://localhost:8443/api" "-u" "admin" "-p" "password" "-t" "foo" "push"])] + (is (nil? (:deploy-changed conf))))) + + (testing "Deploy-changed is independent from other deploy flags" + (let [conf (config ["-s" "https://localhost:8443/api" "-u" "admin" "-p" "password" "-t" "foo" "--deploy-changed" "push"])] + (is (= true (:deploy-changed conf))) + (is (nil? (:deploy conf))) + (is (nil? (:deploy-all conf))))) + + (testing "Deploy-new flag is parsed correctly" + (let [conf (config ["-s" "https://localhost:8443/api" "-u" "admin" "-p" "password" "-t" "foo" "--deploy-new" "push"])] + (is (= true (:deploy-new conf))))) + + (testing "Deploy-new defaults to nil" + (let [conf (config ["-s" "https://localhost:8443/api" "-u" "admin" "-p" "password" "-t" "foo" "push"])] + (is (nil? (:deploy-new conf))))) + + (testing "Deploy-new can be combined with deploy-changed" + (let [conf (config ["-s" "https://localhost:8443/api" "-u" "admin" "-p" "password" "-t" "foo" "--deploy-changed" "--deploy-new" "push"])] + (is (= true (:deploy-changed conf))) + (is (= true (:deploy-new conf))))) + (testing "Token authentication is accepted" (let [conf (config ["-s" "https://localhost:8443/api" "--token" "test-token-123" "-t" "foo" "pull"])] (is (= 0 (:exit-code conf))) From e75da6d49002812b3e060345dd231c56ce70ce5e Mon Sep 17 00:00:00 2001 From: gibson9583 Date: Tue, 17 Feb 2026 10:19:05 -0500 Subject: [PATCH 2/3] Use consistent apply str format for channel-set XML construction Reverting back to original construction --- src/mirthsync/apis.clj | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/mirthsync/apis.clj b/src/mirthsync/apis.clj index 461bfa6..277b6f8 100644 --- a/src/mirthsync/apis.clj +++ b/src/mirthsync/apis.clj @@ -256,7 +256,9 @@ (when (seq channel-ids) (log/infof "Deploying %d channels in bulk: %s" (count channel-ids) (pr-str channel-ids)) (try+ - (let [channel-set (str "" (apply str (map #(str "" % "") channel-ids)) "")] + (let [channel-set (apply str "" + (map #(str "" % "") channel-ids) + "")] (mhttp/post-xml app-conf "/channels/_deploy" @@ -314,7 +316,9 @@ all-channels (distinct (concat changed-channels new-channels))] (if (seq all-channels) (do (log/infof "Deploying %d channel(s): %s" (count all-channels) (pr-str all-channels)) - (let [channel-set (str "" (apply str (map #(str "" % "") all-channels)) "")] + (let [channel-set (apply str "" + (map #(str "" % "") all-channels) + "")] (mhttp/post-xml app-conf "/channels/_deploy" channel-set {:returnErrors "true" :debug "false"} false) (log/info "Deploy-changed completed successfully"))) From 71f7c52a5f892fb374244fb658a42e7539aa02d1 Mon Sep 17 00:00:00 2001 From: gibson9583 Date: Tue, 17 Feb 2026 10:30:10 -0500 Subject: [PATCH 3/3] Update README.md --- README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b2def09..468ed96 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,15 @@ Options: in a single bulk operation after all channels are saved. Allows Mirth's dependency logic to control deployment order. More efficient than individual deployment for multiple channels. - -I, --interactive + --deploy-changed Deploy only changed channels after push + After all channels are saved, query the server for channel statuses + and deploy only those with a non-zero deployedRevisionDelta or + where codeTemplatesChanged is true. + --deploy-new Deploy channels that are not currently deployed + Use with --deploy-changed. During push, tracks which channels were + saved. After push, any saved channel not found in the dashboard + statuses (i.e. not currently deployed) will also be deployed. + -I, --interactive Allow for console prompts for user input --commit-message MESSAGE mirthsync commit Commit message for git operations --git-author NAME Git author name for commits @@ -396,6 +404,15 @@ $ java -jar mirthsync--standalone.jar -s https://localhost:8443/api -u $ java -jar mirthsync--standalone.jar -s https://localhost:8443/api -u admin -p admin --deploy-all push -t ./mirth-config -r "Channels/Production Group" ``` +**Selective deployment (deploy only changed channels):** +``` shell +# Deploy only channels that have pending changes (non-zero revision delta or code template changes) +$ java -jar mirthsync--standalone.jar -s https://localhost:8443/api -u admin -p admin --deploy-changed push -t ./mirth-config + +# Deploy changed channels AND any newly pushed channels that aren't currently deployed +$ java -jar mirthsync--standalone.jar -s https://localhost:8443/api -u admin -p admin --deploy-changed --deploy-new push -t ./mirth-config +``` + **Performance comparison:** - `--deploy`: Each channel is deployed immediately after being saved (N API calls for N channels) - `--deploy-all`: All channels are collected during push, then deployed in a single bulk operation (1 API call for all channels)