Skip to content

Commit 49eb174

Browse files
iHiDclaude
andauthored
Sanitize title in GitHub issue search to prevent UnprocessableEntity errors (#8418)
Curly braces in error messages (e.g. KeyError) break GitHub's search query syntax, causing Octokit::UnprocessableEntity and silently preventing issues from being opened. Strip {} from the title used in search queries and rescue UnprocessableEntity as a fallback. Closes #8367 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a0c52f1 commit 49eb174

2 files changed

Lines changed: 124 additions & 1 deletion

File tree

app/commands/github/issue/open.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ def reopen_issue
3535
memoize
3636
def issue
3737
author = Exercism.config.github_bot_username
38-
Exercism.octokit_client.search_issues("\"#{title}\" is:issue in:title repo:#{repo} author:#{author}")[:items]&.first
38+
sanitized_title = title.gsub(/[{}]/, '')
39+
Exercism.octokit_client.search_issues(
40+
"\"#{sanitized_title}\" is:issue in:title repo:#{repo} author:#{author}"
41+
)[:items]&.first
42+
rescue Octokit::UnprocessableEntity
43+
nil
3944
end
4045
end
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
require "test_helper"
2+
3+
class Github::Issue::OpenTest < ActiveSupport::TestCase
4+
setup do
5+
Exercism.config.stubs(:github_bot_username).returns('exercism-bot')
6+
end
7+
8+
test "opens issue when issue was not yet created" do
9+
stub_request(:get, "https://api.github.com/search/issues?per_page=100&q=%22Test%20title%22%20is:issue%20in:title%20repo:exercism/ruby-analyzer%20author:exercism-bot"). # rubocop:disable Layout/LineLength
10+
to_return(
11+
status: 200,
12+
body: {
13+
total_count: 0,
14+
incomplete_results: false,
15+
items: []
16+
}.to_json,
17+
headers: { 'Content-Type': 'application/json' }
18+
)
19+
20+
stub_request(:post, "https://api.github.com/repos/exercism/ruby-analyzer/issues").
21+
with do |request|
22+
json = JSON.parse(request.body)
23+
json["title"] == "Test title" &&
24+
json["body"] == "Test body"
25+
end.
26+
to_return(status: 200, body: "", headers: {}).
27+
times(1)
28+
29+
Github::Issue::Open.("exercism/ruby-analyzer", "Test title", "Test body")
30+
end
31+
32+
test "re-opens issue when issue was closed" do
33+
stub_request(:get, "https://api.github.com/search/issues?per_page=100&q=%22Test%20title%22%20is:issue%20in:title%20repo:exercism/ruby-analyzer%20author:exercism-bot"). # rubocop:disable Layout/LineLength
34+
to_return(
35+
status: 200,
36+
body: {
37+
total_count: 1,
38+
incomplete_results: false,
39+
items: [{
40+
number: 42,
41+
state: "closed"
42+
}]
43+
}.to_json,
44+
headers: { 'Content-Type': 'application/json' }
45+
)
46+
47+
stub_request(:patch, "https://api.github.com/repos/exercism/ruby-analyzer/issues/42").
48+
with(body: { state: "open" }.to_json).
49+
to_return(status: 200, body: "", headers: {}).
50+
times(1)
51+
52+
Github::Issue::Open.("exercism/ruby-analyzer", "Test title", "Test body")
53+
end
54+
55+
test "does nothing when issue already open" do
56+
stub_request(:get, "https://api.github.com/search/issues?per_page=100&q=%22Test%20title%22%20is:issue%20in:title%20repo:exercism/ruby-analyzer%20author:exercism-bot"). # rubocop:disable Layout/LineLength
57+
to_return(
58+
status: 200,
59+
body: {
60+
total_count: 1,
61+
incomplete_results: false,
62+
items: [{
63+
number: 42,
64+
state: "open"
65+
}]
66+
}.to_json,
67+
headers: { 'Content-Type': 'application/json' }
68+
)
69+
70+
Github::Issue::Open.("exercism/ruby-analyzer", "Test title", "Test body")
71+
72+
# If the GitHub API would have been called to create/reopen, we would not have gotten to this point
73+
end
74+
75+
test "sanitizes curly braces from title in search query" do
76+
stub_request(:get, "https://api.github.com/search/issues?per_page=100&q=%22keymentoring_request_url%20not%20found%22%20is:issue%20in:title%20repo:exercism/ruby-analyzer%20author:exercism-bot"). # rubocop:disable Layout/LineLength
77+
to_return(
78+
status: 200,
79+
body: {
80+
total_count: 0,
81+
incomplete_results: false,
82+
items: []
83+
}.to_json,
84+
headers: { 'Content-Type': 'application/json' }
85+
)
86+
87+
stub_request(:post, "https://api.github.com/repos/exercism/ruby-analyzer/issues").
88+
with do |request|
89+
json = JSON.parse(request.body)
90+
json["title"] == "key{mentoring_request_url} not found"
91+
end.
92+
to_return(status: 200, body: "", headers: {}).
93+
times(1)
94+
95+
Github::Issue::Open.("exercism/ruby-analyzer", "key{mentoring_request_url} not found", "backtrace")
96+
end
97+
98+
test "creates issue when search returns UnprocessableEntity" do
99+
stub_request(:get, %r{api\.github\.com/search/issues}).
100+
to_return(status: 422, body: "", headers: {})
101+
102+
stub_request(:post, "https://api.github.com/repos/exercism/ruby-analyzer/issues").
103+
to_return(status: 200, body: "", headers: {}).
104+
times(1)
105+
106+
Github::Issue::Open.("exercism/ruby-analyzer", "key{mentoring_request_url} not found", "backtrace")
107+
end
108+
109+
test "does nothing in development environment" do
110+
# Unstub github_bot_username since it won't be called (early return)
111+
Exercism.config.unstub(:github_bot_username)
112+
Rails.env.stubs(:development?).returns(true)
113+
114+
Github::Issue::Open.("exercism/ruby-analyzer", "Test title", "Test body")
115+
116+
# No API calls should be made - if they were, WebMock would raise
117+
end
118+
end

0 commit comments

Comments
 (0)