diff --git a/lib/atlassian/jira_connect/client.rb b/lib/atlassian/jira_connect/client.rb
index b8aa2cc8ea03c0c1ba08ec69913809cb7c4420e4..d38516e5288a905a1ec95cff187a3bd05ea47bea 100644
--- a/lib/atlassian/jira_connect/client.rb
+++ b/lib/atlassian/jira_connect/client.rb
@@ -14,14 +14,14 @@ def initialize(base_uri, shared_secret)
 
       def send_info(project:, update_sequence_id: nil, **args)
         common = { project: project, update_sequence_id: update_sequence_id }
-        dev_info = args.slice(:commits, :branches, :merge_requests)
+        dev_info = DevInfo.new(**common.merge(args.slice(:commits, :branches, :merge_requests)))
         build_info = args.slice(:pipelines)
         deploy_info = args.slice(:deployments)
         ff_info = args.slice(:feature_flags)
 
         responses = []
 
-        responses << store_dev_info(**common, **dev_info) if dev_info.present?
+        responses << store_dev_info(dev_info) if dev_info.present?
         responses << store_build_info(**common, **build_info) if build_info.present?
         responses << store_deploy_info(**common, **deploy_info) if deploy_info.present?
         responses << store_ff_info(**common, **ff_info) if ff_info.present?
@@ -93,17 +93,8 @@ def store_build_info(project:, pipelines:, update_sequence_id: nil)
         handle_response(r, 'builds') { |data| errors(data, 'rejectedBuilds') }
       end
 
-      def store_dev_info(project:, commits: nil, branches: nil, merge_requests: nil, update_sequence_id: nil)
-        repo = ::Atlassian::JiraConnect::Serializers::RepositoryEntity.represent(
-          project,
-          commits: commits,
-          branches: branches,
-          merge_requests: merge_requests,
-          user_notes_count: user_notes_count(merge_requests),
-          update_sequence_id: update_sequence_id
-        )
-
-        post('/rest/devinfo/0.10/bulk', { repositories: [repo] })
+      def store_dev_info(dev_info)
+        post(dev_info.url, dev_info.body)
       end
 
       def post(path, payload)
@@ -157,14 +148,6 @@ def errors(data, key)
         { 'errorMessages' => messages }
       end
 
-      def user_notes_count(merge_requests)
-        return unless merge_requests
-
-        Note.count_for_collection(merge_requests.map(&:id), 'MergeRequest').to_h do |count_group|
-          [count_group.noteable_id, count_group.count]
-        end
-      end
-
       def jwt_token(http_method, uri)
         claims = Atlassian::Jwt.build_claims(
           Atlassian::JiraConnect.app_key,
diff --git a/lib/atlassian/jira_connect/dev_info.rb b/lib/atlassian/jira_connect/dev_info.rb
new file mode 100644
index 0000000000000000000000000000000000000000..90ccc1939d259b76646be394f84e970738d672cb
--- /dev/null
+++ b/lib/atlassian/jira_connect/dev_info.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Atlassian
+  module JiraConnect
+    class DevInfo
+      URL = '/rest/devinfo/0.10/bulk'
+
+      def initialize(project:, commits: nil, branches: nil, merge_requests: nil, update_sequence_id: nil)
+        @project = project
+        @commits = commits
+        @branches = branches
+        @merge_requests = merge_requests
+        @update_sequence_id = update_sequence_id
+      end
+
+      def url
+        URL
+      end
+
+      def body
+        repo = ::Atlassian::JiraConnect::Serializers::RepositoryEntity.represent(
+          @project,
+          commits: @commits,
+          branches: @branches,
+          merge_requests: @merge_requests,
+          user_notes_count: user_notes_count,
+          update_sequence_id: @update_sequence_id
+        )
+
+        { repositories: [repo] }
+      end
+
+      def present?
+        [@commits, @branches, @merge_requests].any?(&:present?)
+      end
+
+      private
+
+      def user_notes_count
+        return unless @merge_requests
+
+        Note.count_for_collection(@merge_requests.map(&:id), 'MergeRequest').to_h do |count_group|
+          [count_group.noteable_id, count_group.count]
+        end
+      end
+    end
+  end
+end
diff --git a/spec/fixtures/api/schemas/jira_connect/dev_info.json b/spec/fixtures/api/schemas/jira_connect/dev_info.json
new file mode 100644
index 0000000000000000000000000000000000000000..98437353fde35b460feff5ac39e65179a86da298
--- /dev/null
+++ b/spec/fixtures/api/schemas/jira_connect/dev_info.json
@@ -0,0 +1,8 @@
+{
+  "repositories": {
+    "type": "array",
+    "items": {
+      "$ref": "./repository.json"
+    }
+  }
+}
diff --git a/spec/lib/atlassian/jira_connect/client_spec.rb b/spec/lib/atlassian/jira_connect/client_spec.rb
index dd3130c78bfe6af1d3104ae5ac3e96df85c6e083..1857a1431dfe621d937c7fabc4995d48631cbf80 100644
--- a/spec/lib/atlassian/jira_connect/client_spec.rb
+++ b/spec/lib/atlassian/jira_connect/client_spec.rb
@@ -58,12 +58,16 @@
         deployments: :q
       ).and_return(:deploys_stored)
 
-      expect(subject).to receive(:store_dev_info).with(
+      expect(Atlassian::JiraConnect::DevInfo).to receive(:new).with(
         project: project,
         update_sequence_id: :x,
         commits: :a,
         branches: :b,
         merge_requests: :c
+      ).and_call_original
+
+      expect(subject).to receive(:store_dev_info).with(
+        instance_of(Atlassian::JiraConnect::DevInfo)
       ).and_return(:dev_stored)
 
       args = {
@@ -83,9 +87,7 @@
 
     it 'only calls methods that we need to call' do
       expect(subject).to receive(:store_dev_info).with(
-        project: project,
-        update_sequence_id: :x,
-        commits: :a
+        instance_of(Atlassian::JiraConnect::DevInfo)
       ).and_return(:dev_stored)
 
       args = {
@@ -402,15 +404,7 @@ def expected_headers(path)
     end
 
     it "calls the API with auth headers" do
-      subject.send(:store_dev_info, project: project)
-    end
-
-    it 'avoids N+1 database queries' do
-      control_count = ActiveRecord::QueryRecorder.new { subject.send(:store_dev_info, project: project, merge_requests: merge_requests) }.count
-
-      merge_requests << create(:merge_request, :unique_branches)
-
-      expect { subject.send(:store_dev_info, project: project, merge_requests: merge_requests) }.not_to exceed_query_limit(control_count)
+      subject.send(:store_dev_info, Atlassian::JiraConnect::DevInfo.new(project: project))
     end
   end
 end
diff --git a/spec/lib/atlassian/jira_connect/dev_info_spec.rb b/spec/lib/atlassian/jira_connect/dev_info_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..357168a94b95d506b1ec06d87f05f8d7495bba66
--- /dev/null
+++ b/spec/lib/atlassian/jira_connect/dev_info_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::JiraConnect::DevInfo do
+  let_it_be(:project) { create_default(:project, :repository).freeze }
+
+  let(:update_sequence_id) { '123' }
+
+  describe '#url' do
+    subject { described_class.new(project: project).url }
+
+    it { is_expected.to eq('/rest/devinfo/0.10/bulk') }
+  end
+
+  describe '#body' do
+    let_it_be(:merge_request) { create(:merge_request, :unique_branches, title: 'TEST-123') }
+    let_it_be(:note) { create(:note, noteable: merge_request, project: merge_request.project) }
+    let_it_be(:branches) do
+      project.repository.create_branch('TEST-123', project.default_branch_or_main)
+      [project.repository.find_branch('TEST-123')]
+    end
+
+    let(:merge_requests) { [merge_request] }
+
+    subject(:body) { described_class.new(project: project, branches: branches, merge_requests: merge_requests, update_sequence_id: update_sequence_id).body.to_json }
+
+    it 'matches the schema' do
+      expect(body).to match_schema('jira_connect/dev_info')
+    end
+
+    it 'avoids N+1 database queries' do
+      control_count = ActiveRecord::QueryRecorder.new { subject }.count
+
+      merge_requests << create(:merge_request, :unique_branches)
+
+      expect { subject }.not_to exceed_query_limit(control_count)
+    end
+  end
+
+  describe '#present?' do
+    let(:arguments) { { commits: nil, branches: nil, merge_requests: nil } }
+
+    subject { described_class.new(**{ project: project, update_sequence_id: update_sequence_id }.merge(arguments)).present? }
+
+    it { is_expected.to eq(false) }
+
+    context 'with commits, branches or merge requests' do
+      let(:arguments) { { commits: anything, branches: anything, merge_requests: anything } }
+
+      it { is_expected.to eq(true) }
+    end
+  end
+end