Add Bitbucket integration support

- Add Bitbucket API client with support for commits, webhooks, PRs, and file retrieval
- Add Bitbucket webhook controller and job for autodeploy
- Add Bitbucket provider creation action with token validation
- Update Provider model with Bitbucket constants and helper methods
- Update Git::Client factory to route to Bitbucket client
- Add Bitbucket routes for inbound webhooks
- Update provider views with Bitbucket option and instructions
- Update Project and Event models for Bitbucket URLs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Zhu
2026-01-22 05:40:20 +00:00
parent db85a3f21e
commit b58dee53be
14 changed files with 361 additions and 8 deletions

View File

@@ -17,6 +17,10 @@ module Providers
with(provider:).reduce(
Providers::CreateGitlabProvider,
)
elsif provider.bitbucket?
with(provider:).reduce(
Providers::CreateBitbucketProvider,
)
end
end
end

View File

@@ -0,0 +1,47 @@
class Providers::CreateBitbucketProvider
EXPECTED_SCOPES = %w[repository webhook pullrequest]
extend LightService::Action
expects :provider
promises :provider
executed do |context|
base_url = context.provider.api_base_url
user_api_url = "#{base_url}/2.0/user"
# Validate token by fetching user info
response = HTTParty.get(user_api_url,
headers: {
"Authorization" => "Bearer #{context.provider.access_token}"
}
)
if response.code == 401
message = "Invalid access token"
context.provider.errors.add(:access_token, message)
context.fail_and_return!(message)
next
end
if response.code != 200
message = "Failed to validate access token: #{response.body}"
context.provider.errors.add(:access_token, message)
context.fail_and_return!(message)
next
end
# Bitbucket doesn't expose token scopes via API like GitHub/GitLab
# So we skip scope validation and trust the user configured it correctly
username = response["username"] || response["display_name"]
context.provider.auth = {
"info" => { "nickname" => username }
}.merge(response.parsed_response).to_json
context.provider.save!
rescue Errno::ECONNREFUSED, SocketError => e
message = "Could not connect to Bitbucket server: #{e.message}"
context.provider.errors.add(:registry_url, message)
context.fail_and_return!(message)
end
end

View File

@@ -0,0 +1,32 @@
module InboundWebhooks
class BitbucketController < ApplicationController
before_action :verify_event
def create
# Save webhook to database
record = InboundWebhook.create(body: payload)
# Queue webhook for processing
InboundWebhooks::BitbucketJob.perform_later(record, current_user:)
# Tell service we received the webhook successfully
head :ok
end
private
def verify_event
secret = Git::Bitbucket::Client::BITBUCKET_WEBHOOK_SECRET
return if secret.blank?
payload_body = request.body.read
signature = request.headers["X-Hub-Signature"]
return head :bad_request if signature.blank?
expected_signature = "sha256=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, payload_body)
unless Rack::Utils.secure_compare(expected_signature, signature)
head :bad_request
end
end
end
end

View File

@@ -0,0 +1,59 @@
module InboundWebhooks
class BitbucketJob < ApplicationJob
queue_as :default
def perform(inbound_webhook, current_user: nil)
inbound_webhook.processing!
# Process webhook
# Determine the project
# Trigger a docker build & docker deploy if auto deploy is on for the project
body = JSON.parse(inbound_webhook.body)
process_webhook(body, current_user:)
inbound_webhook.processed!
# Or mark as failed and re-enqueue the job
# inbound_webhook.failed!
end
def process_webhook(body, current_user:)
# Bitbucket sends push events with "push" key containing changes
return unless body["push"].present?
# Get repository full name (workspace/repo_slug)
repository = body.dig("repository", "full_name")
return if repository.blank?
# Get changes - Bitbucket sends an array of changes
changes = body.dig("push", "changes") || []
changes.each do |change|
# Get the branch name from the new reference
new_ref = change["new"]
next unless new_ref.present? && new_ref["type"] == "branch"
branch = new_ref["name"]
commit = new_ref.dig("target", "hash")
commit_message = new_ref.dig("target", "message") || ""
projects = Project.where(
"LOWER(repository_url) = ?",
repository.downcase
).where(
"LOWER(branch) = ?",
branch.downcase
).where(autodeploy: true)
projects.each do |project|
# Trigger a docker build & docker deploy
build = project.builds.create!(
current_user:,
commit_sha: commit,
commit_message: commit_message.split("\n").first
)
Projects::BuildJob.perform_later(build, current_user)
end
end
end
end
end

View File

@@ -34,6 +34,10 @@ class Event < ApplicationRecord
def external_link
if project.github?
"https://github.com/#{project.repository_url}/commit/#{eventable.commit_sha}"
elsif project.gitlab?
"https://gitlab.com/#{project.repository_url}/-/commit/#{eventable.commit_sha}"
elsif project.bitbucket?
"https://bitbucket.org/#{project.repository_url}/commits/#{eventable.commit_sha}"
else
"https://hub.docker.com/r/#{project.repository_url}/tags"
end

View File

@@ -97,7 +97,7 @@ class Project < ApplicationRecord
disabled: 0,
manually_create: 1
}, prefix: :forks
delegate :git?, :github?, :gitlab?, to: :project_credential_provider
delegate :git?, :github?, :gitlab?, :bitbucket?, to: :project_credential_provider
delegate :container_registry?, to: :project_credential_provider
def project_fork_cluster_id_is_owned_by_account
@@ -136,12 +136,16 @@ class Project < ApplicationRecord
"https://github.com/#{repository_url}/pull/#{child_fork.number}"
elsif gitlab?
"https://gitlab.com/#{repository_url}/merge_requests/#{child_fork.number}"
elsif bitbucket?
"https://bitbucket.org/#{repository_url}/pull-requests/#{child_fork.number}"
end
else
if github?
"https://github.com/#{repository_url}"
elsif gitlab?
"https://gitlab.com/#{repository_url}"
elsif bitbucket?
"https://bitbucket.org/#{repository_url}"
else
"https://hub.docker.com/r/#{repository_url}"
end

View File

@@ -30,6 +30,7 @@ class ProjectCredentialProvider < ApplicationRecord
delegate :github?, to: :provider
delegate :container_registry?, to: :provider
delegate :gitlab?, to: :provider
delegate :bitbucket?, to: :provider
delegate :git?, to: :provider
def github_username

View File

@@ -36,16 +36,18 @@ class Provider < ApplicationRecord
CUSTOM_REGISTRY_PROVIDER = "container_registry"
GITLAB_PROVIDER = "gitlab"
GITLAB_API_BASE = "https://gitlab.com"
BITBUCKET_PROVIDER = "bitbucket"
BITBUCKET_API_BASE = "https://api.bitbucket.org"
GIT_TYPE = "git"
REGISTRY_TYPE = "registry"
PROVIDER_TYPES = {
GIT_TYPE => [ GITHUB_PROVIDER, GITLAB_PROVIDER ],
GIT_TYPE => [ GITHUB_PROVIDER, GITLAB_PROVIDER, BITBUCKET_PROVIDER ],
REGISTRY_TYPE => [ CUSTOM_REGISTRY_PROVIDER ]
}
PORTAINER_PROVIDER = "portainer"
AVAILABLE_PROVIDERS = [ GITHUB_PROVIDER, GITLAB_PROVIDER, CUSTOM_REGISTRY_PROVIDER ].freeze
AVAILABLE_PROVIDERS = [ GITHUB_PROVIDER, GITLAB_PROVIDER, BITBUCKET_PROVIDER, CUSTOM_REGISTRY_PROVIDER ].freeze
validates :registry_url, presence: true, if: :container_registry?
scope :has_container_registry, -> { where(provider: [ GITHUB_PROVIDER, GITLAB_PROVIDER, CUSTOM_REGISTRY_PROVIDER ]) }
scope :non_sso, -> { where(sso_provider_id: nil) }
@@ -67,7 +69,7 @@ class Provider < ApplicationRecord
end
def git?
github? || gitlab?
github? || gitlab? || bitbucket?
end
def expired?
@@ -91,8 +93,12 @@ class Provider < ApplicationRecord
provider == GITLAB_PROVIDER
end
def bitbucket?
provider == BITBUCKET_PROVIDER
end
def enterprise?
(github? || gitlab?) && registry_url.present?
(github? || gitlab? || bitbucket?) && registry_url.present?
end
def api_base_url
@@ -102,6 +108,8 @@ class Provider < ApplicationRecord
GITHUB_API_BASE
elsif gitlab?
GITLAB_API_BASE
elsif bitbucket?
BITBUCKET_API_BASE
end
end

View File

@@ -0,0 +1,167 @@
class Git::Bitbucket::Client < Git::Client
BITBUCKET_WEBHOOK_SECRET = ENV["BITBUCKET_WEBHOOK_SECRET"]
attr_accessor :access_token, :repository_url, :api_base_url
def self.from_project(project)
provider = project.project_credential_provider.provider
raise "Project is not a Bitbucket project" unless provider.bitbucket?
new(
access_token: provider.access_token,
repository_url: project.repository_url,
api_base_url: provider.api_base_url
)
end
def initialize(access_token:, repository_url:, api_base_url: nil)
@access_token = access_token
@repository_url = repository_url
@api_base_url = api_base_url || "https://api.bitbucket.org"
end
def bitbucket_api_base
"#{@api_base_url}/2.0"
end
def repository_exists?
repository.present? && repository["uuid"].present?
end
def commits(branch)
response = HTTParty.get(
"#{bitbucket_api_base}/repositories/#{repository_url}/commits?include=#{branch}",
headers: auth_headers
)
unless response.success?
raise "Failed to fetch commits: #{response.body}"
end
(response["values"] || []).map do |commit|
Git::Common::Commit.new(
sha: commit["hash"],
message: commit["message"],
author_name: commit.dig("author", "user", "display_name") || commit.dig("author", "raw")&.split("<")&.first&.strip,
author_email: extract_email(commit.dig("author", "raw")),
authored_at: DateTime.parse(commit["date"]),
committer_name: commit.dig("author", "user", "display_name") || commit.dig("author", "raw")&.split("<")&.first&.strip,
committer_email: extract_email(commit.dig("author", "raw")),
committed_at: DateTime.parse(commit["date"]),
url: commit.dig("links", "html", "href")
)
end
end
def can_write_webhooks?
webhooks
true
rescue StandardError
false
end
def register_webhook!
return if webhook_exists?
response = HTTParty.post(
"#{bitbucket_api_base}/repositories/#{repository_url}/hooks",
headers: auth_headers.merge("Content-Type" => "application/json"),
body: {
description: "Canine autodeploy webhook",
url: Rails.application.routes.url_helpers.inbound_webhooks_bitbucket_index_url,
active: true,
secret: BITBUCKET_WEBHOOK_SECRET,
events: [ "repo:push" ]
}.to_json
)
unless response.success?
raise "Failed to register webhook: #{response.body}"
end
response.parsed_response
end
def webhooks
response = HTTParty.get(
"#{bitbucket_api_base}/repositories/#{repository_url}/hooks",
headers: auth_headers
)
return [] unless response.success?
response["values"] || []
end
def webhook_exists?
webhook.present?
end
def webhook
webhooks.find { |h| h["url"]&.include?(Rails.application.routes.url_helpers.inbound_webhooks_bitbucket_index_path) }
end
def remove_webhook!
return unless webhook_exists?
HTTParty.delete(
"#{bitbucket_api_base}/repositories/#{repository_url}/hooks/#{webhook['uuid']}",
headers: auth_headers
)
end
def pull_requests
response = HTTParty.get(
"#{bitbucket_api_base}/repositories/#{repository_url}/pullrequests",
headers: auth_headers
)
return [] unless response.success?
(response["values"] || []).map do |pr|
Git::Common::PullRequest.new(
id: pr["id"],
title: pr["title"],
number: pr["id"],
user: pr.dig("author", "display_name") || pr.dig("author", "nickname"),
url: pr.dig("links", "html", "href"),
branch: pr.dig("source", "branch", "name"),
created_at: DateTime.parse(pr["created_on"]),
updated_at: DateTime.parse(pr["updated_on"])
)
end
end
def pull_request_status(pr_number)
response = HTTParty.get(
"#{bitbucket_api_base}/repositories/#{repository_url}/pullrequests/#{pr_number}",
headers: auth_headers
)
return "not_found" unless response.success?
response.parsed_response["state"]&.downcase
end
def get_file(file_path, branch)
response = HTTParty.get(
"#{bitbucket_api_base}/repositories/#{repository_url}/src/#{branch}/#{file_path}",
headers: auth_headers
)
response.success? ? Git::Common::File.new(file_path, response.body, branch) : nil
end
private
def repository
@repository ||= begin
response = HTTParty.get(
"#{bitbucket_api_base}/repositories/#{repository_url}",
headers: auth_headers
)
response.success? ? response.parsed_response : {}
end
end
def auth_headers
{ "Authorization" => "Bearer #{access_token}" }
end
def extract_email(raw_author)
return nil unless raw_author
match = raw_author.match(/<([^>]+)>/)
match ? match[1] : nil
end
end

View File

@@ -4,6 +4,8 @@ class Git::Client
Git::Github::Client.new(access_token: provider.access_token, repository_url:, api_base_url: provider.api_base_url)
elsif provider.gitlab?
Git::Gitlab::Client.new(access_token: provider.access_token, repository_url:, api_base_url: provider.api_base_url)
elsif provider.bitbucket?
Git::Bitbucket::Client.new(access_token: provider.access_token, repository_url:, api_base_url: provider.api_base_url)
else
raise "Unsupported Git provider: #{provider}"
end
@@ -14,6 +16,8 @@ class Git::Client
Git::Github::Client.from_project(project)
elsif project.project_credential_provider.provider.gitlab?
Git::Gitlab::Client.from_project(project)
elsif project.project_credential_provider.provider.bitbucket?
Git::Bitbucket::Client.from_project(project)
else
raise "Unsupported Git provider: #{project.project_credential_provider.provider}"
end

View File

@@ -59,8 +59,12 @@
</div>
<% provider_type = params[:provider_type] || form.object.provider %>
<% if provider_type == Provider::GITHUB_PROVIDER || provider_type == Provider::GITLAB_PROVIDER %>
<% provider_name = provider_type == Provider::GITHUB_PROVIDER ? "GitHub" : "GitLab" %>
<% if provider_type == Provider::GITHUB_PROVIDER || provider_type == Provider::GITLAB_PROVIDER || provider_type == Provider::BITBUCKET_PROVIDER %>
<% provider_name = case provider_type
when Provider::GITHUB_PROVIDER then "GitHub"
when Provider::GITLAB_PROVIDER then "GitLab"
when Provider::BITBUCKET_PROVIDER then "Bitbucket"
end %>
<div data-controller="expandable-optional-input">
<div>
<a data-action="expandable-optional-input#show" class="btn btn-ghost btn-sm mt-2">
@@ -71,7 +75,12 @@
<label class="label">
<span class="label-text">Host URL</span>
</label>
<%= form.text_field :registry_url, class: "input input-bordered", placeholder: provider_type == Provider::GITHUB_PROVIDER ? "https://github.example.com" : "https://gitlab.example.com" %>
<% placeholder = case provider_type
when Provider::GITHUB_PROVIDER then "https://github.example.com"
when Provider::GITLAB_PROVIDER then "https://gitlab.example.com"
when Provider::BITBUCKET_PROVIDER then "https://bitbucket.example.com"
end %>
<%= form.text_field :registry_url, class: "input input-bordered", placeholder: placeholder %>
<label class="label">
<span class="label-text-alt text-gray-400">Enter your <%= provider_name %> Enterprise server URL</span>
</label>

View File

@@ -13,9 +13,13 @@
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-[1] w-60 p-2 shadow">
<li>
<%= link_to "Github", new_provider_path(provider_type: Provider::GITHUB_PROVIDER) %>
</li>
<li>
<%= link_to "Gitlab", new_provider_path(provider_type: Provider::GITLAB_PROVIDER) %>
</li>
<li>
<%= link_to "Bitbucket", new_provider_path(provider_type: Provider::BITBUCKET_PROVIDER) %>
</li>
<li>
<%= link_to "Container Registry", new_provider_path(provider_type: Provider::CUSTOM_REGISTRY_PROVIDER) %>
</li>

View File

@@ -31,6 +31,15 @@
<% end %>
permissions.
</div>
<% elsif params[:provider_type] == Provider::BITBUCKET_PROVIDER %>
<%= link_to "Create your Bitbucket App Password →", "https://bitbucket.org/account/settings/app-passwords/", target: "_blank", class: "text-sm text-gray-500 " %>
<div class="mt-2">
Select the
<% Providers::CreateBitbucketProvider::EXPECTED_SCOPES.each do |scope| %>
<code class="mx-2 bg-white text-black px-2 py-1 rounded-md"><%= scope %></code>
<% end %>
permissions.
</div>
<% end %>
</div>
</div>

View File

@@ -101,6 +101,7 @@ Rails.application.routes.draw do
namespace :inbound_webhooks do
resources :github, controller: :github, only: [ :create ]
resources :gitlab, controller: :gitlab, only: [ :create ]
resources :bitbucket, controller: :bitbucket, only: [ :create ]
end
get "/privacy", to: "static#privacy"
get "/terms", to: "static#terms"