From 161f923dc848181de16fd7053a8f4782971b9517 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Thu, 6 Aug 2026 19:23:04 +0200 Subject: [PATCH] fixes --- README.md | 13 +++-- app/admin/ci_dashboard.rb | 49 ---------------- app/admin/ci_repositories.rb | 7 ++- app/admin/softwares.rb | 9 +++ .../warp_engine/ci_pipeline_service.rb | 37 ++++++------ spec/dummy/db/schema.rb | 21 ++++++- spec/services/ci_pipeline_service_spec.rb | 58 ++++++++++--------- 7 files changed, 87 insertions(+), 107 deletions(-) delete mode 100644 app/admin/ci_dashboard.rb diff --git a/README.md b/README.md index 9a3ebb3..8931e23 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ Repository: `https://git.teletypegames.org/tools/warp_engine` download statistics. Without ActiveAdmin the engine runs headless (API + updater only). - **Woodpecker CI management (optional)**: with a Woodpecker API token - configured, the admin also gains repo sync, a pipeline dashboard with - manual triggers, and automatic provisioning of application tokens as - Woodpecker secrets. + configured, the admin also gains repo sync, per-repo pipeline history + with manual triggers, and automatic provisioning of application tokens + as Woodpecker secrets. ## Requirements @@ -308,9 +308,10 @@ What it unlocks (all surfaced in the admin): repo list into `CiRepository` records, auto-matching each repo to a catalog `Software` by name; repos that disappear from Woodpecker are deactivated. Platform and software links are editable by hand afterwards. -- **CI dashboard**: the latest pipeline status of every active repo, grouped - by platform, with a manual *Trigger* action; each repo's page lists its - recent pipelines. +- **Pipeline history**: each CI repo's page lists its recent pipelines with + a manual *Trigger* action; the newest run refreshes the repo's cached + last-pipeline status shown on the CI Repos index. The software's admin + page links to its CI repo from the Quick Links sidebar. - **Secret provisioning**: database application tokens are pushed to the repos as the `application_token` Woodpecker secret — creating a token provisions it to its owner's repos (unrestricted tokens to all active diff --git a/app/admin/ci_dashboard.rb b/app/admin/ci_dashboard.rb deleted file mode 100644 index 1166337..0000000 --- a/app/admin/ci_dashboard.rb +++ /dev/null @@ -1,49 +0,0 @@ -ActiveAdmin.register_page "CI Dashboard" do - menu parent: "🌀 WarpEngine", 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 diff --git a/app/admin/ci_repositories.rb b/app/admin/ci_repositories.rb index 98bbfa0..15197ba 100644 --- a/app/admin/ci_repositories.rb +++ b/app/admin/ci_repositories.rb @@ -24,7 +24,7 @@ ActiveAdmin.register WarpEngine::CiRepository, as: "CI Repository" do column("Pipeline") { |r| if r.last_pipeline_status status_tag r.last_pipeline_status, - class: r.last_pipeline_status == "success" ? "ok" : "error" + class: r.last_pipeline_status == "success" ? "yes" : "no" else "-" end @@ -73,10 +73,11 @@ ActiveAdmin.register WarpEngine::CiRepository, as: "CI Repository" do 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("Status") { |p| status_tag p["status"], class: p["status"] == "success" ? "yes" : "no" } column("Branch") { |p| p["branch"] } column("Message") { |p| p["message"]&.truncate(60) } - column("Created") { |p| p["created_at"] } + # Woodpecker returns unix epoch seconds in "created" + column("Created") { |p| p["created"] ? Time.zone.at(p["created"]).strftime("%Y-%m-%d %H:%M") : "-" } end else para "No pipelines found.", style: "color:#999;" diff --git a/app/admin/softwares.rb b/app/admin/softwares.rb index c86065c..c1ab2c3 100644 --- a/app/admin/softwares.rb +++ b/app/admin/softwares.rb @@ -52,6 +52,15 @@ ActiveAdmin.register WarpEngine::Software, as: "Software" do text_node "View on site" end end + + if resource.ci_repository + div style: "margin-bottom:8px;" do + a href: admin_ci_repository_path(resource.ci_repository), style: "display:inline-flex;align-items:center;gap:6px;font-weight:bold;color:#5850ec;" do + span "🔧", style: "font-size:16px;" + text_node "CI Repository" + end + end + end end sidebar "Download Statistics", only: :show do diff --git a/app/services/warp_engine/ci_pipeline_service.rb b/app/services/warp_engine/ci_pipeline_service.rb index 9dc6684..25b2064 100644 --- a/app/services/warp_engine/ci_pipeline_service.rb +++ b/app/services/warp_engine/ci_pipeline_service.rb @@ -4,35 +4,32 @@ module WarpEngine @client = client end - def dashboard - CiRepository.active.includes(:software).map do |repo| - pipeline = begin - @client.latest_pipeline(repo.woodpecker_repo_id) - 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.woodpecker_repo_id, branch: branch) end + # Fetches one page of a repo's pipelines; the newest one refreshes the + # repo's cached last_pipeline_* columns (CI Repos index and the CI API). def list_pipelines(repo, page: 1) - @client.list_pipelines(repo.woodpecker_repo_id, page: page) + pipelines = @client.list_pipelines(repo.woodpecker_repo_id, page: page) + refresh_last_pipeline(repo, pipelines.first) if page == 1 && pipelines.is_a?(Array) + pipelines end def pipeline_detail(repo, number) @client.get_pipeline(repo.woodpecker_repo_id, number) end + + private + + # Woodpecker returns unix epoch seconds in "created". + def refresh_last_pipeline(repo, pipeline) + return unless pipeline && repo.persisted? + + repo.update_columns( + last_pipeline_status: pipeline["status"], + last_pipeline_at: pipeline["created"] ? Time.zone.at(pipeline["created"]) : nil + ) + end end end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 70ebb16..bf9e111 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do +ActiveRecord::Schema[8.1].define(version: 2026_08_06_000001) do create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", precision: 3 t.datetime "deleted_at", precision: 3 @@ -29,6 +29,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true end + create_table "ci_repositories", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.boolean "active", default: true, null: false + t.datetime "created_at", precision: 3 + t.datetime "deleted_at", precision: 3 + t.datetime "last_pipeline_at", precision: 3 + t.string "last_pipeline_status", limit: 32 + t.string "platform", limit: 32, null: false + t.string "repo_name", limit: 128, null: false + t.string "repo_owner", limit: 128, null: false + t.bigint "software_id", unsigned: true + t.datetime "updated_at", precision: 3 + t.bigint "woodpecker_repo_id", null: false, unsigned: true + t.index ["deleted_at"], name: "idx_ci_repos_deleted" + t.index ["repo_owner", "repo_name"], name: "idx_ci_repos_owner_name", unique: true + t.index ["software_id"], name: "idx_ci_repos_software" + t.index ["woodpecker_repo_id"], name: "idx_ci_repos_wp_id", unique: true + end + create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", precision: 3 t.datetime "deleted_at", precision: 3 @@ -137,6 +155,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do t.datetime "updated_at", precision: 3 end + add_foreign_key "ci_repositories", "softwares", name: "fk_ci_repos_software", on_delete: :nullify add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade add_foreign_key "release_assets", "releases", name: "fk_releases_release_assets", on_delete: :cascade diff --git a/spec/services/ci_pipeline_service_spec.rb b/spec/services/ci_pipeline_service_spec.rb index 92328b3..d6967bd 100644 --- a/spec/services/ci_pipeline_service_spec.rb +++ b/spec/services/ci_pipeline_service_spec.rb @@ -4,31 +4,6 @@ RSpec.describe WarpEngine::CiPipelineService do let(:client) { instance_double(WarpEngine::WoodpeckerClient) } let(:service) { described_class.new(client: client) } - describe "#dashboard" do - it "returns latest pipeline for each active repo" do - repo = create(:ci_repository, repo_owner: "org", repo_name: "game") - pipeline = { "number" => 5, "status" => "success", "created_at" => "2026-08-06T12:00:00Z" } - allow(client).to receive(:latest_pipeline).with(repo.woodpecker_repo_id).and_return(pipeline) - - entries = service.dashboard - - expect(entries.size).to eq(1) - expect(entries.first[:pipeline]["status"]).to eq("success") - expect(repo.reload.last_pipeline_status).to eq("success") - end - - it "handles API errors gracefully per repo" do - create(:ci_repository, repo_owner: "org", repo_name: "broken") - allow(client).to receive(:latest_pipeline) - .and_raise(WarpEngine::WoodpeckerClient::ApiError.new("fail", status: 500)) - - entries = service.dashboard - - expect(entries.size).to eq(1) - expect(entries.first[:pipeline]).to be_nil - end - end - describe "#trigger" do it "delegates to client" do repo = build(:ci_repository, repo_owner: "org", repo_name: "game") @@ -42,12 +17,39 @@ RSpec.describe WarpEngine::CiPipelineService do end describe "#list_pipelines" do - it "returns paginated pipelines" do - repo = build(:ci_repository, repo_owner: "org", repo_name: "game") - pipelines = [{ "number" => 1 }, { "number" => 2 }] + it "returns paginated pipelines and refreshes the repo's cached last pipeline" do + repo = create(:ci_repository, repo_owner: "org", repo_name: "game") + pipelines = [ + { "number" => 2, "status" => "failure", "created" => 1_754_500_000 }, + { "number" => 1, "status" => "success", "created" => 1_754_400_000 } + ] allow(client).to receive(:list_pipelines).with(repo.woodpecker_repo_id, page: 1).and_return(pipelines) expect(service.list_pipelines(repo)).to eq(pipelines) + + repo.reload + expect(repo.last_pipeline_status).to eq("failure") + expect(repo.last_pipeline_at).to eq(Time.zone.at(1_754_500_000)) + end + + it "does not touch the cache on later pages" do + repo = create(:ci_repository, repo_owner: "org", repo_name: "game", + last_pipeline_status: "success") + allow(client).to receive(:list_pipelines).with(repo.woodpecker_repo_id, page: 2) + .and_return([{ "number" => 1, "status" => "failure", "created" => 1_754_400_000 }]) + + service.list_pipelines(repo, page: 2) + + expect(repo.reload.last_pipeline_status).to eq("success") + end + + it "leaves the cache alone when the repo has no pipelines" do + repo = create(:ci_repository, repo_owner: "org", repo_name: "game", + last_pipeline_status: "success") + allow(client).to receive(:list_pipelines).and_return([]) + + expect(service.list_pipelines(repo)).to eq([]) + expect(repo.reload.last_pipeline_status).to eq("success") end end end