From 77cdeac5098b460ac6faba189f7e6a4f3d642c25 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Tue, 16 Jun 2026 20:57:46 -0400 Subject: [PATCH 1/4] Add find_or_create_pull_request action Adds a generic action that returns the URL of the open Pull Request for a head branch, creating one only if none exists yet. Backed by a new GithubHelper#find_pull_request (list-by-head). Useful for rolling automations (e.g. a daily translations job) that force-push the same head branch each run, where GitHub auto-refreshes the existing PR's diff. --- CHANGELOG.md | 2 +- .../find_or_create_pull_request_action.rb | 123 ++++++++++++++++++ .../wpmreleasetoolkit/helper/github_helper.rb | 17 +++ spec/find_or_create_pull_request_spec.rb | 69 ++++++++++ spec/github_helper_spec.rb | 40 ++++++ 5 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_or_create_pull_request_action.rb create mode 100644 spec/find_or_create_pull_request_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4690cb0ef..74b09b204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- Added `find_or_create_pull_request` action and `GithubHelper#find_pull_request`: returns the URL of the open Pull Request for a head branch, creating one only if none exists yet. Useful for "rolling" automations (e.g. a daily translations or dependency-update job) that force-push the same head branch on every run. ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_or_create_pull_request_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_or_create_pull_request_action.rb new file mode 100644 index 000000000..3561723c2 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_or_create_pull_request_action.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'fastlane/action' +require_relative '../../helper/github_helper' + +module Fastlane + module Actions + class FindOrCreatePullRequestAction < Action + def self.run(params) + github_helper = Fastlane::Helper::GithubHelper.new(github_token: params[:github_token]) + + existing_pr = github_helper.find_pull_request( + repository: params[:repository], + head: params[:head], + base: params[:base] + ) + + unless existing_pr.nil? + UI.message("An open Pull Request already exists for `#{params[:head]}`: #{existing_pr.html_url}") + return existing_pr.html_url + end + + other_action.create_pull_request( + api_url: params[:api_url], + api_token: params[:github_token], + repo: params[:repository], + title: params[:title], + body: params[:body], + head: params[:head], + base: params[:base], + labels: params[:labels], + assignees: params[:assignees], + reviewers: params[:reviewers], + team_reviewers: params[:team_reviewers], + milestone: params[:milestone] + ) + end + + def self.description + 'Returns the URL of the open Pull Request for a head branch, creating one if none exists yet' + end + + def self.details + <<~DETAILS + Looks for an open Pull Request whose head is the given branch (optionally targeting a specific base), + and returns its URL if found. Otherwise, creates a new Pull Request and returns its URL. + + This is useful for "rolling" automations (e.g. a daily translations or dependency-update job) that + force-push the same head branch on every run: GitHub automatically refreshes the diff of the existing + PR, so this action only needs to open a PR the first time. + DETAILS + end + + def self.authors + ['Automattic'] + end + + def self.return_type + :string + end + + def self.return_value + 'The URL of the existing or newly-created Pull Request' + end + + def self.available_options + # Parameters we forward as-is from Fastlane's `create_pull_request` action + forwarded_param_keys = %i[ + api_url + labels + assignees + reviewers + team_reviewers + milestone + ].freeze + + forwarded_params = Fastlane::Actions::CreatePullRequestAction.available_options.select do |opt| + forwarded_param_keys.include?(opt.key) + end + + [ + *forwarded_params, + Fastlane::Helper::GithubHelper.github_token_config_item, # forwarded to `api_token` in the `create_pull_request` action + FastlaneCore::ConfigItem.new( + key: :repository, + env_name: 'GHHELPER_REPOSITORY', + description: 'The remote path of the GH repository on which we work, e.g. `wordpress-mobile/wordpress-ios`', + optional: false, + type: String + ), + FastlaneCore::ConfigItem.new( + key: :title, + description: 'The title of the Pull Request to create if none exists yet', + optional: false, + type: String + ), + FastlaneCore::ConfigItem.new( + key: :body, + description: 'The body of the Pull Request to create if none exists yet', + optional: true, + type: String + ), + FastlaneCore::ConfigItem.new( + key: :head, + description: 'The head branch of the Pull Request (the branch with the changes)', + optional: false, + type: String + ), + FastlaneCore::ConfigItem.new( + key: :base, + description: 'The base branch the Pull Request targets (e.g. `trunk`)', + optional: false, + type: String + ), + ] + end + + def self.is_supported?(platform) + true + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb index e357beebd..789a2cc97 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb @@ -304,6 +304,23 @@ def comment_on_pr(project_slug:, pr_number:, body:, reuse_identifier: SecureRand reuse_identifier end + # Find an existing Pull Request matching the given head (and optionally base) branch. + # + # @param [String] repository The repository name, including the organization (e.g. `wordpress-mobile/wordpress-ios`) + # @param [String] head The head branch to look for. May be given as `branch` or as the fully-qualified `owner:branch`; + # when unqualified, it is automatically prefixed with the repository's owner. + # @param [String] base The base branch the PR should target. If nil, PRs targeting any base are considered. + # @param [String] state The PR state to match (`open`, `closed`, or `all`). Defaults to `open`. + # @return [Sawyer::Resource, nil] The first matching Pull Request, or nil if none matches. + # + def find_pull_request(repository:, head:, base: nil, state: 'open') + qualified_head = head.include?(':') ? head : "#{repository.split('/').first}:#{head}" + options = { state: state, head: qualified_head } + options[:base] = base unless base.nil? + + client.pull_requests(repository, options).first + end + # Update a milestone for a repository # # @param [String] repository The repository name (including the organization) diff --git a/spec/find_or_create_pull_request_spec.rb b/spec/find_or_create_pull_request_spec.rb new file mode 100644 index 000000000..913309562 --- /dev/null +++ b/spec/find_or_create_pull_request_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Fastlane::Actions::FindOrCreatePullRequestAction do + let(:test_token) { 'ghp_fake_token' } + let(:test_repo) { 'repo-test/project-test' } + let(:test_head) { 'translations/daily-update' } + let(:test_base) { 'trunk' } + let(:github_helper) { instance_double(Fastlane::Helper::GithubHelper) } + let(:other_action_mock) { double } + + before do + allow(Fastlane::Helper::GithubHelper).to receive(:new).with(github_token: test_token).and_return(github_helper) + allow(Fastlane::Action).to receive(:other_action).and_return(other_action_mock) + end + + context 'when an open PR already exists for the head branch' do + it 'returns its URL and does not create a new PR' do + existing_pr = double('PullRequest', html_url: "https://github.com/#{test_repo}/pull/7") # rubocop:disable RSpec/VerifiedDoubles + allow(github_helper).to receive(:find_pull_request) + .with(repository: test_repo, head: test_head, base: test_base) + .and_return(existing_pr) + expect(other_action_mock).not_to receive(:create_pull_request) + + result = run_described_fastlane_action( + github_token: test_token, + repository: test_repo, + title: 'Update translations', + head: test_head, + base: test_base + ) + + expect(result).to eq("https://github.com/#{test_repo}/pull/7") + end + end + + context 'when no open PR exists for the head branch' do + it 'creates a new PR forwarding the parameters and returns its URL' do + allow(github_helper).to receive(:find_pull_request).and_return(nil) + allow(other_action_mock).to receive(:create_pull_request).with( + api_url: 'https://api.github.com', + api_token: test_token, + repo: test_repo, + title: 'Update translations', + body: 'Sync translations from GlotPress', + head: test_head, + base: test_base, + labels: ['Localization'], + assignees: nil, + reviewers: nil, + team_reviewers: nil, + milestone: nil + ).and_return("https://github.com/#{test_repo}/pull/8") + + result = run_described_fastlane_action( + github_token: test_token, + repository: test_repo, + title: 'Update translations', + body: 'Sync translations from GlotPress', + head: test_head, + base: test_base, + labels: ['Localization'] + ) + + expect(result).to eq("https://github.com/#{test_repo}/pull/8") + end + end +end diff --git a/spec/github_helper_spec.rb b/spec/github_helper_spec.rb index 3e7e0c5fd..f30bacc48 100644 --- a/spec/github_helper_spec.rb +++ b/spec/github_helper_spec.rb @@ -46,6 +46,46 @@ def download_file_from_tag(download_folder:) end end + describe '#find_pull_request' do + let(:test_repo) { 'repo-test/project-test' } + let(:found_pr) { double('PullRequest', html_url: 'https://github.com/repo-test/project-test/pull/42') } # rubocop:disable RSpec/VerifiedDoubles + let(:client) do + instance_double( + Octokit::Client, + pull_requests: [found_pr], + user: instance_double('User', name: 'test'), + 'auto_paginate=': nil + ) + end + + before do + allow(Octokit::Client).to receive(:new).and_return(client) + end + + it 'qualifies an unqualified head with the repository owner and forwards the base' do + expect(client).to receive(:pull_requests).with(test_repo, { state: 'open', head: 'repo-test:my-branch', base: 'trunk' }) + find_pull_request(head: 'my-branch', base: 'trunk') + end + + it 'uses an already-qualified head as-is and omits the base when not provided' do + expect(client).to receive(:pull_requests).with(test_repo, { state: 'open', head: 'someone:other-branch' }) + find_pull_request(head: 'someone:other-branch') + end + + it 'returns the first matching pull request' do + expect(find_pull_request(head: 'my-branch')).to eq(found_pr) + end + + it 'returns nil when no pull request matches' do + allow(client).to receive(:pull_requests).and_return([]) + expect(find_pull_request(head: 'my-branch')).to be_nil + end + + def find_pull_request(head:, base: nil) + described_class.new(github_token: 'Fake-GitHubToken-123').find_pull_request(repository: test_repo, head: head, base: base) + end + end + describe '#get_last_milestone' do let(:test_repo) { 'repo-test/project-test' } let(:last_stone) { mock_milestone('10.0') } From 5c03a935e97ac5d5c3b2fd2011f20312b1706246 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Thu, 18 Jun 2026 09:07:02 -0400 Subject: [PATCH 2/4] Address Copilot review on find_or_create_pull_request - Reword the action's details: base is required (drop 'optionally targeting a specific base') to match the required `base` ConfigItem. - find_pull_request YARD: mark `base` as `[String?]` since it accepts nil. --- .../actions/common/find_or_create_pull_request_action.rb | 2 +- lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_or_create_pull_request_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_or_create_pull_request_action.rb index 3561723c2..83b0ef04d 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_or_create_pull_request_action.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_or_create_pull_request_action.rb @@ -42,7 +42,7 @@ def self.description def self.details <<~DETAILS - Looks for an open Pull Request whose head is the given branch (optionally targeting a specific base), + Looks for an open Pull Request whose head is the given branch and which targets the given base, and returns its URL if found. Otherwise, creates a new Pull Request and returns its URL. This is useful for "rolling" automations (e.g. a daily translations or dependency-update job) that diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb index 789a2cc97..b8a89d27f 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb @@ -309,7 +309,7 @@ def comment_on_pr(project_slug:, pr_number:, body:, reuse_identifier: SecureRand # @param [String] repository The repository name, including the organization (e.g. `wordpress-mobile/wordpress-ios`) # @param [String] head The head branch to look for. May be given as `branch` or as the fully-qualified `owner:branch`; # when unqualified, it is automatically prefixed with the repository's owner. - # @param [String] base The base branch the PR should target. If nil, PRs targeting any base are considered. + # @param [String?] base The base branch the PR should target. If nil, PRs targeting any base are considered. # @param [String] state The PR state to match (`open`, `closed`, or `all`). Defaults to `open`. # @return [Sawyer::Resource, nil] The first matching Pull Request, or nil if none matches. # From 4b39f7d5b05e88f2228602ff3e1ca2f07369963c Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Thu, 18 Jun 2026 10:22:18 -0400 Subject: [PATCH 3/4] Add PR reference to the CHANGELOG entry [#733] --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b09b204..420701b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -- Added `find_or_create_pull_request` action and `GithubHelper#find_pull_request`: returns the URL of the open Pull Request for a head branch, creating one only if none exists yet. Useful for "rolling" automations (e.g. a daily translations or dependency-update job) that force-push the same head branch on every run. +- Added `find_or_create_pull_request` action and `GithubHelper#find_pull_request`: returns the URL of the open Pull Request for a head branch, creating one only if none exists yet. Useful for "rolling" automations (e.g. a daily translations or dependency-update job) that force-push the same head branch on every run. [#733] ### Bug Fixes From dd96bb56915637ee298625ee146f5ff636df9031 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Fri, 19 Jun 2026 08:18:13 -0400 Subject: [PATCH 4/4] Forward draft parameter in find_or_create_pull_request Allow creating draft PRs by forwarding a draft option to the underlying create_pull_request action. --- .../find_or_create_pull_request_action.rb | 2 ++ spec/find_or_create_pull_request_spec.rb | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_or_create_pull_request_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_or_create_pull_request_action.rb index 83b0ef04d..fc3b1595b 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_or_create_pull_request_action.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/find_or_create_pull_request_action.rb @@ -26,6 +26,7 @@ def self.run(params) repo: params[:repository], title: params[:title], body: params[:body], + draft: params[:draft], head: params[:head], base: params[:base], labels: params[:labels], @@ -67,6 +68,7 @@ def self.available_options # Parameters we forward as-is from Fastlane's `create_pull_request` action forwarded_param_keys = %i[ api_url + draft labels assignees reviewers diff --git a/spec/find_or_create_pull_request_spec.rb b/spec/find_or_create_pull_request_spec.rb index 913309562..3cd186cff 100644 --- a/spec/find_or_create_pull_request_spec.rb +++ b/spec/find_or_create_pull_request_spec.rb @@ -44,6 +44,7 @@ repo: test_repo, title: 'Update translations', body: 'Sync translations from GlotPress', + draft: nil, head: test_head, base: test_base, labels: ['Localization'], @@ -65,5 +66,23 @@ expect(result).to eq("https://github.com/#{test_repo}/pull/8") end + + it 'forwards draft: true when creating a draft PR' do + allow(github_helper).to receive(:find_pull_request).and_return(nil) + allow(other_action_mock).to receive(:create_pull_request) + .with(hash_including(draft: true)) + .and_return("https://github.com/#{test_repo}/pull/9") + + result = run_described_fastlane_action( + github_token: test_token, + repository: test_repo, + title: 'Update translations', + head: test_head, + base: test_base, + draft: true + ) + + expect(result).to eq("https://github.com/#{test_repo}/pull/9") + end end end