Every CI action is a CRUD action, and the URLs do not move

`CiController` had three actions named after verbs — `pipelines`, `status`,
`trigger` — and built its JSON by hand in a private `pipeline_json`, in a
codebase where everything else is serialized by Blueprinter. Triggering a
build is the creation of a run, so it is a resource of its own:

    GET  /api/ci/pipelines             -> Api::Ci::PipelinesController#index
    GET  /api/ci/pipelines/:id/status  -> Api::Ci::PipelinesController#show
    POST /api/ci/pipelines/:id/trigger -> Api::Ci::PipelineRunsController#create

The addresses are untouched on purpose: they are what the CI and the scripts
already call, and renaming them would be a breaking change for a version that
does not need one. What changed is the shape behind them — three standard
actions across two controllers, `require_ci!` and the pipeline scope in a
shared base, and a `PipelineSerializer` that carries the fields the endpoint
already answered with, `repo_id` and `woodpecker_repo_id` included (the
duplicate stays: a client may read either, and ci_controller_spec asserts they
agree).

`/api/software/highlighted` answers with `#show` now rather than `#index`: it
returns one title, and the action name should say so. Same address, same body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 09:01:34 +02:00
co-authored by Claude Opus 5
parent 7c9f2988be
commit 6024ef71c3
7 changed files with 112 additions and 82 deletions
@@ -0,0 +1,19 @@
module WarpEngine
module Api
module Ci
class BaseController < WarpEngine::ApiController
before_action :require_ci!
private
def require_ci!
return if WarpEngine.ci.configured?
render json: { error: "CI not configured" }, status: :service_unavailable
end
def pipeline_scope = WarpEngine::Pipeline.all
end
end
end
end
@@ -0,0 +1,31 @@
module WarpEngine
module Api
module Ci
class PipelineRunsController < BaseController
include WarpEngine::UpdateAuthentication
resource_description do
short "CI pipeline runs"
end
api :POST, "/api/ci/pipelines/:id/trigger", "Trigger a pipeline"
header "X-Update-Secret", "Shared secret or application token (update scope)", required: true
param :id, :number, required: true, desc: "Pipeline id"
param :branch, String, required: false, desc: "Branch to build (default: main)"
returns code: 200, desc: "JSON with triggered run data"
error code: 401, desc: "Invalid secret"
error code: 503, desc: "No CI provider configured"
def create
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPDATE_SCOPE)
return render json: { error: "Unauthorized" }, status: :unauthorized
end
pipeline = pipeline_scope.find(params[:id])
result = WarpEngine::PipelineService.new.trigger(pipeline, branch: params[:branch].presence || "main")
render json: { triggered: true, pipeline: result }
end
end
end
end
end
@@ -0,0 +1,40 @@
module WarpEngine
module Api
module Ci
class PipelinesController < BaseController
resource_description do
short "CI pipelines"
end
api :GET, "/api/ci/pipelines", "List active pipelines"
returns code: 200, desc: "JSON array of tracked pipelines"
error code: 503, desc: "No CI provider configured"
def index
pipelines = pipeline_scope.active.includes(:software)
render json: WarpEngine::PipelineSerializer.render_as_hash(pipelines)
end
api :GET, "/api/ci/pipelines/:id/status", "Get a pipeline with its latest run"
param :id, :number, required: true, desc: "Pipeline id"
returns code: 200, desc: "JSON with pipeline and latest run data"
error code: 503, desc: "No CI provider configured"
def show
pipeline = pipeline_scope.find(params[:id])
render json: {
pipeline: WarpEngine::PipelineSerializer.render_as_hash(pipeline),
latest_run: latest_run(pipeline)
}
end
private
def latest_run(pipeline)
WarpEngine::PipelineService.new.run(pipeline, "latest")
rescue WarpEngine::CI::Error
nil
end
end
end
end
end
@@ -1,75 +0,0 @@
module WarpEngine
module Api
class CiController < ApiController
include UpdateAuthentication
before_action :require_ci!
resource_description do
short "CI pipeline management"
end
api :GET, "/api/ci/pipelines", "List active pipelines"
returns code: 200, desc: "JSON array of tracked pipelines"
error code: 503, desc: "No CI provider configured"
def pipelines
records = Pipeline.active.includes(:software)
render json: records.map { |p| pipeline_json(p) }
end
api :GET, "/api/ci/pipelines/:id/status", "Get a pipeline with its latest run"
param :id, :number, required: true, desc: "Pipeline id"
returns code: 200, desc: "JSON with pipeline and latest run data"
error code: 503, desc: "No CI provider configured"
def status
pipeline = Pipeline.find(params[:id])
latest_run = begin
PipelineService.new.run(pipeline, "latest")
rescue CI::Error
nil
end
render json: { pipeline: pipeline_json(pipeline), latest_run: latest_run }
end
api :POST, "/api/ci/pipelines/:id/trigger", "Trigger a pipeline"
header "X-Update-Secret", "Shared secret or application token (update scope)", required: true
param :id, :number, required: true, desc: "Pipeline id"
param :branch, String, required: false, desc: "Branch to build (default: main)"
returns code: 200, desc: "JSON with triggered run data"
error code: 401, desc: "Invalid secret"
error code: 503, desc: "No CI provider configured"
def trigger
unless update_authorized?(required_scope: ApplicationToken::UPDATE_SCOPE)
return render json: { error: "Unauthorized" }, status: :unauthorized
end
pipeline = Pipeline.find(params[:id])
result = PipelineService.new.trigger(pipeline, branch: params[:branch] || "main")
render json: { triggered: true, pipeline: result }
end
private
def require_ci!
return if WarpEngine.ci.configured?
render json: { error: "CI not configured" }, status: :service_unavailable
end
def pipeline_json(pipeline)
{
id: pipeline.id,
repo_id: pipeline.remote_repo_id,
woodpecker_repo_id: pipeline.remote_repo_id,
repo_owner: pipeline.repo_owner,
repo_name: pipeline.repo_name,
platform: pipeline.platform,
active: pipeline.active,
software_name: pipeline.software&.name,
last_pipeline_status: pipeline.last_pipeline_status,
last_pipeline_at: pipeline.last_pipeline_at
}
end
end
end
end
@@ -14,8 +14,8 @@ module WarpEngine
property :desc, String, desc: "Short description"
property :story, String, desc: "Long description / story"
property :license, String, desc: "License type"
property :platform, String, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
property :status, String, desc: "Status"
property :platform, String, desc: "Platform (one of WarpEngine::Platform::NAMES)"
property :status, String, desc: "Status (development, demo, released, archived)"
property :highlighted, :boolean, desc: "Highlighted flag"
property :imageUrl, String, desc: "Default image URL"
end
@@ -33,7 +33,7 @@ module WarpEngine
property :totalDownloads, Integer, desc: "Total download count across all releases"
end
error code: 404, desc: "No highlighted software found"
def index
def show
result = WarpEngine::SoftwareHighlightedService.new.index(subject: current_subject)
if result
render json: result