woodpecker integration

This commit is contained in:
2026-08-06 13:58:58 +02:00
parent 5f8502a2ed
commit 867f71f7c0
25 changed files with 1249 additions and 2 deletions
@@ -0,0 +1,38 @@
module WarpEngine
class CiPipelineService
def initialize(client: WoodpeckerClient.new)
@client = client
end
def dashboard
CiRepository.active.includes(:software).map do |repo|
pipeline = begin
@client.latest_pipeline(repo.repo_owner, repo.repo_name)
rescue WoodpeckerClient::ApiError
nil
end
if pipeline
repo.update_columns(
last_pipeline_status: pipeline["status"],
last_pipeline_at: pipeline["created_at"]
)
end
{ repo: repo, pipeline: pipeline }
end
end
def trigger(repo, branch: "main")
@client.trigger_pipeline(repo.repo_owner, repo.repo_name, branch: branch)
end
def list_pipelines(repo, page: 1)
@client.list_pipelines(repo.repo_owner, repo.repo_name, page: page)
end
def pipeline_detail(repo, number)
@client.get_pipeline(repo.repo_owner, repo.repo_name, number)
end
end
end
@@ -0,0 +1,73 @@
module WarpEngine
class CiRepoSyncService
def initialize(client: WoodpeckerClient.new)
@client = client
end
def sync_all
remote_repos = @client.list_repos
results = { created: [], updated: [], deactivated: [] }
remote_ids = remote_repos.map { |r| r["id"] }
remote_repos.each do |remote|
record = CiRepository.unscoped.find_or_initialize_by(
woodpecker_repo_id: remote["id"]
)
was_new = record.new_record?
record.assign_attributes(
repo_name: remote["name"],
repo_owner: remote["owner"],
active: remote["active"],
deleted_at: nil
)
if record.platform.blank? || record.platform == "unknown"
sw = Software.find_by(name: remote["name"])
record.platform = sw&.platform || "unknown"
record.software = sw if sw
end
next unless record.save
results[was_new ? :created : :updated] << record
end
CiRepository.where.not(woodpecker_repo_id: remote_ids).find_each do |orphan|
orphan.update!(active: false) if orphan.active?
results[:deactivated] << orphan
end
results
end
def activate(repo_id)
@client.activate_repo(repo_id)
sync_single(repo_id)
end
def deactivate(repo_id)
@client.deactivate_repo(repo_id)
record = CiRepository.find_by!(woodpecker_repo_id: repo_id)
record.update!(active: false)
end
private
def sync_single(repo_id)
remote = @client.get_repo(repo_id)
record = CiRepository.unscoped.find_or_initialize_by(woodpecker_repo_id: repo_id)
record.assign_attributes(
repo_name: remote["name"], repo_owner: remote["owner"],
active: remote["active"], deleted_at: nil
)
if record.platform.blank?
sw = Software.find_by(name: remote["name"])
record.platform = sw&.platform || "unknown"
record.software = sw if sw
end
record.save!
record
end
end
end
@@ -0,0 +1,80 @@
module WarpEngine
class CiSecretSyncService
SECRET_NAME = "application_token".freeze
def initialize(client: WoodpeckerClient.new)
@client = client
end
def provision(plain_token, repos:)
results = { synced: [], failed: [] }
repos.each do |repo|
if secret_exists?(repo.woodpecker_repo_id)
@client.update_secret(repo.woodpecker_repo_id, SECRET_NAME, value: plain_token)
else
@client.create_secret(repo.woodpecker_repo_id, name: SECRET_NAME, value: plain_token)
end
results[:synced] << repo
rescue WoodpeckerClient::ApiError, WoodpeckerClient::ConnectionError => e
Rails.logger.error("[CiSecretSyncService] failed for #{repo.full_name}: #{e.message}")
results[:failed] << { repo: repo, error: e.message }
end
results
end
def deprovision(application_token)
repos_for_token(application_token).each do |repo|
@client.delete_secret(repo.woodpecker_repo_id, SECRET_NAME)
rescue WoodpeckerClient::ApiError => e
Rails.logger.warn("[CiSecretSyncService] delete failed for #{repo.full_name}: #{e.message}")
end
end
def rotate(application_token)
repos = repos_for_token(application_token)
return { rotated: false, reason: "no repos" } if repos.empty?
new_token = ApplicationToken.create!(
name: "#{application_token.name} (rotated #{Date.current})",
owner_id: application_token.owner_id,
owner_type: application_token.owner_type,
scopes: application_token.scopes,
expires_at: application_token.expires_at,
unrestricted: application_token.unrestricted?
)
result = provision(new_token.plain_token, repos: repos)
if result[:synced].any?
application_token.revoke!
{ rotated: true, new_token: new_token, sync_result: result }
else
new_token.revoke!
{ rotated: false, reason: "all repos failed", sync_result: result }
end
end
def repos_for_token(application_token)
if application_token.unrestricted?
CiRepository.active.to_a
else
software_ids = Software.where(
owner_type: application_token.owner_type,
owner_id: application_token.owner_id
).pluck(:id)
CiRepository.active.where(software_id: software_ids).to_a
end
end
private
def secret_exists?(repo_id)
secrets = @client.list_secrets(repo_id)
secrets.any? { |s| s["name"] == SECRET_NAME }
rescue WoodpeckerClient::ApiError
false
end
end
end
@@ -0,0 +1,151 @@
require "net/http"
require "json"
require "uri"
module WarpEngine
class WoodpeckerClient
class ApiError < StandardError
attr_reader :status, :body
def initialize(message, status:, body: nil)
super(message)
@status = status
@body = body
end
end
class ConnectionError < StandardError; end
def initialize(base_url: nil, token: nil)
@base_url = (base_url || WarpEngine.config.woodpecker_url).to_s.chomp("/")
@token = token || WarpEngine.config.woodpecker_api_token
end
# --- Repos ---
def list_repos
get("/api/repos")
end
def get_repo(repo_id)
get("/api/repos/#{repo_id}")
end
def activate_repo(repo_id)
post("/api/repos", body: { id: repo_id })
end
def deactivate_repo(repo_id)
delete("/api/repos/#{repo_id}")
end
# --- Secrets ---
def list_secrets(repo_id)
get("/api/repos/#{repo_id}/secrets")
end
def create_secret(repo_id, name:, value:, events: %w[push tag deployment])
post("/api/repos/#{repo_id}/secrets",
body: { name: name, value: value, events: events })
end
def update_secret(repo_id, secret_name, value:)
patch("/api/repos/#{repo_id}/secrets/#{secret_name}",
body: { value: value })
end
def delete_secret(repo_id, secret_name)
delete("/api/repos/#{repo_id}/secrets/#{secret_name}")
end
# --- Pipelines ---
def list_pipelines(owner, repo_name, page: 1, per_page: 25)
get("/api/repos/#{owner}/#{repo_name}/pipelines",
params: { page: page, perPage: per_page })
end
def latest_pipeline(owner, repo_name)
get("/api/repos/#{owner}/#{repo_name}/pipelines/latest")
end
def get_pipeline(owner, repo_name, number)
get("/api/repos/#{owner}/#{repo_name}/pipelines/#{number}")
end
def trigger_pipeline(owner, repo_name, branch: "main")
post("/api/repos/#{owner}/#{repo_name}/pipelines",
body: { branch: branch })
end
private
def get(path, params: {})
uri = build_uri(path, params)
request = Net::HTTP::Get.new(uri)
execute(uri, request)
end
def post(path, body: {})
uri = build_uri(path)
request = Net::HTTP::Post.new(uri)
request.body = body.to_json
request.content_type = "application/json"
execute(uri, request)
end
def patch(path, body: {})
uri = build_uri(path)
request = Net::HTTP::Patch.new(uri)
request.body = body.to_json
request.content_type = "application/json"
execute(uri, request)
end
def delete(path)
uri = build_uri(path)
request = Net::HTTP::Delete.new(uri)
execute(uri, request)
end
def build_uri(path, params = {})
uri = URI.parse("#{@base_url}#{path}")
uri.query = URI.encode_www_form(params) if params.any?
uri
end
def execute(uri, request)
request["Authorization"] = "Bearer #{@token}"
request["Accept"] = "application/json"
response = Net::HTTP.start(uri.hostname, uri.port,
use_ssl: uri.scheme == "https",
open_timeout: 10,
read_timeout: 30) do |http|
http.request(request)
end
handle_response(uri, response)
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout,
Net::ReadTimeout, SocketError => e
raise ConnectionError, "Cannot reach Woodpecker at #{@base_url}: #{e.message}"
end
def handle_response(uri, response)
case response
when Net::HTTPSuccess, Net::HTTPNoContent
return nil if response.body.blank?
JSON.parse(response.body)
when Net::HTTPNotFound
raise ApiError.new("Not found: #{uri.path}", status: 404, body: response.body)
else
raise ApiError.new(
"Woodpecker API error #{response.code}: #{response.body&.truncate(200)}",
status: response.code.to_i,
body: response.body
)
end
end
end
end