Skip to content

Commit 77cdeac

Browse files
committed
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.
1 parent 462f587 commit 77cdeac

5 files changed

Lines changed: 250 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ _None_
1010

1111
### New Features
1212

13-
_None_
13+
- 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.
1414

1515
### Bug Fixes
1616

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# frozen_string_literal: true
2+
3+
require 'fastlane/action'
4+
require_relative '../../helper/github_helper'
5+
6+
module Fastlane
7+
module Actions
8+
class FindOrCreatePullRequestAction < Action
9+
def self.run(params)
10+
github_helper = Fastlane::Helper::GithubHelper.new(github_token: params[:github_token])
11+
12+
existing_pr = github_helper.find_pull_request(
13+
repository: params[:repository],
14+
head: params[:head],
15+
base: params[:base]
16+
)
17+
18+
unless existing_pr.nil?
19+
UI.message("An open Pull Request already exists for `#{params[:head]}`: #{existing_pr.html_url}")
20+
return existing_pr.html_url
21+
end
22+
23+
other_action.create_pull_request(
24+
api_url: params[:api_url],
25+
api_token: params[:github_token],
26+
repo: params[:repository],
27+
title: params[:title],
28+
body: params[:body],
29+
head: params[:head],
30+
base: params[:base],
31+
labels: params[:labels],
32+
assignees: params[:assignees],
33+
reviewers: params[:reviewers],
34+
team_reviewers: params[:team_reviewers],
35+
milestone: params[:milestone]
36+
)
37+
end
38+
39+
def self.description
40+
'Returns the URL of the open Pull Request for a head branch, creating one if none exists yet'
41+
end
42+
43+
def self.details
44+
<<~DETAILS
45+
Looks for an open Pull Request whose head is the given branch (optionally targeting a specific base),
46+
and returns its URL if found. Otherwise, creates a new Pull Request and returns its URL.
47+
48+
This is useful for "rolling" automations (e.g. a daily translations or dependency-update job) that
49+
force-push the same head branch on every run: GitHub automatically refreshes the diff of the existing
50+
PR, so this action only needs to open a PR the first time.
51+
DETAILS
52+
end
53+
54+
def self.authors
55+
['Automattic']
56+
end
57+
58+
def self.return_type
59+
:string
60+
end
61+
62+
def self.return_value
63+
'The URL of the existing or newly-created Pull Request'
64+
end
65+
66+
def self.available_options
67+
# Parameters we forward as-is from Fastlane's `create_pull_request` action
68+
forwarded_param_keys = %i[
69+
api_url
70+
labels
71+
assignees
72+
reviewers
73+
team_reviewers
74+
milestone
75+
].freeze
76+
77+
forwarded_params = Fastlane::Actions::CreatePullRequestAction.available_options.select do |opt|
78+
forwarded_param_keys.include?(opt.key)
79+
end
80+
81+
[
82+
*forwarded_params,
83+
Fastlane::Helper::GithubHelper.github_token_config_item, # forwarded to `api_token` in the `create_pull_request` action
84+
FastlaneCore::ConfigItem.new(
85+
key: :repository,
86+
env_name: 'GHHELPER_REPOSITORY',
87+
description: 'The remote path of the GH repository on which we work, e.g. `wordpress-mobile/wordpress-ios`',
88+
optional: false,
89+
type: String
90+
),
91+
FastlaneCore::ConfigItem.new(
92+
key: :title,
93+
description: 'The title of the Pull Request to create if none exists yet',
94+
optional: false,
95+
type: String
96+
),
97+
FastlaneCore::ConfigItem.new(
98+
key: :body,
99+
description: 'The body of the Pull Request to create if none exists yet',
100+
optional: true,
101+
type: String
102+
),
103+
FastlaneCore::ConfigItem.new(
104+
key: :head,
105+
description: 'The head branch of the Pull Request (the branch with the changes)',
106+
optional: false,
107+
type: String
108+
),
109+
FastlaneCore::ConfigItem.new(
110+
key: :base,
111+
description: 'The base branch the Pull Request targets (e.g. `trunk`)',
112+
optional: false,
113+
type: String
114+
),
115+
]
116+
end
117+
118+
def self.is_supported?(platform)
119+
true
120+
end
121+
end
122+
end
123+
end

lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,23 @@ def comment_on_pr(project_slug:, pr_number:, body:, reuse_identifier: SecureRand
304304
reuse_identifier
305305
end
306306

307+
# Find an existing Pull Request matching the given head (and optionally base) branch.
308+
#
309+
# @param [String] repository The repository name, including the organization (e.g. `wordpress-mobile/wordpress-ios`)
310+
# @param [String] head The head branch to look for. May be given as `branch` or as the fully-qualified `owner:branch`;
311+
# when unqualified, it is automatically prefixed with the repository's owner.
312+
# @param [String] base The base branch the PR should target. If nil, PRs targeting any base are considered.
313+
# @param [String] state The PR state to match (`open`, `closed`, or `all`). Defaults to `open`.
314+
# @return [Sawyer::Resource, nil] The first matching Pull Request, or nil if none matches.
315+
#
316+
def find_pull_request(repository:, head:, base: nil, state: 'open')
317+
qualified_head = head.include?(':') ? head : "#{repository.split('/').first}:#{head}"
318+
options = { state: state, head: qualified_head }
319+
options[:base] = base unless base.nil?
320+
321+
client.pull_requests(repository, options).first
322+
end
323+
307324
# Update a milestone for a repository
308325
#
309326
# @param [String] repository The repository name (including the organization)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
describe Fastlane::Actions::FindOrCreatePullRequestAction do
6+
let(:test_token) { 'ghp_fake_token' }
7+
let(:test_repo) { 'repo-test/project-test' }
8+
let(:test_head) { 'translations/daily-update' }
9+
let(:test_base) { 'trunk' }
10+
let(:github_helper) { instance_double(Fastlane::Helper::GithubHelper) }
11+
let(:other_action_mock) { double }
12+
13+
before do
14+
allow(Fastlane::Helper::GithubHelper).to receive(:new).with(github_token: test_token).and_return(github_helper)
15+
allow(Fastlane::Action).to receive(:other_action).and_return(other_action_mock)
16+
end
17+
18+
context 'when an open PR already exists for the head branch' do
19+
it 'returns its URL and does not create a new PR' do
20+
existing_pr = double('PullRequest', html_url: "https://github.com/#{test_repo}/pull/7") # rubocop:disable RSpec/VerifiedDoubles
21+
allow(github_helper).to receive(:find_pull_request)
22+
.with(repository: test_repo, head: test_head, base: test_base)
23+
.and_return(existing_pr)
24+
expect(other_action_mock).not_to receive(:create_pull_request)
25+
26+
result = run_described_fastlane_action(
27+
github_token: test_token,
28+
repository: test_repo,
29+
title: 'Update translations',
30+
head: test_head,
31+
base: test_base
32+
)
33+
34+
expect(result).to eq("https://github.com/#{test_repo}/pull/7")
35+
end
36+
end
37+
38+
context 'when no open PR exists for the head branch' do
39+
it 'creates a new PR forwarding the parameters and returns its URL' do
40+
allow(github_helper).to receive(:find_pull_request).and_return(nil)
41+
allow(other_action_mock).to receive(:create_pull_request).with(
42+
api_url: 'https://api.github.com',
43+
api_token: test_token,
44+
repo: test_repo,
45+
title: 'Update translations',
46+
body: 'Sync translations from GlotPress',
47+
head: test_head,
48+
base: test_base,
49+
labels: ['Localization'],
50+
assignees: nil,
51+
reviewers: nil,
52+
team_reviewers: nil,
53+
milestone: nil
54+
).and_return("https://github.com/#{test_repo}/pull/8")
55+
56+
result = run_described_fastlane_action(
57+
github_token: test_token,
58+
repository: test_repo,
59+
title: 'Update translations',
60+
body: 'Sync translations from GlotPress',
61+
head: test_head,
62+
base: test_base,
63+
labels: ['Localization']
64+
)
65+
66+
expect(result).to eq("https://github.com/#{test_repo}/pull/8")
67+
end
68+
end
69+
end

spec/github_helper_spec.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,46 @@ def download_file_from_tag(download_folder:)
4646
end
4747
end
4848

49+
describe '#find_pull_request' do
50+
let(:test_repo) { 'repo-test/project-test' }
51+
let(:found_pr) { double('PullRequest', html_url: 'https://github.com/repo-test/project-test/pull/42') } # rubocop:disable RSpec/VerifiedDoubles
52+
let(:client) do
53+
instance_double(
54+
Octokit::Client,
55+
pull_requests: [found_pr],
56+
user: instance_double('User', name: 'test'),
57+
'auto_paginate=': nil
58+
)
59+
end
60+
61+
before do
62+
allow(Octokit::Client).to receive(:new).and_return(client)
63+
end
64+
65+
it 'qualifies an unqualified head with the repository owner and forwards the base' do
66+
expect(client).to receive(:pull_requests).with(test_repo, { state: 'open', head: 'repo-test:my-branch', base: 'trunk' })
67+
find_pull_request(head: 'my-branch', base: 'trunk')
68+
end
69+
70+
it 'uses an already-qualified head as-is and omits the base when not provided' do
71+
expect(client).to receive(:pull_requests).with(test_repo, { state: 'open', head: 'someone:other-branch' })
72+
find_pull_request(head: 'someone:other-branch')
73+
end
74+
75+
it 'returns the first matching pull request' do
76+
expect(find_pull_request(head: 'my-branch')).to eq(found_pr)
77+
end
78+
79+
it 'returns nil when no pull request matches' do
80+
allow(client).to receive(:pull_requests).and_return([])
81+
expect(find_pull_request(head: 'my-branch')).to be_nil
82+
end
83+
84+
def find_pull_request(head:, base: nil)
85+
described_class.new(github_token: 'Fake-GitHubToken-123').find_pull_request(repository: test_repo, head: head, base: base)
86+
end
87+
end
88+
4989
describe '#get_last_milestone' do
5090
let(:test_repo) { 'repo-test/project-test' }
5191
let(:last_stone) { mock_milestone('10.0') }

0 commit comments

Comments
 (0)