diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 875e92e..fb0f4ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,22 +22,6 @@ jobs: - name: Scan for common Rails security vulnerabilities using static analysis run: bin/brakeman --no-pager - scan_js: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - - name: Scan for security vulnerabilities in JavaScript dependencies - run: bin/importmap audit - lint: runs-on: ubuntu-latest steps: diff --git a/Gemfile b/Gemfile index 25a5271..c048854 100644 --- a/Gemfile +++ b/Gemfile @@ -40,3 +40,7 @@ group :development, :test do gem "rubocop-rails-omakase", require: false gem "rspec-rails", "~> 6.1.0" end + +gem "rest-client", "~> 2.1" + +gem "dotenv", "~> 3.1" diff --git a/Gemfile.lock b/Gemfile.lock index f48dba0..4aaf3aa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -76,7 +76,7 @@ GEM bigdecimal (3.1.8) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.1.2) + brakeman (6.2.1) racc builder (3.3.0) concurrent-ruby (1.3.4) @@ -87,10 +87,15 @@ GEM irb (~> 1.10) reline (>= 0.3.8) diff-lcs (1.5.1) + domain_name (0.6.20240107) + dotenv (3.1.2) drb (2.2.1) erubi (1.13.0) globalid (1.2.1) activesupport (>= 6.1) + http-accept (1.7.0) + http-cookie (1.0.7) + domain_name (~> 0.5) i18n (1.14.5) concurrent-ruby (~> 1.0) io-console (0.7.2) @@ -109,6 +114,9 @@ GEM net-pop net-smtp marcel (1.0.4) + mime-types (3.5.2) + mime-types-data (~> 3.2015) + mime-types-data (3.2024.0820) mini_mime (1.1.5) minitest (5.25.1) msgpack (1.7.2) @@ -121,6 +129,7 @@ GEM timeout net-smtp (0.5.0) net-protocol + netrc (0.11.0) nio4r (2.7.3) nokogiri (1.16.7-aarch64-linux) racc (~> 1.4) @@ -187,6 +196,11 @@ GEM regexp_parser (2.9.2) reline (0.5.9) io-console (~> 0.5) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) rexml (3.3.6) strscan rspec-core (3.13.0) @@ -281,8 +295,10 @@ DEPENDENCIES bootsnap brakeman debug + dotenv (~> 3.1) puma (>= 5.0) rails (~> 7.2.0) + rest-client (~> 2.1) rspec-rails (~> 6.1.0) rubocop-rails-omakase sqlite3 (>= 1.4) diff --git a/app/models/pull_request.rb b/app/models/pull_request.rb new file mode 100644 index 0000000..ac74de8 --- /dev/null +++ b/app/models/pull_request.rb @@ -0,0 +1,2 @@ +class PullRequest < ApplicationRecord +end diff --git a/app/services/fetch_github_pull_request_data.rb b/app/services/fetch_github_pull_request_data.rb new file mode 100644 index 0000000..e10a01a --- /dev/null +++ b/app/services/fetch_github_pull_request_data.rb @@ -0,0 +1,100 @@ +class FetchGithubPullRequestData + attr_reader :pull_request_url, :client + + def initialize(pull_request_url, github_client: Github::Client.new) + @pull_request_url = pull_request_url + @client = github_client + end + + def call + data = parse_pull_request_url + pr_data = fetch_pull_request_data(data) + + diff_data = fetch_diff_data(pr_data[:diff_url]) + commits_data = fetch_commits_data(pr_data[:commits_url]) + comments_data = fetch_comments_data(pr_data[:review_comments_url]) + + { status: :ok, pull_request: PullRequest.create(assemble_pull_request_data(pr_data, diff_data, commits_data, comments_data)) } + rescue StandardError => e + { status: :failed, message: e.message } + end + + private + + def parse_pull_request_url + pattern = %r{\Ahttps://github\.com/(?[^/]+)/(?[^/]+)/pull/(?\d+)\z} + + match_data = pull_request_url.match(pattern) + + raise ArgumentError, "Invalid GitHub pull request URL" unless match_data + + { + owner: match_data[:owner], + repo_name: match_data[:repo], + pull_request_number: match_data[:pr_number] + } + end + + def fetch_pull_request_data(data) + response = client.send_get_request("/repos/#{data[:owner]}/#{data[:repo_name]}/pulls/#{data[:pull_request_number]}") + + raise StandardError, JSON.parse(response.body)["message"] unless response.code == 200 + + JSON.parse(response.body).deep_symbolize_keys + end + + def fetch_diff_data(diff_url) + response = client.send_get_request(URI(diff_url).path) + response.code == 200 ? response.body : "" + end + + def fetch_commits_data(commits_url) + response = client.send_get_request(URI(commits_url).path) + + raise StandardError, JSON.parse(response.body)["message"] unless response.code == 200 + + JSON.parse(response.body).map do |commit| + { + sha: commit["sha"], + author: commit["commit"]["author"].slice("name", "email"), + message: commit["commit"]["message"], + date: commit["commit"]["author"]["date"] + } + end + end + + def fetch_comments_data(comments_url) + response = client.send_get_request(URI(comments_url).path) + + raise StandardError, JSON.parse(response.body)["message"] unless response.code == 200 + + JSON.parse(response.body).map do |comment| + { + body: comment["body"], + id: comment["id"], + diff_hunk: comment["diff_hunk"], + path: comment["path"], + user: comment["user"]["login"], + created_at: comment["created_at"], + updated_at: comment["updated_at"], + reactions: comment["reactions"].except("url") + } + end + end + + def assemble_pull_request_data(pr_data, diff_data, commits_data, comments_data) + { + url: pr_data[:html_url], + title: pr_data[:title], + body: pr_data[:body], + repo_owner: pr_data[:head][:repo][:owner][:login], + repo: pr_data[:head][:repo][:name], + creator: pr_data[:user][:login], + comments: comments_data, + commits: commits_data, + diff: diff_data, + pr_created_at: pr_data[:created_at], + pr_updated_at: pr_data[:updated_at] + } + end +end diff --git a/config/application.rb b/config/application.rb index b74c5bf..79d0501 100644 --- a/config/application.rb +++ b/config/application.rb @@ -17,6 +17,7 @@ # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. Bundler.require(*Rails.groups) +Dotenv.load module BlameAi class Application < Rails::Application diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc deleted file mode 100644 index 82337d6..0000000 --- a/config/credentials.yml.enc +++ /dev/null @@ -1 +0,0 @@ -t/Y066Fp5O76svbHfSbuw06fluX1zik2MjqjgrgO/2iB+LFh7jPddOdgN+SWeoximSDj12i2ExiTWuhgK5j9dJrgbus81QkY0SZXpxuXWRtwrPVb2FXZVJ9cvYToiG3TtkLYxTPhqrU8yzJiRH5LDulqvHNO4hPfWFXXAj11UDFtEHuiyE/A2jLnpTkKJLeH9toumcRO1d6ppepY5bf+xMzCGA8Js/YRxGuqElofXExspc9vGnuPX4Fi8A7bzi8WnpJ+0oax+VSpUvxqv3d5Hkbga4C9x1eITcNR1nwbYezGPi2z4DoFti9SSZsGQaZAJVY3bt9fuREaCWrFzoJhMB0Eab8JEDpsYJR+taWX/nfrCRcbN4ms5VVGLZB8on1WjvOyHJvtaihhfzd0tVrCRnVMt/T7--Cde6kiXZZMTL+Xr8--lmK43gzy8/wyDxfGPOd8xQ== \ No newline at end of file diff --git a/db/migrate/20240822102014_create_pull_requests.rb b/db/migrate/20240822102014_create_pull_requests.rb new file mode 100644 index 0000000..7d23044 --- /dev/null +++ b/db/migrate/20240822102014_create_pull_requests.rb @@ -0,0 +1,19 @@ +class CreatePullRequests < ActiveRecord::Migration[7.2] + def change + create_table :pull_requests do |t| + t.string :url + t.string :title + t.text :body + t.string :repo_owner + t.string :repo + t.string :creator + t.json :comments + t.json :commits + t.text :diff + t.datetime :pr_created_at + t.datetime :pr_updated_at + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..bd3cd5c --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,29 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.2].define(version: 2024_08_22_102014) do + create_table "pull_requests", force: :cascade do |t| + t.string "url" + t.string "title" + t.text "body" + t.string "repo_owner" + t.string "repo" + t.string "creator" + t.json "comments" + t.json "commits" + t.text "diff" + t.datetime "pr_created_at" + t.datetime "pr_updated_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end +end diff --git a/lib/github/client.rb b/lib/github/client.rb new file mode 100644 index 0000000..025c781 --- /dev/null +++ b/lib/github/client.rb @@ -0,0 +1,35 @@ +module Github + class Client + BASE_URL = "https://api.github.com" + + def send_get_request(path) + RestClient.get("#{BASE_URL}#{path}", headers) + rescue RestClient::ExceptionWithResponse => e + e.response + end + + private + + def parse_pull_request_url(url) + pattern = %r{\Ahttps://github\.com/(?[^/]+)/(?[^/]+)/pull/(?\d+)\z} + + if match_data = url.match(pattern) + owner = match_data[:owner] + repo_name = match_data[:repo] + pull_request_number = match_data[:pr_number] + + { owner: owner, repo_name: repo_name, pull_request_number: pull_request_number } + else + raise ArgumentError, "Invalid GitHub pull request URL" + end + end + + def headers + @_headers ||= { + "X-GitHub-Api-Version" => "2022-11-28", + "Authorization" => "Bearer #{ENV['GITHUB_API_TOKEN']}", + "Accept" => "application/vnd.github+json" + } + end + end +end diff --git a/spec/lib/github/client_spec.rb b/spec/lib/github/client_spec.rb new file mode 100644 index 0000000..499c738 --- /dev/null +++ b/spec/lib/github/client_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' +require 'rest-client' +require 'github/client' + +RSpec.describe Github::Client do + let(:client) { described_class.new } + let(:valid_url) { "https://github.com/owner/repo/pull/123" } + let(:invalid_url) { "https://invalid.com/owner/repo/pull/123" } + + describe "#send_get_request" do + let(:path) { "/some_endpoint" } + let(:url) { "https://api.github.com#{path}" } + let(:response) { double("response") } + + context "when the request is successful" do + before do + allow(RestClient).to receive(:get).and_return(response) + end + + it "sends a GET request to the given URL" do + expect(RestClient).to receive(:get).with(url, anything) + client.send_get_request(path) + end + + it "returns the response" do + result = client.send_get_request(url) + expect(result).to eq(response) + end + end + + context "when the request fails" do + let(:error_response) { double("error_response") } + let(:exception) { RestClient::ExceptionWithResponse.new(error_response) } + + before do + allow(RestClient).to receive(:get).and_raise(exception) + end + + it "rescues the exception and returns the response" do + result = client.send_get_request(path) + expect(result).to eq(error_response) + end + end + end +end diff --git a/spec/services /fetch_github_pull_request_data_spec.rb b/spec/services /fetch_github_pull_request_data_spec.rb new file mode 100644 index 0000000..3e138ef --- /dev/null +++ b/spec/services /fetch_github_pull_request_data_spec.rb @@ -0,0 +1,136 @@ +require 'rails_helper' +require 'json' + +RSpec.describe FetchGithubPullRequestData do + let(:pull_request_url) { "https://github.com/owner/repo/pull/123" } + let(:pull_request_api_url) { "https://api.github.com/repos/owner/repo/pulls/123" } + let(:client) { instance_double(Github::Client) } + let(:service) { described_class.new(pull_request_url, github_client: client) } + + let(:pr_data) do + { + html_url: pull_request_url, + title: "Example Pull Request", + body: "This is a test PR", + head: { + repo: { + owner: { login: "owner" }, + name: "repo" + } + }, + user: { login: "creator" }, + created_at: "2023-01-01T00:00:00Z", + updated_at: "2023-01-02T00:00:00Z", + diff_url: "https://github.com/owner/repo/pull/123.diff", + commits_url: "https://api.github.com/repos/owner/repo/pulls/123/commits", + review_comments_url: "https://api.github.com/repos/owner/repo/pulls/123/comments" + } + end + + let(:diff_data) { "diff --git a/file.txt b/file.txt\nindex 83db48f..a8d38ed 100644\n--- a/file.txt\n+++ b/file.txt\n" } + + let(:commits_data) do + [ + { + "sha" => "abc123", + "commit" => { + "author" => { + "name" => "John Doe", + "email" => "john.doe@example.com", + "date" => "2023-01-01T12:00:00Z" + } + } + } + ] + end + + let(:comments_data) do + [ + { + "body" => "This is a comment", + "id" => 1, + "diff_hunk" => "@@ -0,0 +1,2 @@", + "path" => "file.txt", + "user" => { "login" => "commenter" }, + "created_at" => "2023-01-01T12:30:00Z", + "updated_at" => "2023-01-01T12:45:00Z", + "reactions" => { "+1" => 1, "-1" => 0 } + } + ] + end + + describe "#call" do + context "when all data is successfully fetched" do + before do + allow(client).to receive(:send_get_request) + .with(URI(pull_request_api_url).path) + .and_return(instance_double(RestClient::Response, code: 200, body: pr_data.to_json)) + + allow(client).to receive(:send_get_request) + .with(URI(pr_data[:diff_url]).path) + .and_return(instance_double(RestClient::Response, code: 200, body: diff_data)) + + allow(client).to receive(:send_get_request) + .with(URI(pr_data[:commits_url]).path) + .and_return(instance_double(RestClient::Response, code: 200, body: commits_data.to_json)) + + allow(client).to receive(:send_get_request) + .with(URI(pr_data[:review_comments_url]).path) + .and_return(instance_double(RestClient::Response, code: 200, body: comments_data.to_json)) + end + + it "creates a PullRequest with the correct data" do + expected_data = { + url: pr_data[:html_url], + title: pr_data[:title], + body: pr_data[:body], + repo_owner: pr_data[:head][:repo][:owner][:login], + repo: pr_data[:head][:repo][:name], + creator: pr_data[:user][:login], + comments: comments_data.map do |comment| + { + body: comment["body"], + id: comment["id"], + diff_hunk: comment["diff_hunk"], + path: comment["path"], + user: comment["user"]["login"], + created_at: comment["created_at"], + updated_at: comment["updated_at"], + reactions: comment["reactions"].except("url") + } + end, + commits: commits_data.map do |commit| + { + sha: commit["sha"], + author: { + 'name' => commit["commit"]["author"]["name"], + 'email' => commit["commit"]["author"]["email"] + }, + message: commit["commit"]["message"], + date: commit["commit"]["author"]["date"] + } + end, + diff: diff_data, + pr_created_at: pr_data[:created_at], + pr_updated_at: pr_data[:updated_at] + } + + expect(PullRequest).to receive(:create).with(expected_data) + expect(service.call[:status]).to eq(:ok) + end + end + + context "when pull request data cannot be fetched" do + before do + allow(client).to receive(:send_get_request) + .with(URI(pull_request_api_url).path) + .and_return(instance_double(RestClient::Response, code: 404, body: { message: "Not found" }.to_json)) + end + + it "does not create a PullRequest" do + expect(PullRequest).not_to receive(:create) + expect(service.call).to eq({ status: :failed, message: "Not found" }) + end + end + end +end