From d7ccc0f3a69680fa7d5bb9082e4b5edfa0936085 Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Fri, 9 Oct 2026 18:15:07 +0200 Subject: [PATCH] :sparkles: Add get-file-plugin-data RPC to read file plugin data (#10171) The Plugin API can write arbitrary key/value plugin data onto the file root, but it could only be read back by downloading and decoding the whole file. Add a `get-file-plugin-data` read command that returns the file-level plugin data map, optionally narrowed to a single namespace, so external tooling can fetch metadata such as issue or commit references cheaply. Read-only: reuses `bfc/get-file` and `check-read-permissions!`, and adds no data-model or change-type changes. Object/page level reads are intentionally left out of this first command. Relates to #10169 Signed-off-by: Filip Sajdak Co-authored-by: Claude Opus 4.8 Co-authored-by: Andrey Antukh --- backend/src/app/rpc/commands/files.clj | 26 ++++++++ backend/test/backend_tests/rpc_file_test.clj | 62 ++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 372f97ab59..67153861a3 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -22,6 +22,7 @@ [app.common.transit :as t] [app.common.types.components-list :as ctkl] [app.common.types.file :as ctf] + [app.common.types.plugins :as ctpg] [app.common.types.tokens-lib :as ctob] [app.common.uri :as uri] [app.config :as cf] @@ -721,6 +722,31 @@ (get-file-stats cfg id)) +;; --- COMMAND QUERY: get-file-plugin-data + +(def ^:private schema:get-file-plugin-data + [:map {:title "get-file-plugin-data"} + [:id ::sm/uuid] + [:namespace {:optional true} :keyword]]) + +(sv/defmethod ::get-file-plugin-data + "Return the file-level plugin data: the arbitrary key/value metadata + stored on the file root through the Plugin API. Optionally narrowed + to a single `namespace`. Lets external tooling read metadata such as + issue or commit references without downloading the whole file." + {::doc/added "2.20" + ::sm/params schema:get-file-plugin-data + ::sm/result ctpg/schema:plugin-data + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id namespace]}] + (check-read-permissions! conn profile-id id) + (let [plugin-data (-> (bfc/get-file cfg id) + (get-in [:data :plugin-data]))] + (if namespace + (select-keys plugin-data [namespace]) + (or plugin-data {})))) + + ;; --- COMMAND QUERY: get-file-libraries (def ^:private schema:get-file-libraries diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index d976ed92de..8feb0516fb 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2867,3 +2867,65 @@ (t/is (th/ex-info? (:error out))) (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :params-validation)))) +(t/deftest get-file-plugin-data + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; A file without plugin data returns an empty map + (let [out (th/command! {::th/type :get-file-plugin-data + ::rpc/profile-id (:id profile) + :id (:id file)})] + (t/is (nil? (:error out))) + (t/is (= {} (:result out)))) + + ;; Set file-level plugin data through a change + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :vern 0 + :changes + [{:type :set-plugin-data + :object-type :file + :namespace :shared/traceability + :key "req-id" + :value "REQ-123"}]) + + ;; The full plugin-data map is returned + (let [out (th/command! {::th/type :get-file-plugin-data + ::rpc/profile-id (:id profile) + :id (:id file)})] + (t/is (nil? (:error out))) + (t/is (= {:shared/traceability {"req-id" "REQ-123"}} (:result out)))) + + ;; A namespace filter narrows the result + (let [out (th/command! {::th/type :get-file-plugin-data + ::rpc/profile-id (:id profile) + :id (:id file) + :namespace :shared/traceability})] + (t/is (nil? (:error out))) + (t/is (= {:shared/traceability {"req-id" "REQ-123"}} (:result out)))) + + ;; A filter on an absent namespace returns an empty map + (let [out (th/command! {::th/type :get-file-plugin-data + ::rpc/profile-id (:id profile) + :id (:id file) + :namespace :plugin/absent})] + (t/is (nil? (:error out))) + (t/is (= {} (:result out)))))) + +(t/deftest get-file-plugin-data-forbidden + (let [owner (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:default-project-id owner) + :is-shared false}) + out (th/command! {::th/type :get-file-plugin-data + ::rpc/profile-id (:id other) + :id (:id file)})] + + (t/is (not (nil? (:error out)))) + (let [edata (-> out :error ex-data)] + (t/is (= :not-found (:type edata))))))