1# frozen_string_literal: true
2
3module Atlassian
4  module JiraConnect
5    class Client < Gitlab::HTTP
6      def self.generate_update_sequence_id
7        (Time.now.utc.to_f * 1000).round
8      end
9
10      def initialize(base_uri, shared_secret)
11        @base_uri = base_uri
12        @shared_secret = shared_secret
13      end
14
15      def send_info(project:, update_sequence_id: nil, **args)
16        common = { project: project, update_sequence_id: update_sequence_id }
17        dev_info = args.slice(:commits, :branches, :merge_requests)
18        build_info = args.slice(:pipelines)
19        deploy_info = args.slice(:deployments)
20        ff_info = args.slice(:feature_flags)
21
22        responses = []
23
24        responses << store_dev_info(**common, **dev_info) if dev_info.present?
25        responses << store_build_info(**common, **build_info) if build_info.present?
26        responses << store_deploy_info(**common, **deploy_info) if deploy_info.present?
27        responses << store_ff_info(**common, **ff_info) if ff_info.present?
28        raise ArgumentError, 'Invalid arguments' if responses.empty?
29
30        responses.compact
31      end
32
33      def user_info(account_id)
34        r = get('/rest/api/3/user', { accountId: account_id, expand: 'groups' })
35
36        JiraUser.new(r.parsed_response) if r.code == 200
37      end
38
39      private
40
41      def get(path, query_params)
42        uri = URI.join(@base_uri, path)
43        uri.query = URI.encode_www_form(query_params)
44
45        self.class.get(uri, headers: headers(uri, 'GET'))
46      end
47
48      def store_ff_info(project:, feature_flags:, **opts)
49        items = feature_flags.map { |flag| ::Atlassian::JiraConnect::Serializers::FeatureFlagEntity.represent(flag, opts) }
50        items.reject! { |item| item.issue_keys.empty? }
51
52        return if items.empty?
53
54        r = post('/rest/featureflags/0.1/bulk', {
55          flags: items,
56          properties: { projectId: "project-#{project.id}" }
57        })
58
59        handle_response(r, 'feature flags') do |data|
60          failed = data['failedFeatureFlags']
61          if failed.present?
62            errors = failed.flat_map do |k, errs|
63              errs.map { |e| "#{k}: #{e['message']}" }
64            end
65            { 'errorMessages' => errors }
66          end
67        end
68      end
69
70      def store_deploy_info(project:, deployments:, **opts)
71        items = deployments.map { |d| ::Atlassian::JiraConnect::Serializers::DeploymentEntity.represent(d, opts) }
72        items.reject! { |d| d.issue_keys.empty? }
73
74        return if items.empty?
75
76        r = post('/rest/deployments/0.1/bulk', { deployments: items })
77        handle_response(r, 'deployments') { |data| errors(data, 'rejectedDeployments') }
78      end
79
80      def store_build_info(project:, pipelines:, update_sequence_id: nil)
81        builds = pipelines.map do |pipeline|
82          build = ::Atlassian::JiraConnect::Serializers::BuildEntity.represent(
83            pipeline,
84            update_sequence_id: update_sequence_id
85          )
86          next if build.issue_keys.empty?
87
88          build
89        end.compact
90        return if builds.empty?
91
92        r = post('/rest/builds/0.1/bulk', { builds: builds })
93        handle_response(r, 'builds') { |data| errors(data, 'rejectedBuilds') }
94      end
95
96      def store_dev_info(project:, commits: nil, branches: nil, merge_requests: nil, update_sequence_id: nil)
97        repo = ::Atlassian::JiraConnect::Serializers::RepositoryEntity.represent(
98          project,
99          commits: commits,
100          branches: branches,
101          merge_requests: merge_requests,
102          user_notes_count: user_notes_count(merge_requests),
103          update_sequence_id: update_sequence_id
104        )
105
106        post('/rest/devinfo/0.10/bulk', { repositories: [repo] })
107      end
108
109      def post(path, payload)
110        uri = URI.join(@base_uri, path)
111
112        self.class.post(uri, headers: headers(uri), body: metadata.merge(payload).to_json)
113      end
114
115      def headers(uri, http_method = 'POST')
116        {
117          'Authorization' => "JWT #{jwt_token(http_method, uri)}",
118          'Content-Type' => 'application/json',
119          'Accept' => 'application/json'
120        }
121      end
122
123      def metadata
124        { providerMetadata: { product: "GitLab #{Gitlab::VERSION}" } }
125      end
126
127      def handle_response(response, name, &block)
128        data = response.parsed_response
129
130        case response.code
131        when 200 then yield data
132        when 400 then { 'errorMessages' => data.map { |e| e['message'] } }
133        when 401 then { 'errorMessages' => ['Invalid JWT'] }
134        when 403 then { 'errorMessages' => ["App does not support #{name}"] }
135        when 413 then { 'errorMessages' => ['Data too large'] + data.map { |e| e['message'] } }
136        when 429 then { 'errorMessages' => ['Rate limit exceeded'] }
137        when 503 then { 'errorMessages' => ['Service unavailable'] }
138        else
139          { 'errorMessages' => ['Unknown error'], 'response' => data }
140        end
141      end
142
143      def errors(data, key)
144        messages = if data[key].present?
145                     data[key].flat_map do |rejection|
146                       rejection['errors'].map { |e| e['message'] }
147                     end
148                   else
149                     []
150                   end
151
152        { 'errorMessages' => messages }
153      end
154
155      def user_notes_count(merge_requests)
156        return unless merge_requests
157
158        Note.count_for_collection(merge_requests.map(&:id), 'MergeRequest').to_h do |count_group|
159          [count_group.noteable_id, count_group.count]
160        end
161      end
162
163      def jwt_token(http_method, uri)
164        claims = Atlassian::Jwt.build_claims(
165          Atlassian::JiraConnect.app_key,
166          uri,
167          http_method,
168          @base_uri
169        )
170
171        Atlassian::Jwt.encode(claims, @shared_secret)
172      end
173    end
174  end
175end
176