mirror of
https://github.com/czhu12/canine.git
synced 2026-01-31 08:29:53 -06:00
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:
@@ -17,6 +17,10 @@ module Providers
|
||||
with(provider:).reduce(
|
||||
Providers::CreateGitlabProvider,
|
||||
)
|
||||
elsif provider.bitbucket?
|
||||
with(provider:).reduce(
|
||||
Providers::CreateBitbucketProvider,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
47
app/actions/providers/create_bitbucket_provider.rb
Normal file
47
app/actions/providers/create_bitbucket_provider.rb
Normal 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
|
||||
32
app/controllers/inbound_webhooks/bitbucket_controller.rb
Normal file
32
app/controllers/inbound_webhooks/bitbucket_controller.rb
Normal 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
|
||||
59
app/jobs/inbound_webhooks/bitbucket_job.rb
Normal file
59
app/jobs/inbound_webhooks/bitbucket_job.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
167
app/services/git/bitbucket/client.rb
Normal file
167
app/services/git/bitbucket/client.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user