Skip to content

Commit d909a2f

Browse files
committed
Add --assignee option to keep project PRs assigned
1 parent 8d48898 commit d909a2f

8 files changed

Lines changed: 194 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Usage: project_pull_mover [options]
6060
-h, --gh-path PATH Path to gh executable
6161
-f, --failing-test-label LABEL Name of the label to apply to a pull request that has failing required builds
6262
-u, --author AUTHOR Specify a username so that only PRs in the project authored by that user are changed
63+
-A, --assignee LOGIN Specify a username to add as an assignee to any PR in the project that does not already have that assignee
6364
-m, --mark-draft Also mark pull requests as a draft when setting them to In Progress, Not Against Main, or Conflicting status.
6465
-e Don't mark a PR as draft or move its column based on whether it has failing required builds.
6566
--ignore-failing-required-builds

lib/project_pull_mover/gh_cli.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ def remove_pull_request_label(label_name:, number:, repo_nwo:, pull_name:)
4545
`#{gh_path} pr edit #{number} --repo "#{repo_nwo}" --remove-label "#{label_name}"`
4646
end
4747

48+
sig do
49+
params(login: String, number: Integer, repo_nwo: String, pull_name: String).returns(T.nilable(String))
50+
end
51+
def add_pull_request_assignee(login:, number:, repo_nwo:, pull_name:)
52+
@logger.loading("Adding @#{login} as an assignee to #{pull_name}...") unless quiet_mode?
53+
`#{gh_path} pr edit #{number} --repo "#{repo_nwo}" --add-assignee "#{login}"`
54+
end
55+
4856
sig do
4957
params(run_id: T.untyped, repo_nwo: String, pull_name: String, build_name: T.nilable(String))
5058
.returns(T.nilable(String))

lib/project_pull_mover/options.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ def initialize(logger:, proj_items_limit: 100, pull_fields_per_query: 5, argv: A
7676
"failing required builds")
7777
opts.on("-u AUTHOR", "--author", String, "Specify a username so that only PRs in the project authored by that " \
7878
"user are changed")
79+
opts.on("-A LOGIN", "--assignee", String, "Specify a username to add as an assignee to any PR in the project " \
80+
"that does not already have that assignee")
7981
opts.on("-m", "--mark-draft", "Also mark pull requests as a draft when setting them to In Progress, " \
8082
"Not Against Main, or Conflicting status.")
8183
opts.on("-b BUILDS", "--builds-to-rerun", Array, "Case-insensitive comma-separated list of build names or " \
@@ -133,6 +135,17 @@ def author
133135
@options[:"author"]
134136
end
135137

138+
sig { returns T.nilable(String) }
139+
def assignee
140+
return @assignee if defined?(@assignee)
141+
value = @options[:"assignee"]
142+
if value
143+
value = value.strip
144+
value = nil if value.size < 1
145+
end
146+
@assignee = value
147+
end
148+
136149
sig { returns T::Array[String] }
137150
def build_names_for_rerun
138151
(@options[:"builds-to-rerun"] || []).map { |name| name.strip.downcase }

lib/project_pull_mover/pull_request.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ def graphql_field
122122
reviewDecision
123123
mergeable
124124
baseRefName
125+
assignees(first: 100) {
126+
nodes {
127+
login
128+
}
129+
}
125130
commits(last: 1) {
126131
nodes {
127132
commit {
@@ -307,6 +312,25 @@ def base_branch
307312
@gql_data["baseRefName"]
308313
end
309314

315+
sig { returns T::Array[String] }
316+
def assignees
317+
return @assignees if defined?(@assignees)
318+
@assignees = begin
319+
assignees_data = @gql_data["assignees"]
320+
if assignees_data
321+
nodes = assignees_data["nodes"] || []
322+
nodes.map { |node| node["login"] }.compact
323+
else
324+
[]
325+
end
326+
end
327+
end
328+
329+
sig { params(login: String).returns(T::Boolean) }
330+
def has_assignee?(login)
331+
assignees.map(&:downcase).include?(login.downcase)
332+
end
333+
310334
sig { returns T.nilable(T::Boolean) }
311335
def against_default_branch?
312336
@repo && base_branch == @repo.default_branch
@@ -399,6 +423,35 @@ def apply_label(label_name:)
399423
pull_name: to_s)
400424
end
401425

426+
sig { params(login: String).returns(T.nilable(String)) }
427+
def add_assignee(login:)
428+
number = self.number
429+
repo_name_with_owner = self.repo_name_with_owner
430+
unless number && repo_name_with_owner
431+
raise MissingRequiredDataError, "Unable to add assignee to #{to_s}, missing required data"
432+
end
433+
434+
@gh_cli.add_pull_request_assignee(login: login, number: number, repo_nwo: repo_name_with_owner,
435+
pull_name: to_s)
436+
end
437+
438+
sig { returns T::Boolean }
439+
def should_add_assignee?
440+
assignee = @options.assignee
441+
!!(assignee && !has_assignee?(assignee))
442+
end
443+
444+
sig { returns T.nilable(String) }
445+
def add_assignee_if_necessary
446+
if should_add_assignee?
447+
assignee = T.must(@options.assignee)
448+
add_assignee(login: assignee)
449+
return assignee
450+
end
451+
452+
nil
453+
end
454+
402455
sig { params(label_name: String).returns(T.nilable(String)) }
403456
def remove_label(label_name:)
404457
number = self.number

lib/project_pull_mover/pull_request_mover.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def run
2828
total_status_changes_by_new_status = Hash.new(0)
2929
total_labels_applied_by_name = Hash.new(0)
3030
total_labels_removed_by_name = Hash.new(0)
31+
total_assignees_added_by_login = Hash.new(0)
3132

3233
project_pulls.each do |pull|
3334
new_pull_status_option_name = pull.change_status_if_necessary
@@ -44,11 +45,17 @@ def run
4445
if removed_label_name
4546
total_labels_removed_by_name[removed_label_name] += 1
4647
end
48+
49+
added_assignee_login = pull.add_assignee_if_necessary
50+
if added_assignee_login
51+
total_assignees_added_by_login[added_assignee_login] += 1
52+
end
4753
end
4854

4955
any_changes = (total_status_changes_by_new_status.values.sum +
5056
total_labels_applied_by_name.values.sum +
51-
total_labels_removed_by_name.values.sum) > 0
57+
total_labels_removed_by_name.values.sum +
58+
total_assignees_added_by_login.values.sum) > 0
5259

5360
if any_changes
5461
message_pieces = []
@@ -71,6 +78,12 @@ def run
7178
message_pieces << "#{first_letter}emoved '#{label_name}' from #{count} #{units}"
7279
end
7380

81+
total_assignees_added_by_login.each do |login, count|
82+
units = count == 1 ? "pull request" : "pull requests"
83+
first_letter = message_pieces.size < 1 ? "A" : "a"
84+
message_pieces << "#{first_letter}dded @#{login} as an assignee to #{count} #{units}"
85+
end
86+
7487
message = message_pieces.join(", ")
7588
@logger.info(message) unless quiet_mode?
7689
send_desktop_notification(content: message, title: @data.project.title)

test/lib/project_pull_mover/gh_cli_test.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ module ProjectPullMover
3131
end
3232
end
3333

34+
describe "#add_pull_request_assignee" do
35+
it "runs gh command" do
36+
GhCli.any_instance.expects(:`).with('gh pr edit 1 --repo "foo/bar" --add-assignee "someUser"')
37+
38+
@gh_cli.add_pull_request_assignee(login: "someUser", number: 1, repo_nwo: "foo/bar", pull_name: "my test PR")
39+
40+
assert_equal "#{Logger::LOADING_PREFIX}Adding @someUser as an assignee to my test PR...\n", @out_stream.string
41+
assert_equal "", @err_stream.string
42+
end
43+
end
44+
3445
describe "#remove_pull_request_label" do
3546
it "runs gh command" do
3647
GhCli.any_instance.expects(:`).with('gh pr edit 1 --repo "foo/bar" --remove-label "foo"')

test/lib/project_pull_mover/options_test.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ module ProjectPullMover
7171
refute_predicate options, :quiet_mode?
7272
assert_empty options.ignored_option_ids
7373
assert_nil options.failing_test_label
74+
assert_nil options.assignee
7475
assert_empty options.build_names_for_rerun
7576
end
7677

@@ -87,7 +88,7 @@ module ProjectPullMover
8788
it "parses optional arguments" do
8889
argv = %w(-p 123 -o cheshire137 -t user -i inProgressId -a myNotAgainstMainId -n NeedsReviewID -r
8990
ready_to_deploy_id -c conflictingId -g ignored1,ignored2,Ignored3 -s Status -h /usr/local/bin/gh -m -f
90-
failing-test -u cheshire137 --quiet)
91+
failing-test -u cheshire137 -A someAssignee --quiet)
9192
options = Options.new(file: "project_pull_mover.rb", argv: argv, logger: @logger)
9293

9394
assert options.parse, @err_stream.string
@@ -108,6 +109,7 @@ module ProjectPullMover
108109
assert_predicate options, :quiet_mode?
109110
assert_equal "failing-test", options.failing_test_label
110111
assert_equal "cheshire137", options.author
112+
assert_equal "someAssignee", options.assignee
111113
assert_empty options.build_names_for_rerun
112114
end
113115
end

test/lib/project_pull_mover/pull_request_test.rb

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ module ProjectPullMover
2121
@gh_cli = GhCli.new(options: @options, logger: @logger)
2222
end
2323

24+
def options_with_assignee(login)
25+
argv = ["-p", @project_number.to_s, "-o", @project_owner, "-t", "user", "-s", @status_field, "-i",
26+
"MyInProgressID", "-h", "gh", "-A", login]
27+
Options.parse(file: "project_pull_mover.rb", argv: argv, logger: @logger)
28+
end
29+
2430
describe "#set_graphql_data" do
2531
it "initializes repo and data from GraphQL response" do
2632
initial_data = {}
@@ -99,6 +105,90 @@ module ProjectPullMover
99105
end
100106
end
101107

108+
describe "#assignees" do
109+
it "returns assignee logins from GraphQL data" do
110+
pull = PullRequest.new({}, options: @options, project: @project, gh_cli: @gh_cli)
111+
pull.set_graphql_data({"pullRequest" => {"assignees" => {"nodes" => [{"login" => "userA"},
112+
{"login" => "userB"}]}}})
113+
114+
assert_equal %w(userA userB), pull.assignees
115+
end
116+
117+
it "returns an empty array when there is no assignee data" do
118+
pull = PullRequest.new({}, options: @options, project: @project, gh_cli: @gh_cli)
119+
pull.set_graphql_data({"pullRequest" => {}})
120+
121+
assert_empty pull.assignees
122+
end
123+
end
124+
125+
describe "#has_assignee?" do
126+
it "returns true when the login is already an assignee, case-insensitively" do
127+
pull = PullRequest.new({}, options: @options, project: @project, gh_cli: @gh_cli)
128+
pull.set_graphql_data({"pullRequest" => {"assignees" => {"nodes" => [{"login" => "userA"}]}}})
129+
130+
assert pull.has_assignee?("USERA")
131+
refute pull.has_assignee?("userB")
132+
end
133+
end
134+
135+
describe "#should_add_assignee?" do
136+
it "returns false when no assignee option is set" do
137+
pull = PullRequest.new({}, options: @options, project: @project, gh_cli: @gh_cli)
138+
pull.set_graphql_data({"pullRequest" => {"assignees" => {"nodes" => []}}})
139+
140+
refute_predicate pull, :should_add_assignee?
141+
end
142+
143+
it "returns true when assignee option is set and the PR lacks that assignee" do
144+
options = options_with_assignee("userB")
145+
pull = PullRequest.new({}, options: options, project: @project, gh_cli: @gh_cli)
146+
pull.set_graphql_data({"pullRequest" => {"assignees" => {"nodes" => [{"login" => "userA"}]}}})
147+
148+
assert_predicate pull, :should_add_assignee?
149+
end
150+
151+
it "returns false when the PR already has that assignee" do
152+
options = options_with_assignee("userA")
153+
pull = PullRequest.new({}, options: options, project: @project, gh_cli: @gh_cli)
154+
pull.set_graphql_data({"pullRequest" => {"assignees" => {"nodes" => [{"login" => "userA"}]}}})
155+
156+
refute_predicate pull, :should_add_assignee?
157+
end
158+
end
159+
160+
describe "#add_assignee_if_necessary" do
161+
it "adds the assignee and returns the login when the PR lacks that assignee" do
162+
options = options_with_assignee("userB")
163+
initial_data = {"content" => {"repository" => "foo/bar", "number" => 5}}
164+
pull = PullRequest.new(initial_data, options: options, project: @project, gh_cli: @gh_cli)
165+
pull.set_graphql_data({"pullRequest" => {"assignees" => {"nodes" => [{"login" => "userA"}]}}})
166+
@gh_cli.expects(:add_pull_request_assignee).once.with(login: "userB", number: 5, repo_nwo: "foo/bar",
167+
pull_name: "foo/bar#5")
168+
169+
assert_equal "userB", pull.add_assignee_if_necessary
170+
end
171+
172+
it "does nothing and returns nil when the PR already has that assignee" do
173+
options = options_with_assignee("userA")
174+
initial_data = {"content" => {"repository" => "foo/bar", "number" => 5}}
175+
pull = PullRequest.new(initial_data, options: options, project: @project, gh_cli: @gh_cli)
176+
pull.set_graphql_data({"pullRequest" => {"assignees" => {"nodes" => [{"login" => "userA"}]}}})
177+
@gh_cli.expects(:add_pull_request_assignee).never
178+
179+
assert_nil pull.add_assignee_if_necessary
180+
end
181+
182+
it "does nothing and returns nil when no assignee option is set" do
183+
initial_data = {"content" => {"repository" => "foo/bar", "number" => 5}}
184+
pull = PullRequest.new(initial_data, options: @options, project: @project, gh_cli: @gh_cli)
185+
pull.set_graphql_data({"pullRequest" => {"assignees" => {"nodes" => []}}})
186+
@gh_cli.expects(:add_pull_request_assignee).never
187+
188+
assert_nil pull.add_assignee_if_necessary
189+
end
190+
end
191+
102192
describe "#repo_name_with_owner" do
103193
it "returns full repository name and owner from initial data" do
104194
initial_data = {"content" => {"repository" => "someone/somerepo"}}
@@ -211,6 +301,7 @@ module ProjectPullMover
211301
assert_includes result, "pullRequest(number: 123) {"
212302
assert_includes result, "isRequired(pullRequestNumber: 123)"
213303
assert_includes result, "fieldValueByName(name: \"#{@status_field}\") {"
304+
assert_includes result, "assignees(first: 100) {"
214305
end
215306

216307
it "raises error when repo details or number are missing" do

0 commit comments

Comments
 (0)