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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +125 -0
  4. data/Rakefile +10 -0
  5. data/app/assets/config/pull_request_ai_manifest.js +1 -0
  6. data/app/assets/javascripts/application.js +248 -0
  7. data/app/assets/javascripts/notifications.js +76 -0
  8. data/app/assets/stylesheets/pull_request_ai/application.css +15 -0
  9. data/app/assets/stylesheets/pull_request_ai/blocs.css +4 -0
  10. data/app/assets/stylesheets/pull_request_ai/helpers.css +26 -0
  11. data/app/assets/stylesheets/pull_request_ai/inputs.css +64 -0
  12. data/app/assets/stylesheets/pull_request_ai/notification.css +71 -0
  13. data/app/assets/stylesheets/pull_request_ai/spinner.css +24 -0
  14. data/app/controllers/pull_request_ai/application_controller.rb +6 -0
  15. data/app/controllers/pull_request_ai/pull_request_ai_controller.rb +85 -0
  16. data/app/helpers/pull_request_ai/application_helper.rb +6 -0
  17. data/app/models/pull_request_ai/application_record.rb +7 -0
  18. data/app/views/layouts/pull_request_ai/application.html.erb +29 -0
  19. data/app/views/pull_request_ai/pull_request_ai/new.html.erb +70 -0
  20. data/config/initializers/rack_attack.rb +30 -0
  21. data/config/routes.rb +13 -0
  22. data/lib/pull_request_ai/bitbucket/client.rb +148 -0
  23. data/lib/pull_request_ai/client.rb +80 -0
  24. data/lib/pull_request_ai/engine.rb +10 -0
  25. data/lib/pull_request_ai/github/client.rb +124 -0
  26. data/lib/pull_request_ai/openAi/client.rb +81 -0
  27. data/lib/pull_request_ai/openAi/interpreter.rb +19 -0
  28. data/lib/pull_request_ai/repo/client.rb +43 -0
  29. data/lib/pull_request_ai/repo/file.rb +20 -0
  30. data/lib/pull_request_ai/repo/prompt.rb +33 -0
  31. data/lib/pull_request_ai/repo/reader.rb +118 -0
  32. data/lib/pull_request_ai/util/configuration.rb +49 -0
  33. data/lib/pull_request_ai/util/error.rb +28 -0
  34. data/lib/pull_request_ai/util/symbol_details.rb +14 -0
  35. data/lib/pull_request_ai/version.rb +5 -0
  36. data/lib/pull_request_ai.rb +52 -0
  37. data/lib/tasks/pull_request_ai_tasks.rake +5 -0
  38. 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PullRequestAi
4
+ class ApplicationController < ActionController::Base
5
+ end
6
+ end
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PullRequestAi
4
+ module ApplicationHelper
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PullRequestAi
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ 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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PullRequestAi
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace PullRequestAi
6
+
7
+ config.assets.precompile += ['application.js']
8
+ config.assets.precompile += ['pull_request_ai/application.css']
9
+ end
10
+ 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