woodpecker integration
This commit is contained in:
@@ -52,6 +52,25 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
f.actions
|
||||
end
|
||||
|
||||
action_item :rotate, only: :show do
|
||||
if WarpEngine.woodpecker_configured?
|
||||
link_to "Rotate Token", rotate_admin_application_token_path(resource),
|
||||
method: :post, data: { confirm: "This will revoke the current token, create a new one, and push it to Woodpecker. Continue?" }
|
||||
end
|
||||
end
|
||||
|
||||
member_action :rotate, method: :post do
|
||||
result = WarpEngine::CiSecretSyncService.new.rotate(resource)
|
||||
if result[:rotated]
|
||||
session[:warp_engine_plain_token] = result[:new_token].plain_token
|
||||
redirect_to resource_path(result[:new_token]),
|
||||
notice: "Token rotated and synced to #{result.dig(:sync_result, :synced)&.size || 0} repo(s)"
|
||||
else
|
||||
redirect_to resource_path(resource),
|
||||
alert: "Rotation failed: #{result[:reason]}"
|
||||
end
|
||||
end
|
||||
|
||||
show do
|
||||
if (plain = controller.instance_variable_get(:@plain_token))
|
||||
panel "⚠️ Token — shown only once, copy it now!" do
|
||||
@@ -80,6 +99,19 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
create! do |success, _failure|
|
||||
success.html do
|
||||
session[:warp_engine_plain_token] = resource.plain_token
|
||||
|
||||
if WarpEngine.woodpecker_configured?
|
||||
service = WarpEngine::CiSecretSyncService.new
|
||||
repos = service.repos_for_token(resource)
|
||||
if repos.any?
|
||||
result = service.provision(resource.plain_token, repos: repos)
|
||||
flash[:notice] = "Token created and synced to #{result[:synced].size} repo(s)."
|
||||
if result[:failed].any?
|
||||
flash[:alert] = "Failed to sync to #{result[:failed].size} repo(s)."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
redirect_to resource_path(resource) and return
|
||||
end
|
||||
end
|
||||
@@ -90,10 +122,12 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
show!
|
||||
end
|
||||
|
||||
# Revoke = soft delete, audit-nyommal.
|
||||
def destroy
|
||||
if WarpEngine.woodpecker_configured?
|
||||
WarpEngine::CiSecretSyncService.new.deprovision(resource)
|
||||
end
|
||||
resource.revoke!
|
||||
redirect_to collection_path, notice: "Token revoked."
|
||||
redirect_to collection_path, notice: "Token revoked and Woodpecker secrets cleaned up."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
ActiveAdmin.register_page "CI Dashboard" do
|
||||
menu priority: 11, label: "🚀 CI Dashboard"
|
||||
|
||||
content do
|
||||
if WarpEngine.woodpecker_configured?
|
||||
begin
|
||||
entries = WarpEngine::CiPipelineService.new.dashboard
|
||||
rescue => e
|
||||
entries = []
|
||||
div class: "flash flash_alert" do
|
||||
"Error connecting to Woodpecker: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
entries.group_by { |e| e[:repo].platform }.sort.each do |platform, group|
|
||||
panel platform.titleize do
|
||||
table_for group do
|
||||
column("Repository") { |e| link_to e[:repo].full_name, admin_ci_repository_path(e[:repo]) }
|
||||
column("Software") { |e| e[:repo].software ? link_to(e[:repo].software.title, admin_software_path(e[:repo].software)) : "-" }
|
||||
column("Status") { |e|
|
||||
if e[:pipeline]
|
||||
status_tag e[:pipeline]["status"],
|
||||
class: e[:pipeline]["status"] == "success" ? "ok" : "error"
|
||||
else
|
||||
status_tag "unknown", class: "warning"
|
||||
end
|
||||
}
|
||||
column("Branch") { |e| e.dig(:pipeline, "branch") || "-" }
|
||||
column("When") { |e| e.dig(:pipeline, "created_at") || "-" }
|
||||
column("Actions") { |e|
|
||||
if e[:repo].active
|
||||
text_node link_to("Trigger", trigger_admin_ci_repository_path(e[:repo]),
|
||||
method: :post, class: "member_link")
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if entries.empty?
|
||||
para "No CI repositories tracked. Sync repos from the CI Repos page.",
|
||||
style: "color:#999;text-align:center;padding:40px 0;"
|
||||
end
|
||||
else
|
||||
para "Woodpecker is not configured. Set woodpecker_url and woodpecker_api_token in the WarpEngine initializer.",
|
||||
style: "color:#999;text-align:center;padding:40px 0;"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,118 @@
|
||||
ActiveAdmin.register WarpEngine::CiRepository, as: "CI Repository" do
|
||||
actions :index, :show, :edit, :update
|
||||
|
||||
menu priority: 10, label: "🔧 CI Repos"
|
||||
|
||||
config.sort_order = "repo_name_asc"
|
||||
config.batch_actions = false
|
||||
|
||||
scope :all, default: true
|
||||
scope("Active") { |s| s.where(active: true) }
|
||||
scope("Inactive") { |s| s.where(active: false) }
|
||||
|
||||
WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each do |p|
|
||||
scope(p.capitalize) { |s| s.where(platform: p) }
|
||||
end
|
||||
|
||||
index do
|
||||
id_column
|
||||
column :repo_owner
|
||||
column :repo_name
|
||||
column :platform
|
||||
column("Software") { |r| r.software ? link_to(r.software.title, admin_software_path(r.software)) : "-" }
|
||||
column(:active) { |r| status_tag(r.active ? "active" : "inactive", class: r.active ? "ok" : "warning") }
|
||||
column("Pipeline") { |r|
|
||||
if r.last_pipeline_status
|
||||
status_tag r.last_pipeline_status,
|
||||
class: r.last_pipeline_status == "success" ? "ok" : "error"
|
||||
else
|
||||
"-"
|
||||
end
|
||||
}
|
||||
column :last_pipeline_at
|
||||
actions defaults: true do |repo|
|
||||
if repo.active && WarpEngine.woodpecker_configured?
|
||||
item "Trigger", trigger_admin_ci_repository_path(repo), method: :post, class: "member_link"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
filter :repo_name
|
||||
filter :platform, as: :select, collection: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS
|
||||
filter :active
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :platform, as: :select, collection: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS
|
||||
f.input :software_id, as: :select,
|
||||
collection: WarpEngine::Software.order(:title).map { |s| [ s.title, s.id ] },
|
||||
include_blank: "- none -"
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :id
|
||||
row :woodpecker_repo_id
|
||||
row :repo_owner
|
||||
row :repo_name
|
||||
row :platform
|
||||
row("Software") { |r| r.software ? link_to(r.software.title, admin_software_path(r.software)) : "-" }
|
||||
row(:active) { |r| status_tag(r.active ? "active" : "inactive") }
|
||||
row :last_pipeline_status
|
||||
row :last_pipeline_at
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
|
||||
if WarpEngine.woodpecker_configured? && resource.active
|
||||
panel "Recent Pipelines" do
|
||||
begin
|
||||
pipelines = WarpEngine::CiPipelineService.new.list_pipelines(resource, page: 1)
|
||||
if pipelines.is_a?(Array) && pipelines.any?
|
||||
table_for pipelines.first(10) do
|
||||
column("Number") { |p| p["number"] }
|
||||
column("Status") { |p| status_tag p["status"], class: p["status"] == "success" ? "ok" : "error" }
|
||||
column("Branch") { |p| p["branch"] }
|
||||
column("Message") { |p| p["message"]&.truncate(60) }
|
||||
column("Created") { |p| p["created_at"] }
|
||||
end
|
||||
else
|
||||
para "No pipelines found.", style: "color:#999;"
|
||||
end
|
||||
rescue => e
|
||||
para "Error fetching pipelines: #{e.message}", style: "color:red;"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
member_action :trigger, method: :post do
|
||||
repo = WarpEngine::CiRepository.find(params[:id])
|
||||
WarpEngine::CiPipelineService.new.trigger(repo)
|
||||
redirect_to resource_path(repo), notice: "Pipeline triggered for #{repo.full_name}"
|
||||
rescue => e
|
||||
redirect_to resource_path(repo), alert: "Trigger failed: #{e.message}"
|
||||
end
|
||||
|
||||
collection_action :sync, method: :post do
|
||||
result = WarpEngine::CiRepoSyncService.new.sync_all
|
||||
redirect_to collection_path,
|
||||
notice: "Synced: #{result[:created].size} new, #{result[:updated].size} updated, #{result[:deactivated].size} deactivated"
|
||||
rescue => e
|
||||
redirect_to collection_path, alert: "Sync failed: #{e.message}"
|
||||
end
|
||||
|
||||
action_item :sync_repos, only: :index do
|
||||
if WarpEngine.woodpecker_configured?
|
||||
link_to "Sync from Woodpecker", sync_admin_ci_repositories_path, method: :post
|
||||
end
|
||||
end
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.includes(:software)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,74 @@
|
||||
module WarpEngine
|
||||
module Api
|
||||
class CiController < ApiController
|
||||
include UpdateAuthentication
|
||||
|
||||
before_action :require_woodpecker!
|
||||
|
||||
resource_description do
|
||||
short "CI repository and pipeline management"
|
||||
end
|
||||
|
||||
api :GET, "/api/ci/repos", "List active CI repositories"
|
||||
returns code: 200, desc: "JSON array of tracked repos"
|
||||
error code: 503, desc: "Woodpecker not configured"
|
||||
def repos
|
||||
records = CiRepository.active.includes(:software)
|
||||
render json: records.map { |r| repo_json(r) }
|
||||
end
|
||||
|
||||
api :GET, "/api/ci/repos/:id/status", "Get a CI repository with its latest pipeline"
|
||||
param :id, :number, required: true, desc: "CiRepository id"
|
||||
returns code: 200, desc: "JSON with repo and pipeline data"
|
||||
error code: 503, desc: "Woodpecker not configured"
|
||||
def status
|
||||
repo = CiRepository.find(params[:id])
|
||||
pipeline = begin
|
||||
CiPipelineService.new.pipeline_detail(repo, "latest")
|
||||
rescue WoodpeckerClient::ApiError
|
||||
nil
|
||||
end
|
||||
render json: { repo: repo_json(repo), pipeline: pipeline }
|
||||
end
|
||||
|
||||
api :POST, "/api/ci/repos/:id/trigger", "Trigger a pipeline for a CI repository"
|
||||
header "X-Update-Secret", "Shared secret or application token (update scope)", required: true
|
||||
param :id, :number, required: true, desc: "CiRepository id"
|
||||
param :branch, String, required: false, desc: "Branch to build (default: main)"
|
||||
returns code: 200, desc: "JSON with triggered pipeline data"
|
||||
error code: 401, desc: "Invalid secret"
|
||||
error code: 503, desc: "Woodpecker not configured"
|
||||
def trigger
|
||||
unless update_authorized?(required_scope: ApplicationToken::UPDATE_SCOPE)
|
||||
return render json: { error: "Unauthorized" }, status: :unauthorized
|
||||
end
|
||||
|
||||
repo = CiRepository.find(params[:id])
|
||||
result = CiPipelineService.new.trigger(repo, branch: params[:branch] || "main")
|
||||
render json: { triggered: true, pipeline: result }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_woodpecker!
|
||||
return if WarpEngine.woodpecker_configured?
|
||||
|
||||
render json: { error: "Woodpecker not configured" }, status: :service_unavailable
|
||||
end
|
||||
|
||||
def repo_json(repo)
|
||||
{
|
||||
id: repo.id,
|
||||
woodpecker_repo_id: repo.woodpecker_repo_id,
|
||||
repo_owner: repo.repo_owner,
|
||||
repo_name: repo.repo_name,
|
||||
platform: repo.platform,
|
||||
active: repo.active,
|
||||
software_name: repo.software&.name,
|
||||
last_pipeline_status: repo.last_pipeline_status,
|
||||
last_pipeline_at: repo.last_pipeline_at
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
module WarpEngine
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
retry_on WoodpeckerClient::ConnectionError, wait: 30.seconds, attempts: 3
|
||||
discard_on WoodpeckerClient::ApiError do |job, error|
|
||||
Rails.logger.error("[#{job.class.name}] discarded: #{error.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
module WarpEngine
|
||||
class CiRepoSyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
CiRepoSyncService.new.sync_all
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
module WarpEngine
|
||||
class CiRepository < ApplicationRecord
|
||||
self.table_name = "ci_repositories"
|
||||
|
||||
belongs_to :software, class_name: "WarpEngine::Software", optional: true
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
validates :woodpecker_repo_id, presence: true, uniqueness: true
|
||||
validates :repo_owner, presence: true
|
||||
validates :repo_name, presence: true
|
||||
validates :platform, presence: true,
|
||||
inclusion: { in: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS }
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
def full_name
|
||||
"#{repo_owner}/#{repo_name}"
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[active created_at deleted_at id last_pipeline_at last_pipeline_status
|
||||
platform repo_name repo_owner software_id woodpecker_repo_id]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[software]
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_ci_repository, self)
|
||||
end
|
||||
end
|
||||
@@ -11,6 +11,7 @@ module WarpEngine
|
||||
has_many :releases, foreign_key: :software_id
|
||||
has_many :downloads, through: :releases
|
||||
has_many :external_links, foreign_key: :software_id
|
||||
has_one :ci_repository, foreign_key: :software_id
|
||||
|
||||
accepts_nested_attributes_for :software_images, allow_destroy: true
|
||||
accepts_nested_attributes_for :external_links, allow_destroy: true
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user