diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c95a2e7..720647f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- Added new `list_apps_cdn_builds` action to list builds on the Apps CDN with optional filtering by visibility (server-side) and version (client-side). This enables querying CDN builds directly by version instead of embedding post IDs in GitHub releases. [#702] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/list_apps_cdn_builds.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/list_apps_cdn_builds.rb new file mode 100644 index 000000000..ab740ee11 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/list_apps_cdn_builds.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require 'fastlane/action' +require 'net/http' +require 'uri' +require 'json' + +module Fastlane + module Actions + class ListAppsCdnBuildsAction < Action + VALID_POST_STATUS = %w[publish draft].freeze + + def self.run(params) + UI.message('Listing Apps CDN builds...') + + base_url = "https://public-api.wordpress.com/wp/v2/sites/#{params[:site_id]}" + + # Build query parameters + query_params = { 'per_page' => '100' } + query_params['status'] = params[:post_status] if params[:post_status] + + # Look up the version taxonomy term ID by slug if version is specified + if params[:version] + version_slug = params[:version].tr('.', '-') + version_term_id = lookup_term_id(base_url: base_url, api_token: params[:api_token], taxonomy: 'version', slug: version_slug) + query_params['version'] = version_term_id.to_s + end + + api_endpoint = "#{base_url}/a8c_cdn_build" + uri = URI.parse(api_endpoint) + uri.query = URI.encode_www_form(query_params) + + # Create and send the HTTP request + request = Net::HTTP::Get.new(uri.request_uri) + request['Accept'] = 'application/json' + request['Authorization'] = "Bearer #{params[:api_token]}" + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.open_timeout = 10 + http.read_timeout = 30 + http.request(request) + end + + # Handle the response + case response + when Net::HTTPSuccess + posts = JSON.parse(response.body) + + builds = posts.map do |post| + classes = post['class_list'] || [] + { + post_id: post['id'], + title: post.dig('title', 'rendered'), + status: post['status'], + version: extract_class_prefix(classes, 'version-'), + visibility: extract_class_prefix(classes, 'visibility-'), + platform: extract_class_prefix(classes, 'platform-'), + build_type: extract_class_prefix(classes, 'build_type-') + } + end + + UI.success("Found #{builds.size} Apps CDN build(s)") + + builds + else + UI.error("Failed to list Apps CDN builds: #{response.code} #{response.message}") + UI.error(response.body) + UI.user_error!('Listing of Apps CDN builds failed') + end + end + + # Look up a taxonomy term ID by its slug. + # + # @param base_url [String] The WP REST API v2 base URL (e.g. https://public-api.wordpress.com/wp/v2/sites/123) + # @param api_token [String] The WordPress.com API token + # @param taxonomy [String] The taxonomy name (e.g. 'version', 'visibility') + # @param slug [String] The term slug to look up + # @return [Integer] The term ID + # + def self.lookup_term_id(base_url:, api_token:, taxonomy:, slug:) + uri = URI.parse("#{base_url}/#{taxonomy}?slug=#{slug}") + + request = Net::HTTP::Get.new(uri.request_uri) + request['Accept'] = 'application/json' + request['Authorization'] = "Bearer #{api_token}" + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.open_timeout = 10 + http.read_timeout = 30 + http.request(request) + end + + case response + when Net::HTTPSuccess + terms = JSON.parse(response.body) + UI.user_error!("No #{taxonomy} term found for slug '#{slug}'") if terms.empty? + terms.first['id'] + else + UI.user_error!("Failed to look up #{taxonomy} term '#{slug}': #{response.code} #{response.message}") + end + end + + # Extract a value from the class_list array by prefix. + # e.g. extract_class_prefix(['visibility-external', 'platform-mac-silicon'], 'visibility-') => 'external' + def self.extract_class_prefix(classes, prefix) + classes.find { |c| c.start_with?(prefix) }&.delete_prefix(prefix) + end + + def self.description + 'Lists builds on the Apps CDN with optional filtering' + end + + def self.authors + ['Automattic'] + end + + def self.return_value + 'Returns an Array of Hashes, each containing { post_id:, title:, status:, version:, visibility:, platform:, build_type: }. On error, raises a FastlaneError.' + end + + def self.details + <<~DETAILS + Lists build posts on a WordPress blog that has the Apps CDN plugin enabled, + using the WordPress.com REST API (WP v2). Supports filtering by post status + and version (via taxonomy term lookup). + See PCYsg-15tP-p2 internal a8c documentation for details about the Apps CDN plugin. + DETAILS + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :site_id, + env_name: 'APPS_CDN_SITE_ID', + description: 'The WordPress.com CDN site ID to list builds from', + optional: false, + type: String, + verify_block: proc do |value| + UI.user_error!('Site ID cannot be empty') if value.to_s.empty? + end + ), + FastlaneCore::ConfigItem.new( + key: :api_token, + env_name: 'WPCOM_API_TOKEN', + description: 'The WordPress.com API token for authentication', + optional: false, + type: String, + verify_block: proc do |value| + UI.user_error!('API token cannot be empty') if value.to_s.empty? + end + ), + FastlaneCore::ConfigItem.new( + key: :post_status, + description: "Filter builds by post status ('publish' or 'draft')", + optional: true, + type: String, + verify_block: proc do |value| + UI.user_error!("Post status must be one of: #{VALID_POST_STATUS.join(', ')}") unless VALID_POST_STATUS.include?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :version, + description: 'Filter builds by version string (e.g., "v1.7.5") — looks up the version taxonomy term by slug', + optional: true, + type: String + ), + ] + end + + def self.is_supported?(platform) + true + end + + def self.example_code + [ + 'list_apps_cdn_builds( + site_id: "12345678", + api_token: ENV["WPCOM_API_TOKEN"] + )', + 'list_apps_cdn_builds( + site_id: "12345678", + api_token: ENV["WPCOM_API_TOKEN"], + post_status: "draft", + version: "v1.7.5" + )', + ] + end + end + end +end diff --git a/spec/list_apps_cdn_builds_spec.rb b/spec/list_apps_cdn_builds_spec.rb new file mode 100644 index 000000000..ec74e32f7 --- /dev/null +++ b/spec/list_apps_cdn_builds_spec.rb @@ -0,0 +1,286 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' +require 'webmock/rspec' + +describe Fastlane::Actions::ListAppsCdnBuildsAction do + let(:test_site_id) { '12345678' } + let(:test_api_token) { 'test_api_token' } + let(:v2_base) { "https://public-api.wordpress.com/wp/v2/sites/#{test_site_id}" } + let(:api_url) { "#{v2_base}/a8c_cdn_build" } + let(:version_term_url) { "#{v2_base}/version" } + + let(:version_term_id) { 42 } + + let(:sample_post) do + { + 'id' => 100, + 'title' => { 'rendered' => 'WordPress.com Studio v1.7.5 Mac - Silicon' }, + 'status' => 'publish', + 'class_list' => %w[version-v1-7-5 visibility-external platform-mac-silicon build_type-production] + } + end + + let(:sample_post_second) do + { + 'id' => 101, + 'title' => { 'rendered' => 'WordPress.com Studio v1.7.5 Mac - Intel' }, + 'status' => 'draft', + 'class_list' => %w[version-v1-7-5 visibility-external platform-mac-intel build_type-production] + } + end + + let(:sample_post_other_version) do + { + 'id' => 200, + 'title' => { 'rendered' => 'WordPress.com Studio v1.7.4 Mac - Silicon' }, + 'status' => 'publish', + 'class_list' => %w[version-v1-7-4 visibility-external platform-mac-silicon build_type-production] + } + end + + before do + WebMock.disable_net_connect! + end + + describe 'listing builds with no filters' do + it 'returns all builds' do + stub_request(:get, api_url) + .with(query: { 'per_page' => '100' }) + .to_return( + status: 200, + body: [sample_post, sample_post_other_version].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token + ) + + expect(result).to be_an(Array) + expect(result.size).to eq(2) + + expect(result[0][:post_id]).to eq(100) + expect(result[0][:title]).to eq('WordPress.com Studio v1.7.5 Mac - Silicon') + expect(result[0][:status]).to eq('publish') + expect(result[0][:version]).to eq('v1-7-5') + expect(result[0][:visibility]).to eq('external') + expect(result[0][:platform]).to eq('mac-silicon') + expect(result[0][:build_type]).to eq('production') + + expect(result[1][:post_id]).to eq(200) + + expect(WebMock).to( + have_requested(:get, api_url) + .with(query: { 'per_page' => '100' }) do |req| + expect(req.headers['Authorization']).to eq("Bearer #{test_api_token}") + expect(req.headers['Accept']).to eq('application/json') + true + end + ) + end + end + + describe 'filtering by post_status' do + it 'passes status as a server-side filter' do + stub_request(:get, api_url) + .with(query: { 'per_page' => '100', 'status' => 'draft' }) + .to_return( + status: 200, + body: [sample_post_second].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_status: 'draft' + ) + + expect(result.size).to eq(1) + expect(result[0][:status]).to eq('draft') + end + end + + describe 'filtering by version (server-side via term lookup)' do + it 'looks up the version term ID and filters server-side' do + stub_request(:get, version_term_url) + .with(query: { 'slug' => 'v1-7-5' }) + .to_return( + status: 200, + body: [{ 'id' => version_term_id, 'name' => 'v1.7.5', 'slug' => 'v1-7-5' }].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + stub_request(:get, api_url) + .with(query: { 'per_page' => '100', 'version' => version_term_id.to_s }) + .to_return( + status: 200, + body: [sample_post, sample_post_second].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + version: 'v1.7.5' + ) + + expect(result.size).to eq(2) + expect(result.map { |b| b[:post_id] }).to eq([100, 101]) + end + + it 'raises an error when the version term is not found' do + stub_request(:get, version_term_url) + .with(query: { 'slug' => 'v9-9-9' }) + .to_return( + status: 200, + body: [].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + version: 'v9.9.9' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, "No version term found for slug 'v9-9-9'") + end + + it 'raises an error when the version term lookup fails' do + stub_request(:get, version_term_url) + .with(query: { 'slug' => 'v1-7-5' }) + .to_return( + status: 500, + body: 'Internal Server Error', + headers: { 'Content-Type' => 'text/plain' } + ) + + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + version: 'v1.7.5' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, /Failed to look up version term 'v1-7-5': 500/) + end + end + + describe 'combining filters' do + it 'filters by both post_status and version' do + stub_request(:get, version_term_url) + .with(query: { 'slug' => 'v1-7-5' }) + .to_return( + status: 200, + body: [{ 'id' => version_term_id, 'name' => 'v1.7.5', 'slug' => 'v1-7-5' }].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + stub_request(:get, api_url) + .with(query: { 'per_page' => '100', 'status' => 'draft', 'version' => version_term_id.to_s }) + .to_return( + status: 200, + body: [sample_post_second].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_status: 'draft', + version: 'v1.7.5' + ) + + expect(result.size).to eq(1) + expect(result[0][:status]).to eq('draft') + end + end + + describe 'empty results' do + it 'returns an empty array when no builds exist' do + stub_request(:get, api_url) + .with(query: { 'per_page' => '100' }) + .to_return( + status: 200, + body: [].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token + ) + + expect(result).to be_an(Array) + expect(result).to be_empty + end + end + + describe 'error handling' do + it 'handles API authorization errors' do + stub_request(:get, api_url) + .with(query: { 'per_page' => '100' }) + .to_return( + status: 403, + body: { code: 'rest_forbidden', message: 'You are not authorized.' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Listing of Apps CDN builds failed') + end + + it 'handles server errors' do + stub_request(:get, api_url) + .with(query: { 'per_page' => '100' }) + .to_return( + status: 500, + body: 'Internal Server Error', + headers: { 'Content-Type' => 'text/plain' } + ) + + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Listing of Apps CDN builds failed') + end + end + + describe 'parameter validation' do + it 'fails if site_id is empty' do + expect do + run_described_fastlane_action( + site_id: '', + api_token: test_api_token + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Site ID cannot be empty') + end + + it 'fails if api_token is empty' do + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: '' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'API token cannot be empty') + end + + it 'fails if post_status is not valid' do + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + post_status: 'pending' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Post status must be one of: publish, draft') + end + end +end