Skip to content

Commit de7ff74

Browse files
authored
Add find_or_create_pull_request action (#733)
2 parents 462f587 + dd96bb5 commit de7ff74

5 files changed

Lines changed: 271 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. [#733]
1414

1515
### Bug Fixes
1616

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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+
draft: params[:draft],
30+
head: params[:head],
31+
base: params[:base],
32+
labels: params[:labels],
33+
assignees: params[:assignees],
34+
reviewers: params[:reviewers],
35+
team_reviewers: params[:team_reviewers],
36+
milestone: params[:milestone]
37+
)
38+
end
39+
40+
def self.description
41+
'Returns the URL of the open Pull Request for a head branch, creating one if none exists yet'
42+
end
43+
44+
def self.details
45+
<<~DETAILS
46+
Looks for an open Pull Request whose head is the given branch and which targets the given base,
47+
and returns its URL if found. Otherwise, creates a new Pull Request and returns its URL.
48+
49+
This is useful for "rolling" automations (e.g. a daily translations or dependency-update job) that
50+
force-push the same head branch on every run: GitHub automatically refreshes the diff of the existing
51+
PR, so this action only needs to open a PR the first time.
52+
DETAILS
53+
end
54+
55+
def self.authors
56+
['Automattic']
57+
end
58+
59+
def self.return_type
60+
:string
61+
end
62+
63+
def self.return_value
64+
'The URL of the existing or newly-created Pull Request'
65+
end
66+
67+
def self.available_options
68+
# Parameters we forward as-is from Fastlane's `create_pull_request` action
69+
forwarded_param_keys = %i[
70+
api_url
71+
draft
72+
labels
73+
assignees
74+
reviewers
75+
team_reviewers
76+
milestone
77+
].freeze
78+
79+
forwarded_params = Fastlane::Actions::CreatePullRequestAction.available_options.select do |opt|
80+
forwarded_param_keys.include?(opt.key)
81+
end
82+
83+
[
84+
*forwarded_params,
85+
Fastlane::Helper::GithubHelper.github_token_config_item, # forwarded to `api_token` in the `create_pull_request` action
86+
FastlaneCore::ConfigItem.new(
87+
key: :repository,
88+
env_name: 'GHHELPER_REPOSITORY',
89+
description: 'The remote path of the GH repository on which we work, e.g. `wordpress-mobile/wordpress-ios`',
90+
optional: false,
91+
type: String
92+
),
93+
FastlaneCore::ConfigItem.new(
94+
key: :title,
95+
description: 'The title of the Pull Request to create if none exists yet',
96+
optional: false,
97+
type: String
98+
),
99+
FastlaneCore::ConfigItem.new(
100+
key: :body,
101+
description: 'The body of the Pull Request to create if none exists yet',
102+
optional: true,
103+
type: String
104+
),
105+
FastlaneCore::ConfigItem.new(
106+
key: :head,
107+
description: 'The head branch of the Pull Request (the branch with the changes)',
108+
optional: false,
109+
type: String
110+
),
111+
FastlaneCore::ConfigItem.new(
112+
key: :base,
113+
description: 'The base branch the Pull Request targets (e.g. `trunk`)',
114+
optional: false,
115+
type: String
116+
),
117+
]
118+
end
119+
120+
def self.is_supported?(platform)
121+
true
122+
end
123+
end
124+
end
125+
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: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
draft: nil,
48+
head: test_head,
49+
base: test_base,
50+
labels: ['Localization'],
51+
assignees: nil,
52+
reviewers: nil,
53+
team_reviewers: nil,
54+
milestone: nil
55+
).and_return("https://github.com/#{test_repo}/pull/8")
56+
57+
result = run_described_fastlane_action(
58+
github_token: test_token,
59+
repository: test_repo,
60+
title: 'Update translations',
61+
body: 'Sync translations from GlotPress',
62+
head: test_head,
63+
base: test_base,
64+
labels: ['Localization']
65+
)
66+
67+
expect(result).to eq("https://github.com/#{test_repo}/pull/8")
68+
end
69+
70+
it 'forwards draft: true when creating a draft PR' do
71+
allow(github_helper).to receive(:find_pull_request).and_return(nil)
72+
allow(other_action_mock).to receive(:create_pull_request)
73+
.with(hash_including(draft: true))
74+
.and_return("https://github.com/#{test_repo}/pull/9")
75+
76+
result = run_described_fastlane_action(
77+
github_token: test_token,
78+
repository: test_repo,
79+
title: 'Update translations',
80+
head: test_head,
81+
base: test_base,
82+
draft: true
83+
)
84+
85+
expect(result).to eq("https://github.com/#{test_repo}/pull/9")
86+
end
87+
end
88+
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)