Skip to content

Commit

Permalink
Solve #6 - Add a GitHub fetcher component (#7)
Browse files Browse the repository at this point in the history
* Add github fetcher

* Add test to the github fetcher

* Add a mapper for Github

* Add test for the github mapper

* Fix rubocop warnings

* Fix typo
  • Loading branch information
FelipeGuzmanSierra authored Apr 4, 2024
1 parent 7daff86 commit f8788bc
Show file tree
Hide file tree
Showing 9 changed files with 285 additions and 0 deletions.
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ source "https://rubygems.org"
# Specify your gem's dependencies in bas.gemspec
gemspec

gem "jwt", "~> 2.8.1"

gem "rake", "~> 13.0"

gem "net-imap", "~> 0.4.10"
gem "net-smtp", "~> 0.4.0.1"

gem "octokit", "~> 8.1.0"
gem "openssl", "~> 3.2"

gem "rspec", "~> 3.0"
gem "rubocop", "~> 1.21"
gem "simplecov", require: false, group: :test
Expand Down
22 changes: 22 additions & 0 deletions lib/bas/domain/issue.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Domain
##
# The Domain::Issue class provides a domain-specific representation of a Github issue object.
# It encapsulates information about a repository issue, including the title, state, assignees,
# description, and the repository url.
#
class Issue
attr_reader :title, :state, :assignees, :description, :url

ATTRIBUTES = %w[title state assignees description url].freeze

def initialize(title, state, assignees, body, url)
@title = title
@state = state
@assignees = assignees
@description = body
@url = url
end
end
end
57 changes: 57 additions & 0 deletions lib/bas/fetcher/github/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

require "octokit"
require "openssl"
require "jwt"

require_relative "../base"
require_relative "./types/response"

module Fetcher
module Github
##
# This class is an implementation of the Fetcher::Base interface, specifically designed
# for fetching data from a GitHub repository.
#
class Base < Fetcher::Base
protected

# Implements the data fetching logic to get data from a Github repository.
# It connects to Github using the octokit gem, authenticates with a github app,
# request the data and returns a validated response.
#
def execute(method, *filter)
octokit_response = octokit.public_send(method, *filter)

Fetcher::Github::Types::Response.new(octokit_response)
end

private

def octokit
Octokit::Client.new(bearer_token: access_token)
end

def access_token
app = Octokit::Client.new(client_id: config[:app_id], bearer_token: jwt)

app.create_app_installation_access_token(config[:installation_id])[:token]
end

def jwt
private_pem = File.read(config[:secret_path])
private_key = OpenSSL::PKey::RSA.new(private_pem)

JWT.encode(jwt_payload, private_key, "RS256")
end

def jwt_payload
{
iat: Time.now.to_i - 60,
exp: Time.now.to_i + (10 * 60),
iss: config[:app_id]
}
end
end
end
end
27 changes: 27 additions & 0 deletions lib/bas/fetcher/github/types/response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

module Fetcher
module Github
module Types
##
# Represents a response received from the Octokit Github client. It encapsulates essential
# information about the response, providing a structured way to handle and analyze
# it's responses.
class Response
attr_reader :status_code, :message, :results

def initialize(response)
if response.empty?
@status_code = 404
@message = "no result were found"
@results = []
else
@status_code = 200
@message = "success"
@results = response
end
end
end
end
end
end
17 changes: 17 additions & 0 deletions lib/bas/fetcher/github/use_case/repo_issues.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

require_relative "../base"

module Fetcher
module Github
##
# This class is an implementation of the Fetcher::Github::Base interface, specifically designed
# for fetching issues from a Github repository.
#
class RepoIssues < Github::Base
def fetch
execute("list_issues", config[:repo])
end
end
end
end
57 changes: 57 additions & 0 deletions lib/bas/mapper/github/issues.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

require_relative "../../domain/issue"
require_relative "../base"

module Mapper
module Github
##
# This class implementats the methods of the Mapper::Base module, specifically designed for
# preparing or shaping Github issues data coming from a Fetcher::Base implementation.
class Issues
include Base

# Implements the logic for shaping the results from a fetcher response.
#
# <br>
# <b>Params:</b>
# * <tt>Fetcher::Github::Types::Response</tt> github_response: Array of github issues data.
#
# <br>
# <b>return</b> <tt>List<Domain::Issue></tt> mapped github issues to be used by a
# Formatter::Base implementation.
#
def map(github_response)
return [] if github_response.results.empty?

normalized_github_data = normalize_response(github_response.results)

normalized_github_data.map do |issue|
Domain::Issue.new(
issue["title"], issue["state"], issue["assignees"], issue["body"], issue["url"]
)
end
end

private

def normalize_response(results)
return [] if results.nil?

results.map do |value|
{
"title" => value[:title],
"state" => value[:state],
"assignees" => extract_assignees(value[:assignees]),
"body" => value[:body],
"url" => value[:url]
}
end
end

def extract_assignees(assignees)
assignees.map { |assignee| assignee[:login] }
end
end
end
end
2 changes: 2 additions & 0 deletions lib/bas/use_cases/use_cases.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
require_relative "../fetcher/notion/use_case/work_items_limit"
require_relative "../fetcher/postgres/use_case/pto_today"
require_relative "../fetcher/imap/use_case/support_emails"
require_relative "../fetcher/github/use_case/repo_issues"

# mapper
require_relative "../mapper/notion/birthday_today"
require_relative "../mapper/notion/pto_today"
require_relative "../mapper/notion/work_items_limit"
require_relative "../mapper/postgres/pto_today"
require_relative "../mapper/imap/support_emails"
require_relative "../mapper/github/issues"

# formatter
require_relative "../formatter/birthday"
Expand Down
61 changes: 61 additions & 0 deletions spec/bas/fetcher/github/repo_issues_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

RSpec.describe Fetcher::Github::RepoIssues do
before do
config = {
app_id: "123456",
installation_id: "78910",
secret_path: "secrets_file_path.pem",
repo: "Organization/Repository"
}

@fetcher = described_class.new(config)
end

describe "attributes and arguments" do
it { expect(described_class).to respond_to(:new).with(1).arguments }

it { expect(@fetcher).to respond_to(:config) }
it { expect(@fetcher).to respond_to(:fetch).with(0).arguments }
end

describe ".fetch" do
let(:empty_response) { [] }
let(:response) { [{ url: "repo_url" }] }

let(:octokit) do
stub = {
create_app_installation_access_token: { token: "access_token" }
}

instance_double(Octokit::Client, stub)
end

before do
allow(File).to receive(:read).and_return("private_pem")
allow(OpenSSL::PKey::RSA).to receive(:new).and_return("private_key")
allow(JWT).to receive(:encode).and_return("jwt_token")
allow(Octokit::Client).to receive(:new).and_return(octokit)
end

it "fetch issues from the Github repo when there are no 'issues'" do
allow(octokit).to receive(:public_send).and_return(empty_response)

fetched_data = @fetcher.fetch

expect(fetched_data).to be_an_instance_of(Fetcher::Github::Types::Response)
expect(fetched_data.results).to be_an_instance_of(Array)
expect(fetched_data.results.length).to eq(0)
end

it "fetch issues from the Github repo when there are 'issues'" do
allow(octokit).to receive(:public_send).and_return(response)

fetched_data = @fetcher.fetch

expect(fetched_data).to be_an_instance_of(Fetcher::Github::Types::Response)
expect(fetched_data.results).to be_an_instance_of(Array)
expect(fetched_data.results.length).to eq(1)
end
end
end
36 changes: 36 additions & 0 deletions spec/bas/mapper/github/issues_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

RSpec.describe Mapper::Github::Issues do
let(:assignees) { [{ login: "username" }] }
let(:issues) do
[{
url: "repo_url",
title: "title",
state: "state",
assignees: assignees,
description: "description"
}]
end

before do
@imap_response = Fetcher::Github::Types::Response.new(issues)
@mapper = described_class.new
end

describe "attributes and arguments" do
it { expect(described_class).to respond_to(:new).with(0).arguments }
it { expect(@mapper).to respond_to(:map).with(1).arguments }
end

describe ".map" do
it "maps the given data into an array of Domain::Issue instances" do
mapped_data = @mapper.map(@imap_response)

are_issues = mapped_data.all? { |element| element.is_a?(Domain::Issue) }

expect(mapped_data).to be_an_instance_of(Array)
expect(mapped_data.length).to eq(1)
expect(are_issues).to be_truthy
end
end
end

0 comments on commit f8788bc

Please sign in to comment.