pull_request_ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +125 -0
- data/Rakefile +10 -0
- data/app/assets/config/pull_request_ai_manifest.js +1 -0
- data/app/assets/javascripts/application.js +248 -0
- data/app/assets/javascripts/notifications.js +76 -0
- data/app/assets/stylesheets/pull_request_ai/application.css +15 -0
- data/app/assets/stylesheets/pull_request_ai/blocs.css +4 -0
- data/app/assets/stylesheets/pull_request_ai/helpers.css +26 -0
- data/app/assets/stylesheets/pull_request_ai/inputs.css +64 -0
- data/app/assets/stylesheets/pull_request_ai/notification.css +71 -0
- data/app/assets/stylesheets/pull_request_ai/spinner.css +24 -0
- data/app/controllers/pull_request_ai/application_controller.rb +6 -0
- data/app/controllers/pull_request_ai/pull_request_ai_controller.rb +85 -0
- data/app/helpers/pull_request_ai/application_helper.rb +6 -0
- data/app/models/pull_request_ai/application_record.rb +7 -0
- data/app/views/layouts/pull_request_ai/application.html.erb +29 -0
- data/app/views/pull_request_ai/pull_request_ai/new.html.erb +70 -0
- data/config/initializers/rack_attack.rb +30 -0
- data/config/routes.rb +13 -0
- data/lib/pull_request_ai/bitbucket/client.rb +148 -0
- data/lib/pull_request_ai/client.rb +80 -0
- data/lib/pull_request_ai/engine.rb +10 -0
- data/lib/pull_request_ai/github/client.rb +124 -0
- data/lib/pull_request_ai/openAi/client.rb +81 -0
- data/lib/pull_request_ai/openAi/interpreter.rb +19 -0
- data/lib/pull_request_ai/repo/client.rb +43 -0
- data/lib/pull_request_ai/repo/file.rb +20 -0
- data/lib/pull_request_ai/repo/prompt.rb +33 -0
- data/lib/pull_request_ai/repo/reader.rb +118 -0
- data/lib/pull_request_ai/util/configuration.rb +49 -0
- data/lib/pull_request_ai/util/error.rb +28 -0
- data/lib/pull_request_ai/util/symbol_details.rb +14 -0
- data/lib/pull_request_ai/version.rb +5 -0
- data/lib/pull_request_ai.rb +52 -0
- data/lib/tasks/pull_request_ai_tasks.rake +5 -0
- metadata +153 -0
@@ -0,0 +1,24 @@
|
|
1
|
+
.spinner {
|
2
|
+
width: 24px;
|
3
|
+
height: 24px;
|
4
|
+
border: 5px solid;
|
5
|
+
border-color: #3d898b transparent #3d898b transparent;
|
6
|
+
border-radius: 50%;
|
7
|
+
animation: spin-anim 1.2s linear infinite;
|
8
|
+
}
|
9
|
+
|
10
|
+
@keyframes spin-anim {
|
11
|
+
0% { transform: rotate(0deg); }
|
12
|
+
100% { transform: rotate(360deg); }
|
13
|
+
}
|
14
|
+
|
15
|
+
.loading-container {
|
16
|
+
width: 100%;
|
17
|
+
height: 100vh;
|
18
|
+
display: flex;
|
19
|
+
justify-content: center;
|
20
|
+
align-items: center;
|
21
|
+
position: fixed;
|
22
|
+
background: #000;
|
23
|
+
z-index: 1;
|
24
|
+
}
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_dependency 'pull_request_ai/application_controller'
|
4
|
+
|
5
|
+
module PullRequestAi
|
6
|
+
class PullRequestAiController < ApplicationController
|
7
|
+
def new
|
8
|
+
@types = [['Feature', :feature], ['Release', :release], ['HotFix', :hotfix]]
|
9
|
+
|
10
|
+
client.destination_branches.fmap do |branches|
|
11
|
+
@branches = branches
|
12
|
+
end.or do |error|
|
13
|
+
@error_message = error.description
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def prepare
|
18
|
+
client.flatten_current_changes(prepare_params[:branch]).fmap do |changes|
|
19
|
+
if changes.empty?
|
20
|
+
render(
|
21
|
+
json: { notice: SYMBOL_DETAILS[:no_changes_btween_branches] },
|
22
|
+
status: :unprocessable_entity
|
23
|
+
)
|
24
|
+
else
|
25
|
+
client.suggested_description(prepare_params[:type], prepare_params[:summary], changes).fmap do |description|
|
26
|
+
response = { description: description }
|
27
|
+
client.current_opened_pull_requests(prepare_params[:branch]).fmap do |open_prs|
|
28
|
+
response[:remote_enabled] = true
|
29
|
+
response[:open_pr] = open_prs.first unless open_prs.empty?
|
30
|
+
render(json: response)
|
31
|
+
end.or do |_|
|
32
|
+
response[:remote_enabled] = false
|
33
|
+
render(json: response)
|
34
|
+
end
|
35
|
+
end.or do |error|
|
36
|
+
render(json: { errors: error.description }, status: :unprocessable_entity)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end.or do |error|
|
40
|
+
render(json: { errors: error.description }, status: :unprocessable_entity)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def create
|
45
|
+
result = client.open_pull_request(
|
46
|
+
pr_params[:branch],
|
47
|
+
pr_params[:title],
|
48
|
+
pr_params[:description]
|
49
|
+
)
|
50
|
+
proccess_result(result)
|
51
|
+
end
|
52
|
+
|
53
|
+
def update
|
54
|
+
result = client.update_pull_request(
|
55
|
+
pr_params[:number],
|
56
|
+
pr_params[:branch],
|
57
|
+
pr_params[:title],
|
58
|
+
pr_params[:description]
|
59
|
+
)
|
60
|
+
proccess_result(result)
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def proccess_result(result)
|
66
|
+
result.fmap do |details|
|
67
|
+
render(json: details)
|
68
|
+
end.or do |error|
|
69
|
+
render(json: { errors: error.description }, status: :unprocessable_entity)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def client
|
74
|
+
@client ||= PullRequestAi::Client.new
|
75
|
+
end
|
76
|
+
|
77
|
+
def prepare_params
|
78
|
+
params.require(:pull_request_ai).permit(:branch, :type, :summary)
|
79
|
+
end
|
80
|
+
|
81
|
+
def pr_params
|
82
|
+
params.require(:pull_request_ai).permit(:number, :branch, :type, :title, :description)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>Pull request ai</title>
|
5
|
+
<%= csrf_meta_tags %>
|
6
|
+
<%= csp_meta_tag %>
|
7
|
+
|
8
|
+
<link rel='stylesheet' href='https://cdn.jsdelivr.net/gh/runtimerevolution/rrtools/dist/assets/styleshhets/rrtools@1.0.0-d8d9aea14840c97f19edec7377493c8c.min.css'>
|
9
|
+
<%= stylesheet_link_tag 'pull_request_ai/application', media: 'all' %>
|
10
|
+
</head>
|
11
|
+
<body>
|
12
|
+
<div class='wrapper'>
|
13
|
+
<% if PullRequestAi.configuration.rrtools_grouped_gems.size.positive? %>
|
14
|
+
<nav>
|
15
|
+
<header>RRTools</header>
|
16
|
+
<ul>
|
17
|
+
<li><span>Tools</span></li>
|
18
|
+
<% PullRequestAi.configuration.rrtools_grouped_gems.each do |menu| %>
|
19
|
+
<li class='active'><a href='<%= menu[:path] %>'><%= menu[:name].humanize %></a></li>
|
20
|
+
<% end %>
|
21
|
+
</ul>
|
22
|
+
</nav>
|
23
|
+
<% end %>
|
24
|
+
<main>
|
25
|
+
<%= yield %>
|
26
|
+
</main>
|
27
|
+
</div>
|
28
|
+
</body>
|
29
|
+
</html>
|
@@ -0,0 +1,70 @@
|
|
1
|
+
<div class='clear'>
|
2
|
+
<h2 class='mt-10'><strong>Pull Request AI</strong></h2>
|
3
|
+
|
4
|
+
<div class='flex mt-10 mh-40 w-760'>
|
5
|
+
<p id='error-field' class='mt-10 red'><%= @error_message %></p>
|
6
|
+
|
7
|
+
<div id='loading-container' class='hide'>
|
8
|
+
<div class='spinner'></div>
|
9
|
+
</div>
|
10
|
+
</div>
|
11
|
+
|
12
|
+
<div id='prepare-container' class=<%= 'hide' if @error_message %>>
|
13
|
+
<div class='flex'>
|
14
|
+
<div class='mr-10 fw'>
|
15
|
+
<%= label_tag :branch_title, 'Destination Branch*' %>
|
16
|
+
<%= select_tag :branch, options_for_select(@branches || []), { id: 'branch-field', class: 'block mt-10 fw' } %>
|
17
|
+
</div>
|
18
|
+
<div class='ml-10 fw'>
|
19
|
+
<%= label_tag :type_title, 'Pull Request Type' %>
|
20
|
+
<%= select_tag :type, options_for_select(@types), { id: 'type-field', class: 'block mt-10 fw' } %>
|
21
|
+
</div>
|
22
|
+
</div>
|
23
|
+
<p class='mt-10 w-760'>* You may notice more branches listed than actually exist. To update your local repository with the remote, you can run `git fetch --prune`. However, before doing so, make sure to consider if this is the appropriate action for your needs.</p>
|
24
|
+
<div class='mt-20'>
|
25
|
+
<%= label_tag :summary_title, 'Summary (Optional)' %>
|
26
|
+
<%= text_area_tag :summary, '', { id: 'summary-field', class: 'block mt-5 h-100' } %>
|
27
|
+
</div>
|
28
|
+
</div>
|
29
|
+
|
30
|
+
<div class='flex'>
|
31
|
+
<%= button_tag 'Reload', { id: 'reload-btn', class: "mt-20#{@error_message ? '' : ' hide'}" } %>
|
32
|
+
<%= button_tag 'Request Description', { id: 'request-description-btn', class: "mt-20#{@error_message ? ' hide' : ''}" } %>
|
33
|
+
</div>
|
34
|
+
|
35
|
+
<div id='chat-description-container' class='hide mt-40'>
|
36
|
+
<%= label_tag :chat_description_title, 'ChatGPT Description' %>
|
37
|
+
<%= text_area_tag :chat_description, '', { id: 'chat-description-field', class: 'block mt-5 h-220' } %>
|
38
|
+
|
39
|
+
<div class='flex mt-20'>
|
40
|
+
<%= button_tag 'Copy to Description', { id: 'copy-chat-to-description-btn', class: 'mr-20' } %>
|
41
|
+
<%= button_tag 'Copy to Clipboard', { id: 'copy-chat-to-clipboard-btn' } %>
|
42
|
+
</div>
|
43
|
+
</div>
|
44
|
+
|
45
|
+
<div id='pull-request-container' class='hide mt-40'>
|
46
|
+
<h3>Pull Request</h3>
|
47
|
+
|
48
|
+
<%= hidden_field_tag :pull_request_number, '', { id: 'pull-request-number-field' } %>
|
49
|
+
|
50
|
+
<div class='mt-20'>
|
51
|
+
<%= label_tag :pull_request_title_title, 'Title' %>
|
52
|
+
<%= text_field_tag :pull_request_title, '', { id: 'pull-request-title-field', class: 'block mt-5 fw' } %>
|
53
|
+
</div>
|
54
|
+
|
55
|
+
<div class='mt-20'>
|
56
|
+
<%= label_tag :pull_request_description_title, 'Description' %>
|
57
|
+
<%= text_area_tag :pull_request_description, '', { id: 'pull-request-description-field', class: 'block mt-5 h-220' } %>
|
58
|
+
</div>
|
59
|
+
|
60
|
+
<div class='mt-20'>
|
61
|
+
<%= button_tag 'Create Pull Request', { id: 'create-pull-request-btn', class: 'mr-10' } %>
|
62
|
+
<%= button_tag 'Update Pull Request', { id: 'update-pull-request-btn', class: 'mr-10' } %>
|
63
|
+
<%= button_tag 'Copy to Clipboard', { id: 'copy-description-to-clipboard-btn', class: 'ml-10' } %>
|
64
|
+
<%= button_tag 'Pull Request Website', { id: 'pull-request-website-btn', class: 'ml-10' } %>
|
65
|
+
</div>
|
66
|
+
|
67
|
+
</div>
|
68
|
+
</div>
|
69
|
+
|
70
|
+
<%= javascript_include_tag 'application.js' %>
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rack
|
4
|
+
class Attack
|
5
|
+
REQUEST_LIMIT = 5
|
6
|
+
LIMIT_PERIOD = 20.seconds
|
7
|
+
|
8
|
+
PROTECTED_ACTIONS = [
|
9
|
+
{ controller: 'pull_request_ai/pull_request_ai', action: 'prepare' },
|
10
|
+
{ controller: 'pull_request_ai/pull_request_ai', action: 'create' },
|
11
|
+
{ controller: 'pull_request_ai/pull_request_ai', action: 'update' }
|
12
|
+
]
|
13
|
+
|
14
|
+
throttle('api_request', limit: REQUEST_LIMIT, period: LIMIT_PERIOD) do |request|
|
15
|
+
request.ip if protected_route?(request.path, request.request_method)
|
16
|
+
end
|
17
|
+
|
18
|
+
class << self
|
19
|
+
def protected_route?(path, method)
|
20
|
+
route_params = Rails.application.routes.recognize_path(path, method: method)
|
21
|
+
|
22
|
+
PROTECTED_ACTIONS.any? do |hash|
|
23
|
+
hash[:controller] == route_params[:controller] && hash[:action] == route_params[:action]
|
24
|
+
end
|
25
|
+
rescue ActionController::RoutingError
|
26
|
+
false
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/config/routes.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
PullRequestAi::Engine.routes.draw do
|
4
|
+
scope :rrtools do
|
5
|
+
scope path: 'pull_request_ai' do
|
6
|
+
root 'pull_request_ai#new'
|
7
|
+
|
8
|
+
post 'prepare', to: 'pull_request_ai#prepare', as: 'pull_request_ai_prepare'
|
9
|
+
post 'create', to: 'pull_request_ai#create', as: 'pull_request_ai_create'
|
10
|
+
post 'update', to: 'pull_request_ai#update', as: 'pull_request_ai_update'
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PullRequestAi
|
4
|
+
module Bitbucket
|
5
|
+
# A client to communicate with the Bitbucket API.
|
6
|
+
class Client < PullRequestAi::Repo::Client
|
7
|
+
attr_accessor :app_password
|
8
|
+
attr_accessor :username
|
9
|
+
|
10
|
+
##
|
11
|
+
# Initializes the client.
|
12
|
+
def initialize(
|
13
|
+
http_timeout: nil,
|
14
|
+
api_endpoint: nil,
|
15
|
+
app_password: nil,
|
16
|
+
username: nil
|
17
|
+
)
|
18
|
+
super(
|
19
|
+
http_timeout || PullRequestAi.http_timeout,
|
20
|
+
api_endpoint || PullRequestAi.bitbucket_api_endpoint
|
21
|
+
)
|
22
|
+
@app_password = app_password || PullRequestAi.bitbucket_app_password
|
23
|
+
@username = username || PullRequestAi.bitbucket_username
|
24
|
+
end
|
25
|
+
|
26
|
+
##
|
27
|
+
# Requests the list of Open Pull Requests using the Bitbucket API.
|
28
|
+
# The slug combines the repository owner name and the repository name.
|
29
|
+
# The query contains the source and destination to filter the results.
|
30
|
+
#
|
31
|
+
# https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-get
|
32
|
+
def opened_pull_requests(slug, head, base)
|
33
|
+
query = {
|
34
|
+
q: "source.branch.name = \"#{head}\" AND destination.branch.name = \"#{base}\""
|
35
|
+
}
|
36
|
+
url = build_url(slug)
|
37
|
+
request(:get, url, query, {}).bind do |open_prs|
|
38
|
+
if open_prs.empty? || open_prs['values'].empty?
|
39
|
+
Dry::Monads::Success([])
|
40
|
+
else
|
41
|
+
result = open_prs['values'].map do |pr|
|
42
|
+
parsed_pr_details(pr)
|
43
|
+
end
|
44
|
+
Dry::Monads::Success(result)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
##
|
50
|
+
# Request to update the existing Pull Request using the Bitbucket API.
|
51
|
+
# The slug combines the repository owner name and the repository name.
|
52
|
+
# It requires the Pull Request id to modify it. The destination, title, and description can be modified.
|
53
|
+
# Notice:
|
54
|
+
# We don't have logic to change the base on the UI.
|
55
|
+
# https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-pull-request-id-put
|
56
|
+
def update_pull_request(slug, number, base, title, description)
|
57
|
+
body = {
|
58
|
+
title: title,
|
59
|
+
destination: {
|
60
|
+
branch: {
|
61
|
+
name: base
|
62
|
+
}
|
63
|
+
},
|
64
|
+
description: description
|
65
|
+
}.to_json
|
66
|
+
url = build_url(slug, "/#{number}")
|
67
|
+
request(:put, url, {}, body).bind do |pr|
|
68
|
+
Dry::Monads::Success(parsed_pr_details(pr))
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
##
|
73
|
+
# Request to open a new Pull Request using the GitHub API.
|
74
|
+
# The slug combines the repository owner name and the repository name.
|
75
|
+
# It requires the head (destination branch), the base (current branch), the title, and a optional description.
|
76
|
+
# https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-post
|
77
|
+
def open_pull_request(slug, head, base, title, description)
|
78
|
+
body = {
|
79
|
+
title: title,
|
80
|
+
source: {
|
81
|
+
branch: {
|
82
|
+
name: head
|
83
|
+
}
|
84
|
+
},
|
85
|
+
destination: {
|
86
|
+
branch: {
|
87
|
+
name: base
|
88
|
+
}
|
89
|
+
},
|
90
|
+
description: description
|
91
|
+
}.to_json
|
92
|
+
url = build_url(slug)
|
93
|
+
request(:post, url, {}, body).bind do |pr|
|
94
|
+
Dry::Monads::Success(parsed_pr_details(pr))
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def parsed_pr_details(details)
|
101
|
+
{
|
102
|
+
number: details['id'],
|
103
|
+
title: details['title'],
|
104
|
+
description: details['description'] || '',
|
105
|
+
link: details.dig('links', 'html', 'href') || ''
|
106
|
+
}
|
107
|
+
end
|
108
|
+
|
109
|
+
def request(type, url, query, body)
|
110
|
+
response = HTTParty.send(
|
111
|
+
type,
|
112
|
+
url,
|
113
|
+
headers: headers,
|
114
|
+
query: query,
|
115
|
+
body: body,
|
116
|
+
timeout: http_timeout,
|
117
|
+
basic_auth: basic_auth
|
118
|
+
)
|
119
|
+
|
120
|
+
if response.success?
|
121
|
+
Dry::Monads::Success(response.parsed_response)
|
122
|
+
else
|
123
|
+
error = response.parsed_response.dig('error', 'message')
|
124
|
+
Error.failure(:failed_on_bitbucket_api_endpoint, error.to_s.empty? ? nil : error)
|
125
|
+
end
|
126
|
+
rescue Net::ReadTimeout
|
127
|
+
Error.failure(:connection_timeout)
|
128
|
+
end
|
129
|
+
|
130
|
+
def build_url(slug, suffix = '')
|
131
|
+
"#{api_endpoint}/2.0/repositories/#{slug}/pullrequests#{suffix}"
|
132
|
+
end
|
133
|
+
|
134
|
+
def headers
|
135
|
+
{
|
136
|
+
'Content-Type' => 'application/json'
|
137
|
+
}
|
138
|
+
end
|
139
|
+
|
140
|
+
def basic_auth
|
141
|
+
{
|
142
|
+
username: username,
|
143
|
+
password: app_password
|
144
|
+
}
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PullRequestAi
|
4
|
+
class Client
|
5
|
+
def initialize(
|
6
|
+
openai_api_key: nil,
|
7
|
+
openai_api_endpoint: nil,
|
8
|
+
github_api_endpoint: nil,
|
9
|
+
github_access_token: nil,
|
10
|
+
bitbucket_api_endpoint: nil,
|
11
|
+
bitbucket_app_password: nil,
|
12
|
+
bitbucket_username: nil,
|
13
|
+
api_version: nil,
|
14
|
+
model: nil,
|
15
|
+
temperature: nil
|
16
|
+
)
|
17
|
+
PullRequestAi.configuration.openai_api_key = openai_api_key if openai_api_key
|
18
|
+
PullRequestAi.configuration.openai_api_endpoint = openai_api_endpoint if openai_api_endpoint
|
19
|
+
PullRequestAi.configuration.github_api_endpoint = github_api_endpoint if github_api_endpoint
|
20
|
+
PullRequestAi.configuration.github_access_token = github_access_token if github_access_token
|
21
|
+
PullRequestAi.configuration.bitbucket_api_endpoint = bitbucket_api_endpoint if bitbucket_api_endpoint
|
22
|
+
PullRequestAi.configuration.bitbucket_app_password = bitbucket_app_password if bitbucket_app_password
|
23
|
+
PullRequestAi.configuration.bitbucket_username = bitbucket_username if bitbucket_username
|
24
|
+
PullRequestAi.configuration.api_version = api_version if api_version
|
25
|
+
PullRequestAi.configuration.model = model if model
|
26
|
+
PullRequestAi.configuration.temperature = temperature if temperature
|
27
|
+
end
|
28
|
+
|
29
|
+
def repo_reader
|
30
|
+
@repo_reader ||= PullRequestAi::Repo::Reader.new
|
31
|
+
end
|
32
|
+
|
33
|
+
def repo_client
|
34
|
+
@repo_client ||= PullRequestAi::Repo::Client.client_from_host(repo_reader.repository_host)
|
35
|
+
end
|
36
|
+
|
37
|
+
def ai_client
|
38
|
+
@ai_client ||= PullRequestAi::OpenAi::Client.new
|
39
|
+
end
|
40
|
+
|
41
|
+
def ai_interpreter
|
42
|
+
@ai_interpreter ||= PullRequestAi::OpenAi::Interpreter.new
|
43
|
+
end
|
44
|
+
|
45
|
+
def current_opened_pull_requests(base)
|
46
|
+
repo_reader.repository_slug.bind do |slug|
|
47
|
+
repo_reader.current_branch.bind do |branch|
|
48
|
+
repo_client.opened_pull_requests(slug, branch, base)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def destination_branches
|
54
|
+
repo_reader.destination_branches
|
55
|
+
end
|
56
|
+
|
57
|
+
def open_pull_request(to_base, title, description)
|
58
|
+
repo_reader.repository_slug.bind do |slug|
|
59
|
+
repo_reader.current_branch.bind do |branch|
|
60
|
+
repo_client.open_pull_request(slug, branch, to_base, title, description)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def update_pull_request(number, base, title, description)
|
66
|
+
repo_reader.repository_slug.bind do |slug|
|
67
|
+
repo_client.update_pull_request(slug, number, base, title, description)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def flatten_current_changes(to_branch)
|
72
|
+
repo_reader.flatten_current_changes(to_branch)
|
73
|
+
end
|
74
|
+
|
75
|
+
def suggested_description(type, summary, changes)
|
76
|
+
chat_message = ai_interpreter.chat_message(type, summary, changes)
|
77
|
+
ai_client.predicted_completions(chat_message)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PullRequestAi
|
4
|
+
module GitHub
|
5
|
+
# A client to communicate with the GitHub API.
|
6
|
+
class Client < PullRequestAi::Repo::Client
|
7
|
+
attr_accessor :access_token
|
8
|
+
|
9
|
+
##
|
10
|
+
# Initializes the client.
|
11
|
+
def initialize(
|
12
|
+
http_timeout: nil,
|
13
|
+
api_endpoint: nil,
|
14
|
+
access_token: nil
|
15
|
+
)
|
16
|
+
super(
|
17
|
+
http_timeout || PullRequestAi.http_timeout,
|
18
|
+
api_endpoint || PullRequestAi.github_api_endpoint
|
19
|
+
)
|
20
|
+
@access_token = access_token || PullRequestAi.github_access_token
|
21
|
+
end
|
22
|
+
|
23
|
+
##
|
24
|
+
# Requests the list of Open Pull Requests using the GitHub API.
|
25
|
+
# The slug combines the repository owner name and the repository name.
|
26
|
+
# The query contains the head and base to filter the results.
|
27
|
+
# Notice:
|
28
|
+
# On GitHub it is only possible to have one PR open with the same head and base, despite the result being a list.
|
29
|
+
# https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests
|
30
|
+
def opened_pull_requests(slug, head, base)
|
31
|
+
query = {
|
32
|
+
head: "#{slug.split(":").first}:#{head}",
|
33
|
+
base: base
|
34
|
+
}
|
35
|
+
url = build_url(slug)
|
36
|
+
request(:get, url, query, {}).bind do |open_prs|
|
37
|
+
if open_prs.empty?
|
38
|
+
Dry::Monads::Success([])
|
39
|
+
else
|
40
|
+
result = open_prs.map do |pr|
|
41
|
+
parsed_pr_details(pr)
|
42
|
+
end
|
43
|
+
Dry::Monads::Success(result)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
##
|
49
|
+
# Request to update the existing Pull Request using the GitHub API.
|
50
|
+
# The slug combines the repository owner name and the repository name.
|
51
|
+
# It requires the Pull Request number to modify it. The base, title, and description can be modified.
|
52
|
+
# Notice:
|
53
|
+
# We don't have logic to change the base on the UI.
|
54
|
+
# https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#update-a-pull-request
|
55
|
+
def update_pull_request(slug, number, base, title, description)
|
56
|
+
body = {
|
57
|
+
title: title,
|
58
|
+
body: description,
|
59
|
+
state: 'open',
|
60
|
+
base: base
|
61
|
+
}.to_json
|
62
|
+
url = build_url(slug, "/#{number}")
|
63
|
+
request(:patch, url, {}, body).bind do |pr|
|
64
|
+
Dry::Monads::Success(parsed_pr_details(pr))
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
##
|
69
|
+
# Request to open a new Pull Request using the GitHub API.
|
70
|
+
# The slug combines the repository owner name and the repository name.
|
71
|
+
# It requires the head (destination branch), the base (current branch), the title, and a optional description.
|
72
|
+
# https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#create-a-pull-request
|
73
|
+
def open_pull_request(slug, head, base, title, description)
|
74
|
+
body = {
|
75
|
+
title: title,
|
76
|
+
body: description,
|
77
|
+
head: head,
|
78
|
+
base: base
|
79
|
+
}.to_json
|
80
|
+
url = build_url(slug)
|
81
|
+
request(:post, url, {}, body).bind do |pr|
|
82
|
+
Dry::Monads::Success(parsed_pr_details(pr))
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def parsed_pr_details(details)
|
89
|
+
{
|
90
|
+
number: details['number'],
|
91
|
+
title: details['title'],
|
92
|
+
description: details['body'] || '',
|
93
|
+
link: details['html_url'] || ''
|
94
|
+
}
|
95
|
+
end
|
96
|
+
|
97
|
+
def request(type, url, query, body)
|
98
|
+
response = HTTParty.send(
|
99
|
+
type, url, headers: headers, query: query, body: body, timeout: http_timeout
|
100
|
+
)
|
101
|
+
|
102
|
+
if response.success?
|
103
|
+
Dry::Monads::Success(response.parsed_response)
|
104
|
+
else
|
105
|
+
errors = response.parsed_response['errors']&.map { |error| error['message'] }&.join(' ')
|
106
|
+
Error.failure(:failed_on_github_api_endpoint, errors.to_s.empty? ? nil : errors)
|
107
|
+
end
|
108
|
+
rescue Net::ReadTimeout
|
109
|
+
Error.failure(:connection_timeout)
|
110
|
+
end
|
111
|
+
|
112
|
+
def build_url(slug, suffix = '')
|
113
|
+
"#{api_endpoint}/repos/#{slug}/pulls#{suffix}"
|
114
|
+
end
|
115
|
+
|
116
|
+
def headers
|
117
|
+
{
|
118
|
+
'Accept' => 'application/vnd.github+json',
|
119
|
+
'Authorization' => "Bearer #{access_token}"
|
120
|
+
}
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|