added generic git client rewrite to support both github and gitlab

This commit is contained in:
Chris
2025-06-20 17:31:24 -07:00
parent 4c357d64a7
commit 4d2de77ae5
22 changed files with 231 additions and 33 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.vscode/
coverage/
workdir/
.DS_Store

View File

@@ -43,15 +43,15 @@ module Projects
def self.create_steps(provider)
steps = []
if provider.github?
steps << Projects::ValidateGithubRepository
if provider.git?
steps << Projects::ValidateGitRepository
end
steps << Projects::Save
# Only register webhook in non-local mode
if !Rails.application.config.local_mode && provider.github?
steps << Projects::RegisterGithubWebhook
if !Rails.application.config.local_mode && provider.git?
steps << Projects::RegisterGitWebhook
end
steps
end

View File

@@ -12,8 +12,8 @@ class Projects::DeployLatestCommit
current_user = context.current_user || project.account.owner
if project.github?
project_credential_provider = project.project_credential_provider
client = Github::Client.from_project(project)
commit = client.commits.first
client = Git::Client.from_project(project)
commit = client.commits(project.branch).first
build = project.builds.create!(
commit_sha: commit.sha,
commit_message: commit.commit[:message],

View File

@@ -0,0 +1,12 @@
class Projects::RegisterGitWebhook
extend LightService::Action
expects :project
executed do |context|
client = Git::Client.from_project(context.project)
client.register_webhook!
rescue StandardError => e
context.project.errors.add(:repository_url, "Failed to create webhook: #{e.message}")
context.fail_and_return!("Failed to create webhook: #{e.message}")
end
end

View File

@@ -1,12 +0,0 @@
class Projects::RegisterGithubWebhook
extend LightService::Action
expects :project
executed do |context|
client = Github::Client.from_project(context.project)
client.register_webhook!
rescue Octokit::UnprocessableEntity => e
next context if e.message.include?("Hook already exists")
context.fail_and_return!("Failed to create webhook")
end
end

View File

@@ -1,12 +1,12 @@
class Projects::ValidateGithubRepository
class Projects::ValidateGitRepository
extend LightService::Action
expects :project, :project_credential_provider
executed do |context|
# The project is not created yet, so we can't call Github::Client.from_project
client = Github::Client.new(
access_token: context.project_credential_provider.access_token,
client = Git::Client.from_provider(
provider: context.project_credential_provider.provider,
repository_url: context.project.repository_url
)
unless client.repository_exists?

View File

@@ -0,0 +1,25 @@
module InboundWebhooks
class GithubController < ApplicationController
before_action :verify_event
def create
# Save webhook to database
record = InboundWebhook.create(body: payload)
# Queue webhook for processing
InboundWebhooks::GithubJob.perform_later(record, current_user:)
# Tell service we received the webhook successfully
head :ok
end
private
def verify_event
payload = request.body.read
# TODO: Verify the event was sent from the service
# Render `head :bad_request` if verification fails
secret = Gitlab::Client::GITLAB_WEBHOOK_SECRET
end
end
end

View File

@@ -37,7 +37,7 @@ class ProjectsController < ApplicationController
# GET /projects/1/edit
def edit
@client = Github::Client.from_project(@project)
@client = Git::Client.from_project(@project)
end
# POST /projects or /projects.json

View File

@@ -0,0 +1,39 @@
module InboundWebhooks
class GitlabJob < 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:)
projects = Project.where(
"LOWER(repository_url) = ?",
body["repository"]["full_name"].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: body["head_commit"]["id"],
commit_message: body["head_commit"]["message"]
)
Projects::BuildJob.perform_later(build)
end
end
end
end

View File

@@ -21,7 +21,7 @@ class Projects::DestroyJob < ApplicationJob
end
def remove_github_webhook(project)
client = Github::Client.from_project(project)
client = Git::Client.from_project(project)
client.remove_webhook!
rescue Octokit::NotFound
# If the hook is not found, do nothing

View File

@@ -63,7 +63,7 @@ class Project < ApplicationRecord
deployed: 1,
destroying: 2
}
delegate :github?, :gitlab?, to: :project_credential_provider
delegate :git?, :github?, :gitlab?, to: :project_credential_provider
delegate :docker_hub?, to: :project_credential_provider
def name_is_unique_to_cluster

View File

@@ -30,6 +30,7 @@ class ProjectCredentialProvider < ApplicationRecord
delegate :github?, to: :provider
delegate :docker_hub?, to: :provider
delegate :gitlab?, to: :provider
delegate :git?, to: :provider
def github_username
JSON.parse(provider.auth)["info"]["nickname"]

View File

@@ -51,6 +51,10 @@ class Provider < ApplicationRecord
JSON.parse(auth)["info"]["nickname"] || JSON.parse(auth)["info"]["username"]
end
def git?
github? || gitlab?
end
def registry
if github?
"ghcr.io"

View File

@@ -0,0 +1,21 @@
class Git::Client
def self.from_provider(provider:, repository_url:)
if provider.github?
Git::Github::Client.new(access_token: provider.access_token, repository_url:)
elsif provider.gitlab?
Git::Gitlab::Client.new(access_token:provider.access_token, repository_url:)
else
raise "Unsupported Git provider: #{provider}"
end
end
def self.from_project(project)
if project.project_credential_provider.provider.github?
Git::Github::Client.from_project(project)
elsif project.project_credential_provider.provider.gitlab?
Git::Gitlab::Client.from_project(project)
else
raise "Unsupported Git provider: #{project.project_credential_provider.provider}"
end
end
end

View File

@@ -1,4 +1,4 @@
class Github::Client
class Git::Github::Client < Git::Client
WEBHOOK_SECRET = ENV["OMNIAUTH_GITHUB_WEBHOOK_SECRET"]
attr_accessor :client, :repository_url
@@ -10,8 +10,8 @@ class Github::Client
)
end
def commits
client.commits(repository_url)
def commits(branch)
client.commits(repository_url, branch)
end
def initialize(access_token:, repository_url:)
@@ -31,6 +31,10 @@ class Github::Client
end
def register_webhook!
if webhook_exists?
return
end
client.create_hook(
repository_url,
"web",

View File

@@ -0,0 +1,96 @@
class Git::Gitlab::Client < Git::Client
GITLAB_API_BASE = "https://gitlab.com/api/v4"
GITLAB_WEBHOOK_SECRET = ENV["GITLAB_WEBHOOK_SECRET"]
attr_accessor :access_token, :repository_url
def self.from_project(project)
raise "Project is not a GitLab project" unless project.project_credential_provider.provider.gitlab?
new(
access_token: project.project_credential_provider.access_token,
repository_url: project.repository_url
)
end
def initialize(access_token:, repository_url:)
@access_token = access_token
@repository_url = repository_url
end
def repository_exists?
repository.present?
end
def commits(branch)
HTTParty.get(
"#{GITLAB_API_BASE}/projects/#{encoded_url}/repository/commits?ref=#{branch}",
headers: { "Authorization" => "Bearer #{access_token}" }
)
end
def can_write_webhooks?
true
end
def register_webhook!
if webhook_exists?
return
end
response = HTTParty.post(
"#{GITLAB_API_BASE}/projects/#{encoded_url}/hooks",
headers: { "Authorization" => "Bearer #{access_token}", "Content-Type" => "application/json" },
body: {
url: Rails.application.routes.url_helpers.inbound_webhooks_gitlab_index_url,
name: "canine-webhook",
push_events: true,
enable_ssl_verification: true,
token: GITLAB_WEBHOOK_SECRET
}.to_json
)
unless response.success?
raise "Failed to register webhook: #{response.body}"
end
response.parsed_response
end
def webhooks
response = HTTParty.get(
"#{GITLAB_API_BASE}/projects/#{encoded_url}/hooks",
headers: { "Authorization" => "Bearer #{access_token}" },
format: :json
)
end
def encoded_url
URI.encode_www_form_component(repository_url)
end
def repository
@repository ||= begin
project_response = HTTParty.get(
"#{GITLAB_API_BASE}/projects/#{encoded_url}",
headers: { "Authorization" => "Bearer #{access_token}" }
)
end
end
def access_token
@access_token
end
def webhook_exists?
webhook.present?
end
def webhook
webhooks.find { |h| h['url'].include?(Rails.application.routes.url_helpers.inbound_webhooks_gitlab_index_path) }
end
def remove_webhook!
if webhook_exists?
HTTParty.delete(
"#{GITLAB_API_BASE}/projects/#{encoded_url}/hooks/#{webhook['id']}",
headers: { "Authorization" => "Bearer #{access_token}" }
)
end
end
end

View File

@@ -2,7 +2,7 @@ class Async::Github::WebhookStatusViewModel < Async::BaseViewModel
expects :project_id
def client
@client ||= Github::Client.from_project(project)
@client ||= Git::Client.from_project(project)
end
def project

View File

@@ -1,5 +1,5 @@
<% if project.docker_hub? %>
<%= render "projects/update/edit_form_docker_hub", project: %>
<% else %>
<%= render "projects/update/edit_form_github", project: %>
<%= render "projects/update/edit_form_git", project: %>
<% end %>

View File

@@ -16,8 +16,12 @@
<% end %>
<div class="text-sm">
<%= link_to project.full_repository_url, target: "_blank" do %>
<% if project.github? %>
<iconify-icon icon="lucide:github"></iconify-icon>
<% if project.git? %>
<% if project.github? %>
<iconify-icon icon="lucide:github"></iconify-icon>
<% elsif project.gitlab? %>
<iconify-icon icon="lucide:gitlab"></iconify-icon>
<% end %>
<span class="underline mr-2"><%= project.repository_url %></span>
<iconify-icon icon="lucide:git-branch"></iconify-icon>
<span class="underline"><%= project.branch %></span>

View File

@@ -72,7 +72,7 @@
</label>
<%= form.text_field :container_registry_url, class: "input input-bordered w-full focus:outline-offset-0", value: form.object.attributes["container_registry_url"] %>
<label class="label">
<span class="label-text-alt">If this is left blank, Github Container Registry will be used</span>
<span class="label-text-alt">If this is left blank, <%= project.github? ? "Github" : "Gitlab" %> Container Registry will be used</span>
</label>
</div>

View File

@@ -2,6 +2,8 @@
<div class="flex items-start gap-2">
<% if provider.github? %>
<iconify-icon icon="mdi:github" width="24" height="24"></iconify-icon>
<% elsif provider.gitlab? %>
<iconify-icon icon="mdi:gitlab" width="24" height="24"></iconify-icon>
<% else %>
<iconify-icon icon="mdi:docker" width="24" height="24"></iconify-icon>
<% end %>
@@ -9,7 +11,7 @@
<span class="text-sm">
Connected as <b><%= provider.username %></b>
</span>
<% if defined?(@project) && @project.github? %>
<% if defined?(@project) && @project.git? %>
<div class="mt-2">
<%= render(
"shared/partials/async_renderer",

View File

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