diff options
Diffstat (limited to 'lib/bitbucket_server')
-rw-r--r-- | lib/bitbucket_server/client.rb | 71 | ||||
-rw-r--r-- | lib/bitbucket_server/collection.rb | 23 | ||||
-rw-r--r-- | lib/bitbucket_server/connection.rb | 122 | ||||
-rw-r--r-- | lib/bitbucket_server/page.rb | 36 | ||||
-rw-r--r-- | lib/bitbucket_server/paginator.rb | 38 | ||||
-rw-r--r-- | lib/bitbucket_server/representation/activity.rb | 71 | ||||
-rw-r--r-- | lib/bitbucket_server/representation/base.rb | 21 | ||||
-rw-r--r-- | lib/bitbucket_server/representation/comment.rb | 130 | ||||
-rw-r--r-- | lib/bitbucket_server/representation/pull_request.rb | 76 | ||||
-rw-r--r-- | lib/bitbucket_server/representation/pull_request_comment.rb | 122 | ||||
-rw-r--r-- | lib/bitbucket_server/representation/repo.rb | 69 |
11 files changed, 779 insertions, 0 deletions
diff --git a/lib/bitbucket_server/client.rb b/lib/bitbucket_server/client.rb new file mode 100644 index 00000000000..15e59f93141 --- /dev/null +++ b/lib/bitbucket_server/client.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module BitbucketServer + class Client + attr_reader :connection + + ServerError = Class.new(StandardError) + + SERVER_ERRORS = [SocketError, + OpenSSL::SSL::SSLError, + Errno::ECONNRESET, + Errno::ECONNREFUSED, + Errno::EHOSTUNREACH, + Net::OpenTimeout, + Net::ReadTimeout, + Gitlab::HTTP::BlockedUrlError, + BitbucketServer::Connection::ConnectionError].freeze + + def initialize(options = {}) + @connection = Connection.new(options) + end + + def pull_requests(project_key, repo) + path = "/projects/#{project_key}/repos/#{repo}/pull-requests?state=ALL" + get_collection(path, :pull_request) + end + + def activities(project_key, repo, pull_request_id) + path = "/projects/#{project_key}/repos/#{repo}/pull-requests/#{pull_request_id}/activities" + get_collection(path, :activity) + end + + def repo(project, repo_name) + parsed_response = connection.get("/projects/#{project}/repos/#{repo_name}") + BitbucketServer::Representation::Repo.new(parsed_response) + end + + def repos + path = "/repos" + get_collection(path, :repo) + end + + def create_branch(project_key, repo, branch_name, sha) + payload = { + name: branch_name, + startPoint: sha, + message: 'GitLab temporary branch for import' + } + + connection.post("/projects/#{project_key}/repos/#{repo}/branches", payload.to_json) + end + + def delete_branch(project_key, repo, branch_name, sha) + payload = { + name: Gitlab::Git::BRANCH_REF_PREFIX + branch_name, + dryRun: false + } + + connection.delete(:branches, "/projects/#{project_key}/repos/#{repo}/branches", payload.to_json) + end + + private + + def get_collection(path, type) + paginator = BitbucketServer::Paginator.new(connection, Addressable::URI.escape(path), type) + BitbucketServer::Collection.new(paginator) + rescue *SERVER_ERRORS => e + raise ServerError, e + end + end +end diff --git a/lib/bitbucket_server/collection.rb b/lib/bitbucket_server/collection.rb new file mode 100644 index 00000000000..b50c5dde352 --- /dev/null +++ b/lib/bitbucket_server/collection.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module BitbucketServer + class Collection < Enumerator + def initialize(paginator) + super() do |yielder| + loop do + paginator.items.each { |item| yielder << item } + end + end + + lazy + end + + def method_missing(method, *args) + return super unless self.respond_to?(method) + + self.__send__(method, *args) do |item| # rubocop:disable GitlabSecurity/PublicSend + block_given? ? yield(item) : item + end + end + end +end diff --git a/lib/bitbucket_server/connection.rb b/lib/bitbucket_server/connection.rb new file mode 100644 index 00000000000..45a437844bd --- /dev/null +++ b/lib/bitbucket_server/connection.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module BitbucketServer + class Connection + include ActionView::Helpers::SanitizeHelper + + DEFAULT_API_VERSION = '1.0' + SEPARATOR = '/' + + attr_reader :api_version, :base_uri, :username, :token + + ConnectionError = Class.new(StandardError) + + def initialize(options = {}) + @api_version = options.fetch(:api_version, DEFAULT_API_VERSION) + @base_uri = options[:base_uri] + @username = options[:user] + @token = options[:password] + end + + def get(path, extra_query = {}) + response = Gitlab::HTTP.get(build_url(path), + basic_auth: auth, + headers: accept_headers, + query: extra_query) + + check_errors!(response) + + response.parsed_response + end + + def post(path, body) + response = Gitlab::HTTP.post(build_url(path), + basic_auth: auth, + headers: post_headers, + body: body) + + check_errors!(response) + + response.parsed_response + end + + # We need to support two different APIs for deletion: + # + # /rest/api/1.0/projects/{projectKey}/repos/{repositorySlug}/branches/default + # /rest/branch-utils/1.0/projects/{projectKey}/repos/{repositorySlug}/branches + def delete(resource, path, body) + url = delete_url(resource, path) + + response = Gitlab::HTTP.delete(url, + basic_auth: auth, + headers: post_headers, + body: body) + + check_errors!(response) + + response.parsed_response + end + + private + + def check_errors!(response) + raise ConnectionError, "Response is not valid JSON" unless response.parsed_response.is_a?(Hash) + + return if response.code >= 200 && response.code < 300 + + details = sanitize(response.parsed_response.dig('errors', 0, 'message')) + message = "Error #{response.code}" + message += ": #{details}" if details + + raise ConnectionError, message + rescue JSON::ParserError + raise ConnectionError, "Unable to parse the server response as JSON" + end + + def auth + @auth ||= { username: username, password: token } + end + + def accept_headers + @accept_headers ||= { 'Accept' => 'application/json' } + end + + def post_headers + @post_headers ||= accept_headers.merge({ 'Content-Type' => 'application/json' }) + end + + def build_url(path) + return path if path.starts_with?(root_url) + + url_join_paths(root_url, path) + end + + def root_url + url_join_paths(base_uri, "/rest/api/#{api_version}") + end + + def delete_url(resource, path) + if resource == :branches + url_join_paths(base_uri, "/rest/branch-utils/#{api_version}#{path}") + else + build_url(path) + end + end + + # URI.join is stupid in that slashes are important: + # + # # URI.join('http://example.com/subpath', 'hello') + # => http://example.com/hello + # + # We really want http://example.com/subpath/hello + # + def url_join_paths(*paths) + paths.map { |path| strip_slashes(path) }.join(SEPARATOR) + end + + def strip_slashes(path) + path = path[1..-1] if path.starts_with?(SEPARATOR) + path.chomp(SEPARATOR) + end + end +end diff --git a/lib/bitbucket_server/page.rb b/lib/bitbucket_server/page.rb new file mode 100644 index 00000000000..5d9a3168876 --- /dev/null +++ b/lib/bitbucket_server/page.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module BitbucketServer + class Page + attr_reader :attrs, :items + + def initialize(raw, type) + @attrs = parse_attrs(raw) + @items = parse_values(raw, representation_class(type)) + end + + def next? + !attrs.fetch(:isLastPage, true) + end + + def next + attrs.fetch(:nextPageStart) + end + + private + + def parse_attrs(raw) + raw.slice('size', 'nextPageStart', 'isLastPage').symbolize_keys + end + + def parse_values(raw, bitbucket_rep_class) + return [] unless raw['values'] && raw['values'].is_a?(Array) + + bitbucket_rep_class.decorate(raw['values']) + end + + def representation_class(type) + BitbucketServer::Representation.const_get(type.to_s.camelize) + end + end +end diff --git a/lib/bitbucket_server/paginator.rb b/lib/bitbucket_server/paginator.rb new file mode 100644 index 00000000000..c351fb2f11f --- /dev/null +++ b/lib/bitbucket_server/paginator.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module BitbucketServer + class Paginator + PAGE_LENGTH = 25 + + def initialize(connection, url, type) + @connection = connection + @type = type + @url = url + @page = nil + end + + def items + raise StopIteration unless has_next_page? + + @page = fetch_next_page + @page.items + end + + private + + attr_reader :connection, :page, :url, :type + + def has_next_page? + page.nil? || page.next? + end + + def next_offset + page.nil? ? 0 : page.next + end + + def fetch_next_page + parsed_response = connection.get(@url, start: next_offset, limit: PAGE_LENGTH) + Page.new(parsed_response, type) + end + end +end diff --git a/lib/bitbucket_server/representation/activity.rb b/lib/bitbucket_server/representation/activity.rb new file mode 100644 index 00000000000..08bf30a5d1e --- /dev/null +++ b/lib/bitbucket_server/representation/activity.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module BitbucketServer + module Representation + class Activity < Representation::Base + def comment? + action == 'COMMENTED' + end + + def inline_comment? + !!(comment? && comment_anchor) + end + + def comment + return unless comment? + + @comment ||= + if inline_comment? + PullRequestComment.new(raw) + else + Comment.new(raw) + end + end + + # TODO Move this into MergeEvent + def merge_event? + action == 'MERGED' + end + + def committer_user + commit.dig('committer', 'displayName') + end + + def committer_email + commit.dig('committer', 'emailAddress') + end + + def merge_timestamp + timestamp = commit['committerTimestamp'] + + self.class.convert_timestamp(timestamp) + end + + def merge_commit + commit['id'] + end + + def created_at + self.class.convert_timestamp(created_date) + end + + private + + def commit + raw.fetch('commit', {}) + end + + def action + raw['action'] + end + + def comment_anchor + raw['commentAnchor'] + end + + def created_date + raw['createdDate'] + end + end + end +end diff --git a/lib/bitbucket_server/representation/base.rb b/lib/bitbucket_server/representation/base.rb new file mode 100644 index 00000000000..a1961bae6ef --- /dev/null +++ b/lib/bitbucket_server/representation/base.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module BitbucketServer + module Representation + class Base + attr_reader :raw + + def initialize(raw) + @raw = raw + end + + def self.decorate(entries) + entries.map { |entry| new(entry)} + end + + def self.convert_timestamp(time_usec) + Time.at(time_usec / 1000) if time_usec.is_a?(Integer) + end + end + end +end diff --git a/lib/bitbucket_server/representation/comment.rb b/lib/bitbucket_server/representation/comment.rb new file mode 100644 index 00000000000..99b97a3b181 --- /dev/null +++ b/lib/bitbucket_server/representation/comment.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module BitbucketServer + module Representation + # A general comment with the structure: + # "comment": { + # "author": { + # "active": true, + # "displayName": "root", + # "emailAddress": "stanhu+bitbucket@gitlab.com", + # "id": 1, + # "links": { + # "self": [ + # { + # "href": "http://localhost:7990/users/root" + # } + # ] + # }, + # "name": "root", + # "slug": "root", + # "type": "NORMAL" + # } + # } + # } + class Comment < Representation::Base + attr_reader :parent_comment + + CommentNode = Struct.new(:raw_comments, :parent) + + def initialize(raw, parent_comment: nil) + super(raw) + + @parent_comment = parent_comment + end + + def id + raw_comment['id'] + end + + def author_username + author['displayName'] + end + + def author_email + author['emailAddress'] + end + + def note + raw_comment['text'] + end + + def created_at + self.class.convert_timestamp(created_date) + end + + def updated_at + self.class.convert_timestamp(created_date) + end + + # Bitbucket Server supports the ability to reply to any comment + # and created multiple threads. It represents these as a linked list + # of comments within comments. For example: + # + # "comments": [ + # { + # "author" : ... + # "comments": [ + # { + # "author": ... + # + # Since GitLab only supports a single thread, we flatten all these + # comments into a single discussion. + def comments + @comments ||= flatten_comments + end + + private + + # In order to provide context for each reply, we need to track + # the parent of each comment. This method works as follows: + # + # 1. Insert the root comment into the workset. The root element is the current note. + # 2. For each node in the workset: + # a. Examine if it has replies to that comment. If it does, + # insert that node into the workset. + # b. Parse that note into a Comment structure and add it to a flat list. + def flatten_comments + comments = raw_comment['comments'] + workset = + if comments + [CommentNode.new(comments, self)] + else + [] + end + + all_comments = [] + + until workset.empty? + node = workset.pop + parent = node.parent + + node.raw_comments.each do |comment| + new_comments = comment.delete('comments') + current_comment = Comment.new({ 'comment' => comment }, parent_comment: parent) + all_comments << current_comment + workset << CommentNode.new(new_comments, current_comment) if new_comments + end + end + + all_comments + end + + def raw_comment + raw.fetch('comment', {}) + end + + def author + raw_comment['author'] + end + + def created_date + raw_comment['createdDate'] + end + + def updated_date + raw_comment['updatedDate'] + end + end + end +end diff --git a/lib/bitbucket_server/representation/pull_request.rb b/lib/bitbucket_server/representation/pull_request.rb new file mode 100644 index 00000000000..c3e927d8de7 --- /dev/null +++ b/lib/bitbucket_server/representation/pull_request.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module BitbucketServer + module Representation + class PullRequest < Representation::Base + def author + raw.dig('author', 'user', 'name') + end + + def author_email + raw.dig('author', 'user', 'emailAddress') + end + + def description + raw['description'] + end + + def iid + raw['id'] + end + + def state + case raw['state'] + when 'MERGED' + 'merged' + when 'DECLINED' + 'closed' + else + 'opened' + end + end + + def merged? + state == 'merged' + end + + def created_at + self.class.convert_timestamp(created_date) + end + + def updated_at + self.class.convert_timestamp(updated_date) + end + + def title + raw['title'] + end + + def source_branch_name + raw.dig('fromRef', 'id') + end + + def source_branch_sha + raw.dig('fromRef', 'latestCommit') + end + + def target_branch_name + raw.dig('toRef', 'id') + end + + def target_branch_sha + raw.dig('toRef', 'latestCommit') + end + + private + + def created_date + raw['createdDate'] + end + + def updated_date + raw['updatedDate'] + end + end + end +end diff --git a/lib/bitbucket_server/representation/pull_request_comment.rb b/lib/bitbucket_server/representation/pull_request_comment.rb new file mode 100644 index 00000000000..a2b3873a397 --- /dev/null +++ b/lib/bitbucket_server/representation/pull_request_comment.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module BitbucketServer + module Representation + # An inline comment with the following structure that identifies + # the part of the diff: + # + # "commentAnchor": { + # "diffType": "EFFECTIVE", + # "fileType": "TO", + # "fromHash": "c5f4288162e2e6218180779c7f6ac1735bb56eab", + # "line": 1, + # "lineType": "ADDED", + # "orphaned": false, + # "path": "CHANGELOG.md", + # "toHash": "a4c2164330f2549f67c13f36a93884cf66e976be" + # } + # + # More details in https://docs.atlassian.com/bitbucket-server/rest/5.12.0/bitbucket-rest.html. + class PullRequestComment < Comment + def from_sha + comment_anchor['fromHash'] + end + + def to_sha + comment_anchor['toHash'] + end + + def to? + file_type == 'TO' + end + + def from? + file_type == 'FROM' + end + + def added? + line_type == 'ADDED' + end + + def removed? + line_type == 'REMOVED' + end + + # There are three line comment types: added, removed, or context. + # + # 1. An added type means a new line was inserted, so there is no old position. + # 2. A removed type means a line was removed, so there is no new position. + # 3. A context type means the line was unmodified, so there is both a + # old and new position. + def new_pos + return if removed? + return unless line_position + + line_position[1] + end + + def old_pos + return if added? + return unless line_position + + line_position[0] + end + + def file_path + comment_anchor.fetch('path') + end + + private + + def file_type + comment_anchor['fileType'] + end + + def line_type + comment_anchor['lineType'] + end + + # Each comment contains the following information about the diff: + # + # hunks: [ + # { + # segments: [ + # { + # "lines": [ + # { + # "commentIds": [ N ], + # "source": X, + # "destination": Y + # }, ... + # ] .... + # + # To determine the line position of a comment, we search all the lines + # entries until we find this comment ID. + def line_position + @line_position ||= diff_hunks.each do |hunk| + segments = hunk.fetch('segments', []) + segments.each do |segment| + lines = segment.fetch('lines', []) + lines.each do |line| + if line['commentIds']&.include?(id) + return [line['source'], line['destination']] + end + end + end + end + end + + def comment_anchor + raw.fetch('commentAnchor', {}) + end + + def diff + raw.fetch('diff', {}) + end + + def diff_hunks + diff.fetch('hunks', []) + end + end + end +end diff --git a/lib/bitbucket_server/representation/repo.rb b/lib/bitbucket_server/representation/repo.rb new file mode 100644 index 00000000000..6c494b79166 --- /dev/null +++ b/lib/bitbucket_server/representation/repo.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module BitbucketServer + module Representation + class Repo < Representation::Base + def initialize(raw) + super(raw) + end + + def project_key + raw.dig('project', 'key') + end + + def project_name + raw.dig('project', 'name') + end + + def slug + raw['slug'] + end + + def browse_url + # The JSON reponse contains an array of 1 element. Not sure if there + # are cases where multiple links would be provided. + raw.dig('links', 'self').first.fetch('href') + end + + def clone_url + raw['links']['clone'].find { |link| link['name'].starts_with?('http') }.fetch('href') + end + + def description + project['description'] + end + + def full_name + "#{project_name}/#{name}" + end + + def issues_enabled? + true + end + + def name + raw['name'] + end + + def valid? + raw['scmId'] == 'git' + end + + def visibility_level + if project['public'] + Gitlab::VisibilityLevel::PUBLIC + else + Gitlab::VisibilityLevel::PRIVATE + end + end + + def project + raw['project'] + end + + def to_s + full_name + end + end + end +end |