diff --git a/README.md b/README.md index 81b122d..d8e9e0c 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ gem install chandler ### 2. Configure .netrc or set ENV vars -In order to access the GitHub API on your behalf, you must provide chandler with your GitHub credentials. +In order to access the GitHub API on your behalf, you must provide chandler with your GitHub credentials. Do this by creating a `~/.netrc` file with your GitHub username and password, like this: @@ -80,6 +80,21 @@ Other command-line options: * `--changelog=History.md` – location of the CHANGELOG (defaults to `CHANGELOG.md`) * `--tag-prefix=myapp-` – specify Git version tags are in the format `myapp-1.0.0` instead of `1.0.0` +## GitHub Enterprise + +Chandler supports GitHub Enterprise as well as public GitHub repositories. It will make an educated guess as to where your GitHub Enterprise installation is located based on the `origin` git remote. You can also specify your GitHub Enterprise repository using the `--github` option like this: + +``` +--github=git@github.mycompany.com:organization/project.git +``` + +Or like this: + +``` +--github=https://github.mycompany.com/organization/project +``` + +To authenticate, Chandler relies on your `~/.netrc`, as explained above. Replace `api.github.com` with the hostname of your GitHub Enterprise installation (`github.mycompany.com` in the example). ## Rakefile integration diff --git a/lib/chandler/configuration.rb b/lib/chandler/configuration.rb index 2605a1a..30dc3b0 100644 --- a/lib/chandler/configuration.rb +++ b/lib/chandler/configuration.rb @@ -23,23 +23,6 @@ def git @git ||= Chandler::Git.new(:path => git_path, :tag_mapper => tag_mapper) end - def octokit - @octokit ||= Octokit::Client.new(octokit_options) - end - - def octokit_options - chandler_token_key = "CHANDLER_GITHUB_API_TOKEN" - if environment[chandler_token_key] - { :access_token => environment[chandler_token_key] } - else - { :netrc => true } - end - end - - def environment - @environment ||= ENV - end - def github @github ||= Chandler::GitHub.new( :repository => github_repository, diff --git a/lib/chandler/github.rb b/lib/chandler/github.rb index a1e5479..e8e96d0 100644 --- a/lib/chandler/github.rb +++ b/lib/chandler/github.rb @@ -1,5 +1,6 @@ -require "octokit" +require "chandler/github/client" require "chandler/github/errors" +require "chandler/github/remote" module Chandler # A facade for performing GitHub API operations on a given GitHub repository @@ -11,7 +12,8 @@ class GitHub attr_reader :repository, :config def initialize(repository:, config:) - @repository = parse_repository(repository) + @repository = repository + @remote = Remote.parse(repository) @config = config end @@ -28,12 +30,10 @@ def create_or_update_release(tag:, title:, description:) private - def parse_repository(repo) - repo[%r{(git@github.com:|://github.com/)(.*)\.git}, 2] || repo - end + attr_reader :remote def existing_release(tag) - release = client.release_for_tag(repository, tag) + release = client.release_for_tag(remote.repository, tag) release.id.nil? ? nil : release rescue Octokit::NotFound nil @@ -49,19 +49,13 @@ def release_unchanged?(release, title, desc) end def create_release(tag, title, desc) - client.create_release(repository, tag, :name => title, :body => desc) + client.create_release( + remote.repository, tag, :name => title, :body => desc + ) end def client - @client ||= begin - octokit = config.octokit - octokit.login ? octokit : fail_missing_credentials - end - end - - def fail_missing_credentials - netrc = config.octokit.netrc - raise netrc ? NetrcAuthenticationFailure : TokenAuthenticationFailure + @client ||= Client.new(:host => remote.host).tap(&:login!) end end end diff --git a/lib/chandler/github/client.rb b/lib/chandler/github/client.rb new file mode 100644 index 0000000..f8d0f3a --- /dev/null +++ b/lib/chandler/github/client.rb @@ -0,0 +1,40 @@ +require "chandler/github/errors" +require "delegate" +require "octokit" +require "uri" + +module Chandler + class GitHub + # A thin wrapper around Octokit::Client that adds support for automatic + # GitHub Enterprise, .netrc, and ENV token-based authentication. + # + class Client < SimpleDelegator + def initialize(host: "github.com", + environment: ENV, + octokit_client: Octokit::Client) + super(octokit_client.new(detect_auth_option(environment))) + assign_enterprise_endpoint(host) + end + + def login! + return if login + raise netrc ? NetrcAuthenticationFailure : TokenAuthenticationFailure + end + + private + + def detect_auth_option(env) + if (token = env["CHANDLER_GITHUB_API_TOKEN"]) + { :access_token => token } + else + { :netrc => true } + end + end + + def assign_enterprise_endpoint(host) + return if host.downcase == "github.com" + self.api_endpoint = "https://#{host}/api/v3/" + end + end + end +end diff --git a/lib/chandler/github/remote.rb b/lib/chandler/github/remote.rb new file mode 100644 index 0000000..4f983cb --- /dev/null +++ b/lib/chandler/github/remote.rb @@ -0,0 +1,37 @@ +require "uri" + +module Chandler + class GitHub + # Assuming a git remote points to a public GitHub or a GitHub Enterprise + # repository, this class parses the remote to obtain the host and repository + # path. Supports SSH and HTTPS style git remotes. + # + # This class also handles parsing values passed into the `--github` command + # line option, which may be a public GitHub repository name, like + # "mattbrictson/chandler". + # + class Remote + def self.parse(url) + if (match = url.match(/@([^:]+):(.+)$/)) + new(match[1], match[2]) + else + parsed_uri = URI(url) + host = parsed_uri.host || "github.com" + path = parsed_uri.path.sub(%r{^/+}, "") + new(host, path) + end + end + + attr_reader :host, :path + + def initialize(host, path) + @host = host.downcase + @path = path + end + + def repository + path.sub(/\.git$/, "") + end + end + end +end diff --git a/test/chandler/configuration_test.rb b/test/chandler/configuration_test.rb index 0a2ea07..a38dac3 100644 --- a/test/chandler/configuration_test.rb +++ b/test/chandler/configuration_test.rb @@ -52,14 +52,4 @@ def test_changelog assert_instance_of(Chandler::Changelog, changelog) assert_equal("../test/history.md", changelog.path) end - - def test_octokit_options_as_netrc - @config.environment = { "ANYTHING" => "1234" } - assert_equal({ :netrc => true }, @config.octokit_options) - end - - def test_octokit_options_as_access_token - @config.environment = { "CHANDLER_GITHUB_API_TOKEN" => "1234" } - assert_equal({ :access_token => "1234" }, @config.octokit_options) - end end diff --git a/test/chandler/github/client_test.rb b/test/chandler/github/client_test.rb new file mode 100644 index 0000000..c409bb7 --- /dev/null +++ b/test/chandler/github/client_test.rb @@ -0,0 +1,72 @@ +require "minitest_helper" +require "chandler/github/client" + +class Chandler::GitHub::ClientTest < Minitest::Test + class FakeOctokitClient + attr_reader :netrc, :access_token + attr_accessor :api_endpoint + + def initialize(auth_options) + @netrc = auth_options.fetch(:netrc, false) + @access_token = auth_options[:access_token] + end + + def login + "successful" + end + end + + class FakeOctokitClientWithError < FakeOctokitClient + def login + nil + end + end + + def test_uses_netrc_by_default + client = Chandler::GitHub::Client.new(:octokit_client => FakeOctokitClient) + assert(client.netrc) + assert_nil(client.access_token) + end + + def test_uses_access_token_from_env + client = Chandler::GitHub::Client.new( + :environment => { "CHANDLER_GITHUB_API_TOKEN" => "foo" }, + :octokit_client => FakeOctokitClient + ) + assert_equal("foo", client.access_token) + refute(client.netrc) + end + + def test_doesnt_change_default_endpoint_for_public_github + client = Chandler::GitHub::Client.new( + :host => "github.com", + :octokit_client => FakeOctokitClient + ) + assert_nil(client.api_endpoint) + end + + def test_assigns_enterprise_endpoint + client = Chandler::GitHub::Client.new( + :host => "github.example.com", + :octokit_client => FakeOctokitClient + ) + assert_equal("https://github.example.com/api/v3/", client.api_endpoint) + end + + def test_raises_exception_if_netrc_fails + assert_raises(Chandler::GitHub::NetrcAuthenticationFailure) do + Chandler::GitHub::Client.new( + :octokit_client => FakeOctokitClientWithError + ).login! + end + end + + def test_raises_exception_if_access_token_fails + assert_raises(Chandler::GitHub::TokenAuthenticationFailure) do + Chandler::GitHub::Client.new( + :environment => { "CHANDLER_GITHUB_API_TOKEN" => "foo" }, + :octokit_client => FakeOctokitClientWithError + ).login! + end + end +end diff --git a/test/chandler/github/remote_test.rb b/test/chandler/github/remote_test.rb new file mode 100644 index 0000000..eb59480 --- /dev/null +++ b/test/chandler/github/remote_test.rb @@ -0,0 +1,34 @@ +require "minitest_helper" +require "chandler/github/remote" + +class Chandler::GitHub::RemoteTest < Minitest::Test + def test_bare_repository_name + repo = parse("mattbrictson/chandler") + assert_equal("github.com", repo.host) + assert_equal("mattbrictson/chandler", repo.repository) + end + + def test_ssh_style_url + repo = parse("git@github.com:mattbrictson/chandler.git") + assert_equal("github.com", repo.host) + assert_equal("mattbrictson/chandler", repo.repository) + end + + def test_https_url + repo = parse("https://github.com/mattbrictson/chandler.git") + assert_equal("github.com", repo.host) + assert_equal("mattbrictson/chandler", repo.repository) + end + + def test_enterprise_ssh_style_url + repo = parse("git@github.example.com:org/project.git") + assert_equal("github.example.com", repo.host) + assert_equal("org/project", repo.repository) + end + + private + + def parse(url) + Chandler::GitHub::Remote.parse(url) + end +end diff --git a/test/chandler/github_test.rb b/test/chandler/github_test.rb index 0079c51..1274316 100644 --- a/test/chandler/github_test.rb +++ b/test/chandler/github_test.rb @@ -2,58 +2,24 @@ require "chandler/configuration" require "chandler/github" -class Chandler::GitHubSetupTest < Minitest::Test - def test_auths_with_access_token - access_token = "12345" - - mocked_client = MiniTest::Mock.new - mocked_client.expect(:login, :access_token => access_token) - - config = Chandler::Configuration.new - config.environment = { "CHANDLER_GITHUB_API_TOKEN" => access_token } - - Octokit::Client.stubs(:new) - .with(:access_token => access_token).returns(mocked_client) - - github = Chandler::GitHub.new( - :repository => "repo", - :config => config - ) - github.send(:client) - mocked_client.verify - end -end - -class Chandler::GitHubInteractionTest < Minitest::Test +class Chandler::GitHubTest < Minitest::Test def setup @config = Chandler::Configuration.new - @octokit = Octokit::Client.new(:netrc => true) - @octokit.stubs(:login => "mattbrictson") - @config.stubs(:octokit).returns(@octokit) - - @github = Chandler::GitHub.new( - :repository => "repo", - :config => @config - ) - end - - def test_fails_if_missing_credentials - @octokit.stubs(:login => nil) + @client = Chandler::GitHub::Client.new + @client.stubs(:login).returns("username") + Chandler::GitHub::Client + .stubs(:new) + .with(:host => "github.com") + .returns(@client) - assert_raises(Chandler::GitHub::NetrcAuthenticationFailure) do - @github.create_or_update_release( - :tag => "v1.0.2", - :title => "1.0.2", - :description => "Fix a bug" - ) - end + @github = Chandler::GitHub.new(:repository => "repo", :config => @config) end def test_dry_run_disables_all_api_calls - @octokit.expects(:release_for_tag).never - @octokit.expects(:create_release).never - @octokit.expects(:update_release).never + @client.expects(:release_for_tag).never + @client.expects(:create_release).never + @client.expects(:update_release).never @config.dry_run = true @github.create_or_update_release( @@ -69,9 +35,9 @@ def test_no_update_performed_if_release_has_not_changed desc = "desc" release = stub(:id => "id", :name => title, :body => desc) - @octokit.expects(:release_for_tag).with("repo", tag).returns(release) - @octokit.expects(:create_release).never - @octokit.expects(:update_release).never + @client.expects(:release_for_tag).with("repo", tag).returns(release) + @client.expects(:create_release).never + @client.expects(:update_release).never @github.create_or_update_release( :tag => tag, @@ -86,10 +52,10 @@ def test_update_performed_if_release_exists_and_is_different desc = "desc" release = stub(:id => "id", :url => "url", :name => title, :body => "old") - @octokit.expects(:release_for_tag).with("repo", tag).returns(release) - @octokit.expects(:update_release) - .with("url", :name => title, :body => desc) - @octokit.expects(:create_release).never + @client.expects(:release_for_tag).with("repo", tag).returns(release) + @client.expects(:update_release) + .with("url", :name => title, :body => desc) + @client.expects(:create_release).never @github.create_or_update_release( :tag => tag, @@ -103,14 +69,14 @@ def test_create_performed_if_existing_release_404s title = "1.0.2" desc = "desc" - @octokit.expects(:release_for_tag) - .with("repo", tag) - .raises(Octokit::NotFound) + @client.expects(:release_for_tag) + .with("repo", tag) + .raises(Octokit::NotFound) - @octokit.expects(:create_release) - .with("repo", tag, :name => title, :body => desc) + @client.expects(:create_release) + .with("repo", tag, :name => title, :body => desc) - @octokit.expects(:update_release).never + @client.expects(:update_release).never @github.create_or_update_release( :tag => tag, @@ -125,12 +91,12 @@ def test_create_performed_if_existing_release_has_nil_id desc = "desc" release = stub(:id => nil) - @octokit.expects(:release_for_tag).with("repo", tag).returns(release) + @client.expects(:release_for_tag).with("repo", tag).returns(release) - @octokit.expects(:create_release) - .with("repo", tag, :name => title, :body => desc) + @client.expects(:create_release) + .with("repo", tag, :name => title, :body => desc) - @octokit.expects(:update_release).never + @client.expects(:update_release).never @github.create_or_update_release( :tag => tag,