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