From 0dbc16e5e092d2d1618f2a70c8db31d538395324 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 3 Mar 2026 13:29:20 +0100 Subject: [PATCH 1/4] Add list_apps_cdn_builds action to query CDN builds by version and visibility Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- .../actions/common/list_apps_cdn_builds.rb | 159 ++++++++++++ spec/list_apps_cdn_builds_spec.rb | 241 ++++++++++++++++++ 3 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/actions/common/list_apps_cdn_builds.rb create mode 100644 spec/list_apps_cdn_builds_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c95a2e7..8e5452d2f 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. [#xxx] ### 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..0157dedec --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/list_apps_cdn_builds.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require 'fastlane/action' +require 'net/http' +require 'uri' +require 'json' + +module Fastlane + module Actions + class ListAppsCdnBuildsAction < Action + VALID_VISIBILITIES = %i[internal external].freeze + + def self.run(params) + UI.message('Listing Apps CDN builds...') + + api_endpoint = "https://public-api.wordpress.com/rest/v1.1/sites/#{params[:site_id]}/posts" + uri = URI.parse(api_endpoint) + + # Build query parameters + query_params = { + 'type' => 'a8c_cdn_build', + 'number' => '100' + } + query_params['term[visibility]'] = params[:visibility].to_s.capitalize if params[:visibility] + + 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 + result = JSON.parse(response.body) + posts = result['posts'] || [] + + # Client-side filter by version if requested + if params[:version] + posts = posts.select { |post| post.dig('terms', 'version')&.key?(params[:version]) } + end + + builds = posts.map do |post| + visibility_terms = post.dig('terms', 'visibility') || {} + visibility_key = visibility_terms.keys.first + platform_terms = post.dig('terms', 'platform') || {} + platform_key = platform_terms.keys.first + build_type_terms = post.dig('terms', 'build_type') || {} + build_type_key = build_type_terms.keys.first + + { + post_id: post['ID'], + title: post['title'], + version: post.dig('terms', 'version')&.keys&.first, + visibility: visibility_key&.downcase, + platform: platform_key, + build_type: build_type_key + } + 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 + + 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:, 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. Supports filtering by visibility (server-side) + and version (client-side). + 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: :visibility, + description: 'Filter builds by visibility (:internal or :external)', + optional: true, + type: Symbol, + verify_block: proc do |value| + UI.user_error!("Visibility must be one of: #{VALID_VISIBILITIES.map { "`:#{_1}`" }.join(', ')}") unless VALID_VISIBILITIES.include?(value.to_s.downcase.to_sym) + end + ), + FastlaneCore::ConfigItem.new( + key: :version, + description: 'Filter builds by version string (e.g., "v1.7.5") — client-side filter matching version taxonomy keys', + 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"], + visibility: :internal, + 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..c3e62bfd6 --- /dev/null +++ b/spec/list_apps_cdn_builds_spec.rb @@ -0,0 +1,241 @@ +# 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(:api_url) { "https://public-api.wordpress.com/rest/v1.1/sites/#{test_site_id}/posts" } + + let(:sample_post) do + { + 'ID' => 100, + 'title' => 'WordPress.com Studio v1.7.5 Mac - Silicon', + 'terms' => { + 'version' => { 'v1.7.5' => { 'ID' => 10, 'name' => 'v1.7.5', 'slug' => 'v1-7-5' } }, + 'visibility' => { 'Internal' => { 'ID' => 20, 'name' => 'Internal', 'slug' => 'internal' } }, + 'platform' => { 'Mac - Silicon' => { 'ID' => 30, 'name' => 'Mac - Silicon', 'slug' => 'mac-silicon' } }, + 'build_type' => { 'Production' => { 'ID' => 40, 'name' => 'Production', 'slug' => 'production' } } + } + } + end + + let(:sample_post_second) do + { + 'ID' => 101, + 'title' => 'WordPress.com Studio v1.7.5 Mac - Intel', + 'terms' => { + 'version' => { 'v1.7.5' => { 'ID' => 10, 'name' => 'v1.7.5', 'slug' => 'v1-7-5' } }, + 'visibility' => { 'Internal' => { 'ID' => 20, 'name' => 'Internal', 'slug' => 'internal' } }, + 'platform' => { 'Mac - Intel' => { 'ID' => 31, 'name' => 'Mac - Intel', 'slug' => 'mac-intel' } }, + 'build_type' => { 'Production' => { 'ID' => 40, 'name' => 'Production', 'slug' => 'production' } } + } + } + end + + let(:sample_post_other_version) do + { + 'ID' => 200, + 'title' => 'WordPress.com Studio v1.7.4 Mac - Silicon', + 'terms' => { + 'version' => { 'v1.7.4' => { 'ID' => 11, 'name' => 'v1.7.4', 'slug' => 'v1-7-4' } }, + 'visibility' => { 'External' => { 'ID' => 21, 'name' => 'External', 'slug' => 'external' } }, + 'platform' => { 'Mac - Silicon' => { 'ID' => 30, 'name' => 'Mac - Silicon', 'slug' => 'mac-silicon' } }, + 'build_type' => { 'Production' => { 'ID' => 40, 'name' => 'Production', 'slug' => 'production' } } + } + } + end + + before do + WebMock.disable_net_connect! + end + + after do + WebMock.allow_net_connect! + end + + describe 'listing builds with no filters' do + it 'returns all builds' do + stub_request(:get, api_url) + .with(query: { 'type' => 'a8c_cdn_build', 'number' => '100' }) + .to_return( + status: 200, + body: { 'posts' => [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][:version]).to eq('v1.7.5') + expect(result[0][:visibility]).to eq('internal') + 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: { 'type' => 'a8c_cdn_build', 'number' => '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 visibility (server-side)' do + it 'passes visibility as a server-side filter' do + stub_request(:get, api_url) + .with(query: { 'type' => 'a8c_cdn_build', 'number' => '100', 'term[visibility]' => 'Internal' }) + .to_return( + status: 200, + body: { 'posts' => [sample_post] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + visibility: :internal + ) + + expect(result.size).to eq(1) + expect(result[0][:visibility]).to eq('internal') + end + end + + describe 'filtering by version (client-side)' do + it 'filters builds by matching version taxonomy keys' do + stub_request(:get, api_url) + .with(query: { 'type' => 'a8c_cdn_build', 'number' => '100' }) + .to_return( + status: 200, + body: { 'posts' => [sample_post, sample_post_second, 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, + version: 'v1.7.5' + ) + + expect(result.size).to eq(2) + expect(result.map { |b| b[:post_id] }).to eq([100, 101]) + end + + it 'returns empty array when no builds match the version' do + stub_request(:get, api_url) + .with(query: { 'type' => 'a8c_cdn_build', 'number' => '100' }) + .to_return( + status: 200, + body: { 'posts' => [sample_post] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + version: 'v9.9.9' + ) + + expect(result).to be_empty + end + end + + describe 'empty results' do + it 'returns an empty array when no builds exist' do + stub_request(:get, api_url) + .with(query: { 'type' => 'a8c_cdn_build', 'number' => '100' }) + .to_return( + status: 200, + body: { 'posts' => [] }.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: { 'type' => 'a8c_cdn_build', 'number' => '100' }) + .to_return( + status: 403, + body: { error: 'unauthorized', message: 'You are not authorized to access this resource.' }.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: { 'type' => 'a8c_cdn_build', 'number' => '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 visibility is not a valid symbol' do + expect do + run_described_fastlane_action( + site_id: test_site_id, + api_token: test_api_token, + visibility: :public + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Visibility must be one of: `:internal`, `:external`') + end + end +end From c07450bb32dfa31b7c106d3cf4cba1ef1ebb90ac Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 3 Mar 2026 13:36:06 +0100 Subject: [PATCH 2/4] Update CHANGELOG PR link Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e5452d2f..720647f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -- 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. [#xxx] +- 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 From e3cc646f54199b0f4409dca40213a7a438015179 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 3 Mar 2026 14:22:19 +0100 Subject: [PATCH 3/4] Address Copilot review: extract term_name helper, remove WebMock leak - Use values.first['name'] instead of keys.first for term parsing (more robust) - Extract term_name helper to avoid long safe-navigation chains - Remove after { WebMock.allow_net_connect! } that leaked into other specs Co-Authored-By: Claude Opus 4.6 --- .../actions/common/list_apps_cdn_builds.rb | 22 +++++++++---------- spec/list_apps_cdn_builds_spec.rb | 4 ---- 2 files changed, 11 insertions(+), 15 deletions(-) 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 index 0157dedec..2112c47fa 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/list_apps_cdn_builds.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/list_apps_cdn_builds.rb @@ -10,6 +10,13 @@ module Actions class ListAppsCdnBuildsAction < Action VALID_VISIBILITIES = %i[internal external].freeze + # Extract the name of the first term in a taxonomy from an API post response. + # Terms are keyed by display name, e.g. { 'Internal' => { 'name' => 'Internal', 'slug' => 'internal' } } + def self.term_name(post, taxonomy) + term = post.dig('terms', taxonomy)&.values&.first + term&.[]('name') + end + def self.run(params) UI.message('Listing Apps CDN builds...') @@ -48,20 +55,13 @@ def self.run(params) end builds = posts.map do |post| - visibility_terms = post.dig('terms', 'visibility') || {} - visibility_key = visibility_terms.keys.first - platform_terms = post.dig('terms', 'platform') || {} - platform_key = platform_terms.keys.first - build_type_terms = post.dig('terms', 'build_type') || {} - build_type_key = build_type_terms.keys.first - { post_id: post['ID'], title: post['title'], - version: post.dig('terms', 'version')&.keys&.first, - visibility: visibility_key&.downcase, - platform: platform_key, - build_type: build_type_key + version: term_name(post, 'version'), + visibility: term_name(post, 'visibility')&.downcase, + platform: term_name(post, 'platform'), + build_type: term_name(post, 'build_type') } end diff --git a/spec/list_apps_cdn_builds_spec.rb b/spec/list_apps_cdn_builds_spec.rb index c3e62bfd6..792b01651 100644 --- a/spec/list_apps_cdn_builds_spec.rb +++ b/spec/list_apps_cdn_builds_spec.rb @@ -51,10 +51,6 @@ WebMock.disable_net_connect! end - after do - WebMock.allow_net_connect! - end - describe 'listing builds with no filters' do it 'returns all builds' do stub_request(:get, api_url) From f6e0854b588e3739591a3d860dc63f72b8ce8967 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Wed, 4 Mar 2026 14:08:26 +0100 Subject: [PATCH 4/4] Switch list_apps_cdn_builds to WP REST API v2 The v1.1 API returns 500 for a8c_cdn_build custom post types. Replaces visibility filter with post_status filter (draft/publish) and adds server-side version filtering via taxonomy term ID lookup. Co-Authored-By: Claude Opus 4.6 --- .../actions/common/list_apps_cdn_builds.rb | 105 ++++++++---- spec/list_apps_cdn_builds_spec.rb | 159 ++++++++++++------ 2 files changed, 172 insertions(+), 92 deletions(-) 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 index 2112c47fa..ab740ee11 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/list_apps_cdn_builds.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/list_apps_cdn_builds.rb @@ -8,28 +8,26 @@ module Fastlane module Actions class ListAppsCdnBuildsAction < Action - VALID_VISIBILITIES = %i[internal external].freeze - - # Extract the name of the first term in a taxonomy from an API post response. - # Terms are keyed by display name, e.g. { 'Internal' => { 'name' => 'Internal', 'slug' => 'internal' } } - def self.term_name(post, taxonomy) - term = post.dig('terms', taxonomy)&.values&.first - term&.[]('name') - end + VALID_POST_STATUS = %w[publish draft].freeze def self.run(params) UI.message('Listing Apps CDN builds...') - api_endpoint = "https://public-api.wordpress.com/rest/v1.1/sites/#{params[:site_id]}/posts" - uri = URI.parse(api_endpoint) + base_url = "https://public-api.wordpress.com/wp/v2/sites/#{params[:site_id]}" # Build query parameters - query_params = { - 'type' => 'a8c_cdn_build', - 'number' => '100' - } - query_params['term[visibility]'] = params[:visibility].to_s.capitalize if params[:visibility] + 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 @@ -46,22 +44,18 @@ def self.run(params) # Handle the response case response when Net::HTTPSuccess - result = JSON.parse(response.body) - posts = result['posts'] || [] - - # Client-side filter by version if requested - if params[:version] - posts = posts.select { |post| post.dig('terms', 'version')&.key?(params[:version]) } - end + posts = JSON.parse(response.body) builds = posts.map do |post| + classes = post['class_list'] || [] { - post_id: post['ID'], - title: post['title'], - version: term_name(post, 'version'), - visibility: term_name(post, 'visibility')&.downcase, - platform: term_name(post, 'platform'), - build_type: term_name(post, 'build_type') + 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 @@ -75,6 +69,43 @@ def self.run(params) 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 @@ -84,14 +115,14 @@ def self.authors end def self.return_value - 'Returns an Array of Hashes, each containing { post_id:, title:, version:, visibility:, platform:, build_type: }. On error, raises a FastlaneError.' + '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. Supports filtering by visibility (server-side) - and version (client-side). + 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 @@ -119,17 +150,17 @@ def self.available_options end ), FastlaneCore::ConfigItem.new( - key: :visibility, - description: 'Filter builds by visibility (:internal or :external)', + key: :post_status, + description: "Filter builds by post status ('publish' or 'draft')", optional: true, - type: Symbol, + type: String, verify_block: proc do |value| - UI.user_error!("Visibility must be one of: #{VALID_VISIBILITIES.map { "`:#{_1}`" }.join(', ')}") unless VALID_VISIBILITIES.include?(value.to_s.downcase.to_sym) + 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") — client-side filter matching version taxonomy keys', + description: 'Filter builds by version string (e.g., "v1.7.5") — looks up the version taxonomy term by slug', optional: true, type: String ), @@ -149,7 +180,7 @@ def self.example_code 'list_apps_cdn_builds( site_id: "12345678", api_token: ENV["WPCOM_API_TOKEN"], - visibility: :internal, + post_status: "draft", version: "v1.7.5" )', ] diff --git a/spec/list_apps_cdn_builds_spec.rb b/spec/list_apps_cdn_builds_spec.rb index 792b01651..ec74e32f7 100644 --- a/spec/list_apps_cdn_builds_spec.rb +++ b/spec/list_apps_cdn_builds_spec.rb @@ -6,44 +6,36 @@ describe Fastlane::Actions::ListAppsCdnBuildsAction do let(:test_site_id) { '12345678' } let(:test_api_token) { 'test_api_token' } - let(:api_url) { "https://public-api.wordpress.com/rest/v1.1/sites/#{test_site_id}/posts" } + 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' => 'WordPress.com Studio v1.7.5 Mac - Silicon', - 'terms' => { - 'version' => { 'v1.7.5' => { 'ID' => 10, 'name' => 'v1.7.5', 'slug' => 'v1-7-5' } }, - 'visibility' => { 'Internal' => { 'ID' => 20, 'name' => 'Internal', 'slug' => 'internal' } }, - 'platform' => { 'Mac - Silicon' => { 'ID' => 30, 'name' => 'Mac - Silicon', 'slug' => 'mac-silicon' } }, - 'build_type' => { 'Production' => { 'ID' => 40, 'name' => 'Production', 'slug' => 'production' } } - } + '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' => 'WordPress.com Studio v1.7.5 Mac - Intel', - 'terms' => { - 'version' => { 'v1.7.5' => { 'ID' => 10, 'name' => 'v1.7.5', 'slug' => 'v1-7-5' } }, - 'visibility' => { 'Internal' => { 'ID' => 20, 'name' => 'Internal', 'slug' => 'internal' } }, - 'platform' => { 'Mac - Intel' => { 'ID' => 31, 'name' => 'Mac - Intel', 'slug' => 'mac-intel' } }, - 'build_type' => { 'Production' => { 'ID' => 40, 'name' => 'Production', 'slug' => 'production' } } - } + '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' => 'WordPress.com Studio v1.7.4 Mac - Silicon', - 'terms' => { - 'version' => { 'v1.7.4' => { 'ID' => 11, 'name' => 'v1.7.4', 'slug' => 'v1-7-4' } }, - 'visibility' => { 'External' => { 'ID' => 21, 'name' => 'External', 'slug' => 'external' } }, - 'platform' => { 'Mac - Silicon' => { 'ID' => 30, 'name' => 'Mac - Silicon', 'slug' => 'mac-silicon' } }, - 'build_type' => { 'Production' => { 'ID' => 40, 'name' => 'Production', 'slug' => 'production' } } - } + '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 @@ -54,10 +46,10 @@ describe 'listing builds with no filters' do it 'returns all builds' do stub_request(:get, api_url) - .with(query: { 'type' => 'a8c_cdn_build', 'number' => '100' }) + .with(query: { 'per_page' => '100' }) .to_return( status: 200, - body: { 'posts' => [sample_post, sample_post_other_version] }.to_json, + body: [sample_post, sample_post_other_version].to_json, headers: { 'Content-Type' => 'application/json' } ) @@ -71,16 +63,17 @@ 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][:version]).to eq('v1.7.5') - expect(result[0][:visibility]).to eq('internal') - expect(result[0][:platform]).to eq('Mac - Silicon') - expect(result[0][:build_type]).to eq('Production') + 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: { 'type' => 'a8c_cdn_build', 'number' => '100' }) do |req| + .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 @@ -89,34 +82,42 @@ end end - describe 'filtering by visibility (server-side)' do - it 'passes visibility as a server-side filter' do + describe 'filtering by post_status' do + it 'passes status as a server-side filter' do stub_request(:get, api_url) - .with(query: { 'type' => 'a8c_cdn_build', 'number' => '100', 'term[visibility]' => 'Internal' }) + .with(query: { 'per_page' => '100', 'status' => 'draft' }) .to_return( status: 200, - body: { 'posts' => [sample_post] }.to_json, + 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, - visibility: :internal + post_status: 'draft' ) expect(result.size).to eq(1) - expect(result[0][:visibility]).to eq('internal') + expect(result[0][:status]).to eq('draft') end end - describe 'filtering by version (client-side)' do - it 'filters builds by matching version taxonomy keys' do + 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: { 'type' => 'a8c_cdn_build', 'number' => '100' }) + .with(query: { 'per_page' => '100', 'version' => version_term_id.to_s }) .to_return( status: 200, - body: { 'posts' => [sample_post, sample_post_second, sample_post_other_version] }.to_json, + body: [sample_post, sample_post_second].to_json, headers: { 'Content-Type' => 'application/json' } ) @@ -130,32 +131,80 @@ expect(result.map { |b| b[:post_id] }).to eq([100, 101]) end - it 'returns empty array when no builds match the version' do + 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: { 'type' => 'a8c_cdn_build', 'number' => '100' }) + .with(query: { 'per_page' => '100', 'status' => 'draft', 'version' => version_term_id.to_s }) .to_return( status: 200, - body: { 'posts' => [sample_post] }.to_json, + 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, - version: 'v9.9.9' + post_status: 'draft', + version: 'v1.7.5' ) - expect(result).to be_empty + 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: { 'type' => 'a8c_cdn_build', 'number' => '100' }) + .with(query: { 'per_page' => '100' }) .to_return( status: 200, - body: { 'posts' => [] }.to_json, + body: [].to_json, headers: { 'Content-Type' => 'application/json' } ) @@ -172,10 +221,10 @@ describe 'error handling' do it 'handles API authorization errors' do stub_request(:get, api_url) - .with(query: { 'type' => 'a8c_cdn_build', 'number' => '100' }) + .with(query: { 'per_page' => '100' }) .to_return( status: 403, - body: { error: 'unauthorized', message: 'You are not authorized to access this resource.' }.to_json, + body: { code: 'rest_forbidden', message: 'You are not authorized.' }.to_json, headers: { 'Content-Type' => 'application/json' } ) @@ -189,7 +238,7 @@ it 'handles server errors' do stub_request(:get, api_url) - .with(query: { 'type' => 'a8c_cdn_build', 'number' => '100' }) + .with(query: { 'per_page' => '100' }) .to_return( status: 500, body: 'Internal Server Error', @@ -224,14 +273,14 @@ end.to raise_error(FastlaneCore::Interface::FastlaneError, 'API token cannot be empty') end - it 'fails if visibility is not a valid symbol' do + 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, - visibility: :public + post_status: 'pending' ) - end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Visibility must be one of: `:internal`, `:external`') + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'Post status must be one of: publish, draft') end end end