hubkit 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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 645c28ca63135ba2eb911fce4e0f759db34be764
4
+ data.tar.gz: db3abc9ba844450b14555e9cb743989d21896b03
5
+ SHA512:
6
+ metadata.gz: b63ffe491f25e4c10ebed01fbc6284e37f1a9f5ac99e1d6af79d08342b2a237362888623641259b1d1ee89e854eed3fa19b5789b245aedd4fdabc6871f91d469
7
+ data.tar.gz: 5859fd0c38ae794734299634298dfe1a8607545eee5d162f4544463488655c019a929cba670429a0688e2b8dca564e0159233f0647095d8033f7cdc33e069cf4
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.1
5
+ before_install: gem install bundler -v 1.14.6
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at robert@revelry.co. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
@@ -0,0 +1,32 @@
1
+ # Setup for Development
2
+
3
+ ```
4
+ bundle install
5
+ ```
6
+
7
+ should be sufficient to install dependencies.
8
+
9
+ You will a `.env` file with your API keys to record new tests against the
10
+ API. Your API key will be omitted from the test cassettes by VCR. The format for
11
+ `.env` is:
12
+
13
+ ```
14
+ GITHUB_TOKEN=YOUR-API-TOKEN
15
+ ```
16
+
17
+ # Submitting Changes
18
+
19
+ 1. Fork the repository.
20
+ 2. Set up the gem per the instructions above and ensure `bundle exec rake spec`
21
+ runs cleanly.
22
+ 3. Create a topic branch.
23
+ 4. Add specs for your unimplemented feature or bug fix.
24
+ 5. Run `bundle exec rake spec`. If your specs pass, return to step 4.
25
+ 6. Implement your feature or bug fix.
26
+ 7. Re-run `bundle exec rake spec`. If your specs fail, return to step 6.
27
+ 8. Open coverage/index.html. If your changes are not completely covered by the
28
+ test suite, return to Step 4.
29
+ 9. Thoroughly document and comment your code.
30
+ 10. Run `bundle exec rake doc:yard` and make sure your changes are documented.
31
+ 11. Add, commit, and push your changes.
32
+ 12. Submit a pull request.
data/Gemfile ADDED
@@ -0,0 +1,17 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in hubkit.gemspec
4
+ gemspec
5
+
6
+ group :development, :test do
7
+ gem 'byebug'
8
+ # rubocop:disable Style/ExtraSpacing
9
+ gem 'cucumber', '~> 2.1'
10
+ gem 'dotenv'
11
+ gem 'rspec', '~> 3.5.0'
12
+ gem 'rspec-collection_matchers'
13
+ gem 'simplecov', '~> 0.11.2'
14
+ gem 'vcr', '~> 2.6'
15
+ gem 'webmock', '~> 1.17.3'
16
+ gem 'yard', '~> 0.8.7'
17
+ end
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2017 Robert Prehn
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,38 @@
1
+ # Hubkit
2
+
3
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/hubkit`. To experiment with that code, run `bin/console` for an interactive prompt.
4
+
5
+ TODO: Delete this and the text above, and describe your gem
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'hubkit'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install hubkit
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ ## Contributing
32
+
33
+ Bug reports and pull requests are welcome on GitHub at https://github.com/prehnRA/hubkit. Check out [CONTRIBUTING.md](https://github.com/prehnRA/hubkit/blob/master/CONTRIBUTING.md) for more info.
34
+
35
+ Everyone is welcome to participate in the project. We expect contributors to
36
+ adhere the Contributor Covenant Code of Conduct (see [CODE_OF_CONDUCT.md](https://github.com/prehnRA/hubkit/blob/master/CODE_OF_CONDUCT.md)).
37
+
38
+
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "hubkit"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,34 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'hubkit/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "hubkit"
8
+ spec.version = Hubkit::VERSION
9
+ spec.authors = ["Robert Prehn"]
10
+ spec.email = ["robert@revelry.co"]
11
+ spec.licenses = ['MIT']
12
+
13
+ spec.summary = "Higher level abstractions for querying the github API"
14
+ spec.description = <<-DESC
15
+ Hubkit provides methods for querying the github API at a higher level than
16
+ making individual API calls. Think of it like an ORM for the github API.
17
+ DESC
18
+
19
+ spec.homepage = "https://github.com/prehnRA/hubkit"
20
+
21
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
22
+ f.match(%r{^(test|spec|features)/})
23
+ end
24
+ spec.bindir = "exe"
25
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
26
+ spec.require_paths = ["lib"]
27
+
28
+ spec.add_dependency 'github_api', '~> 0.16.0'
29
+ spec.add_dependency 'activesupport', '>= 4', '< 6'
30
+
31
+ spec.add_development_dependency "bundler", "~> 1.14"
32
+ spec.add_development_dependency "rake", "~> 10.0"
33
+ spec.add_development_dependency "rspec", "~> 3.0"
34
+ end
@@ -0,0 +1,33 @@
1
+ require 'active_support/core_ext/module'
2
+ require 'active_support/core_ext/object'
3
+ require 'active_support/json'
4
+ require 'active_support/time'
5
+
6
+ require 'github_api'
7
+ require 'hubkit/configuration'
8
+ require 'hubkit/chainable_collection'
9
+ require 'hubkit/paginator'
10
+ require 'hubkit/cooldowner'
11
+ require 'hubkit/event_collection'
12
+ require 'hubkit/event_paginator'
13
+ require 'hubkit/issue_collection'
14
+ require 'hubkit/issue_paginator'
15
+ require 'hubkit/issue'
16
+ require 'hubkit/logger'
17
+ require 'hubkit/repo_collection'
18
+ require 'hubkit/repo_paginator'
19
+ require 'hubkit/repo'
20
+ require 'hubkit/version'
21
+
22
+ # Main module of the hubkit library. This is generally not used directly, but
23
+ # through a subclass such as Hubkit::IssueCollection, Hubkit::EventCollection,
24
+ # Hubkit::Repo, etc.
25
+ module Hubkit
26
+ class << self
27
+ # Return the Github client used by the library
28
+ # @return [Github::Client] the github API client
29
+ def client
30
+ Configuration.client
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,106 @@
1
+ module Hubkit
2
+ # @abstract A class which wraps an array (or Enumerable) and provides convenience
3
+ # methods for chainable filters, e.g.:
4
+ # @example
5
+ # repo.issues.unassigned.labeled('in progress')
6
+ class ChainableCollection
7
+ # Allows definition of new chainable filters within the class definition
8
+ # @example
9
+ # scope :unlabeled, -> { |collection| collection.reject(&:labeled?) }
10
+ # @param [String, Symbol] name the anem of the method
11
+ # @yieldparam ... any arguments needed by the block
12
+ def self.scope(name, &block)
13
+ define_method name do |*args|
14
+ wrap(
15
+ instance_exec(*args, &block),
16
+ )
17
+ end
18
+ end
19
+
20
+ # Create a new ChainableCollection
21
+ # @param [Enumerable] inner the collection which will be wrapped in the
22
+ # Hubkit::ChainableCollection
23
+ def initialize(inner)
24
+ @inner = inner
25
+ end
26
+
27
+ # Return a Hubkit::ChainableCollection containing all members for which
28
+ # the block is true. This new Hubkit::ChainableCollection will also be
29
+ # filterable in the same way.
30
+ # @yieldparam item the item to be evaluated by the block
31
+ def select(&block)
32
+ return wrap(@inner.select &block) if block_given?
33
+ wrap(@inner.select)
34
+ end
35
+
36
+ # Return a collection of the same type as `self`, containing `items`
37
+ # @param [Enumberable] items the items to be contained in the new
38
+ # collection
39
+ def wrap(items)
40
+ self.class.new(items)
41
+ end
42
+
43
+ # Check if a method is implemented by either this method or the wrapped
44
+ # collection
45
+ # @param [String, Symbol] name the name of the method to check
46
+ # @param [Boolean] include_all if true, will include private methods
47
+ # @return [Boolean] returns true if the collection or the wrapped
48
+ # collection implements the method
49
+ def respond_to?(name, include_all = false)
50
+ super || @inner.respond_to?(name)
51
+ end
52
+
53
+ # Call into the wrapped collection if a method has not been implemented
54
+ # on the ChainableCollection
55
+ # @param [String, Symbol] name the name of the method being called
56
+ # @param [Array] args the arguments to the method being called
57
+ # @yieldparam ... the parameters of the any block given to the method
58
+ # which is being called
59
+ # @return the value of the method on the inner collection as called
60
+ def method_missing(name, *args, &block)
61
+ return super unless @inner.respond_to?(name)
62
+ @inner.send(name, *args, &block)
63
+ end
64
+
65
+ # Returns a collection which will contain all elements which are contained
66
+ # in this ChainableCollection, but NOT matching any additional chained filters
67
+ # @example
68
+ # ChainableCollection.new([1, 2, 3, 4]).not.select(&:odd?) # even->[2, 4]
69
+ # @return [NotCollection] a collection which contains all elements of self
70
+ # which don't match additional filters
71
+ def not
72
+ NotCollection.new(self)
73
+ end
74
+
75
+ # Returns true if the other collection contains the same elements
76
+ # @param [Enumerable] other collection to compare with
77
+ # @return [Boolean] true if this collection and the other contain the same
78
+ # elements
79
+ def ==(other)
80
+ other == self.to_a
81
+ end
82
+ end
83
+
84
+ # A collection that lets you perform the inverse of a filter
85
+ # @example
86
+ # repo.issues.not.labeled('in progress')
87
+ class NotCollection
88
+ def initialize(base)
89
+ @base = base
90
+ end
91
+
92
+ # Any method of this class will be delegated down to the original
93
+ # ChainableCollection. The result of the method will be a
94
+ # ChainableCollection which contains all the elements not returned
95
+ # by the filter called.
96
+ # @param [String, Symbol] name the name of the method being called
97
+ # @param [Array] args the arguments to the method being called
98
+ # @yieldparam ... the parameters of the any block given to the method
99
+ # which is being called
100
+ # @return [ChainableCollection] all elements which do not match the chained
101
+ # filter in a new ChainableCollection
102
+ def method_missing(name, *args, &block)
103
+ @base.wrap(@base - @base.send(name, *args, &block))
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,51 @@
1
+ module Hubkit
2
+ # Hold the configuration of the Hubkit library. Holds the client, default
3
+ # github organization, and the github library configuration options.
4
+ # @attr [Github::Client] client the underlying Github client used for library
5
+ # operations
6
+ # @attr [String] default_org the github organization that will be used if
7
+ # none is specified
8
+ # @attr [Hash] github_config the configuration to use for the github client
9
+ # as a Hash
10
+ module Configuration
11
+ class << self
12
+ attr_writer :client
13
+ attr_accessor :default_org
14
+ attr_accessor :github_config
15
+
16
+ # Set configuration for the library
17
+ # @param [Hash] opts the configuration options to set
18
+ # @yieldparam [Hubkit::Configuration] this configuration, which is passed
19
+ # to the block
20
+ # @return [Github::Client] the github client to be used by the library
21
+ def configure(opts = {}, &block)
22
+ hubkit_config = self
23
+ @client = Github.new do |github_config|
24
+ hubkit_config.github_config = github_config
25
+ yield hubkit_config
26
+ end
27
+ end
28
+
29
+ # The underlying Github::Client that will be used for library operations
30
+ # @return [Github::Client] a Github::Client which will be used for
31
+ # library operations
32
+ def client
33
+ @client || Github.new
34
+ end
35
+
36
+ # Pass through all method calls which are not implemented directly on the
37
+ # Configuration to the client's configuration method
38
+ # @param [String, Symbol] name the name of the method which is being called
39
+ # @param [Array] args the array of arguments to the method
40
+ # @yieldparam ... any arguments to the block required by the underlying
41
+ # Github::Client method
42
+ # @return the same return value that the github configuration would return
43
+ def method_missing(name, *args, &block)
44
+ if github_config.present? && github_config.respond_to?(name, false)
45
+ return github_config.public_send(name, *args, &block)
46
+ end
47
+ super
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,20 @@
1
+ module Hubkit
2
+ # An object that handles Github rate throttling by setting a delay and then
3
+ # retrying a block
4
+ class Cooldowner
5
+ # Perform an action, and if Github rejects it due to rate limit, sleep and
6
+ # try again later
7
+ # @yield
8
+ def self.with_cooldown
9
+ cooldown = 1
10
+ begin
11
+ yield
12
+ rescue Github::Error::Forbidden => e
13
+ Logger.warn "Sleeping for abuse (#{cooldown} seconds)"
14
+ sleep cooldown
15
+ cooldown = [2 * cooldown, 10].min
16
+ retry
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,58 @@
1
+ module Hubkit
2
+ # A collection of GitHub events with chainable filters
3
+ class EventCollection < ChainableCollection
4
+ # Put the list of events in reverse chronological order
5
+ # @return [EventCollection] the events in reverse chronological order
6
+ def reverse_chronological
7
+ wrap(
8
+ sort do |a, b|
9
+ Time.parse(b['created_at']) <=> Time.parse(a['created_at'])
10
+ end,
11
+ )
12
+ end
13
+
14
+ # Put the list of events in chronological order
15
+ # @return [EventCollection] the events in chronological order
16
+ def chronological
17
+ wrap(
18
+ sort do |a, b|
19
+ Time.parse(a['created_at']) <=> Time.parse(b['created_at'])
20
+ end,
21
+ )
22
+ end
23
+
24
+ # Filter to all the events which occur between start_dt and end_date,
25
+ # inclusive
26
+ # @param [Date, DateTime] start_dt the beginning of the included period (inclusive)
27
+ # @param [Date, DateTime] end_date the end of the included period (inclusive)
28
+ # @return [EventCollection] the events falling in the window
29
+ def between(start_dt, end_date)
30
+ wrap(
31
+ @inner.select do |event|
32
+ stamp = Time.parse(event['created_at'])
33
+ stamp >= start_dt && stamp <= end_date
34
+ end,
35
+ )
36
+ end
37
+
38
+ # Filter to all events where an issue was labeled with a given label
39
+ # @param [String] label the label to search for
40
+ # @return [EventCollection] a collection containing all events where an
41
+ # issue was labeled with the given label
42
+ def labeled(label)
43
+ wrap(
44
+ @inner.select do |event|
45
+ event['event'] == 'labeled' &&
46
+ event['label'].name.downcase == label.downcase
47
+ end,
48
+ )
49
+ end
50
+
51
+ # Filter to all events where an issue was closed
52
+ # @return [EventCollection] a collection containing all events where an
53
+ # issue was closed
54
+ def closed
55
+ wrap(@inner.select { |event| event.event == 'closed' })
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,36 @@
1
+ module Hubkit
2
+ # Returns all events for a GitHub issues-- for example, labeling, unlabeling,
3
+ # closing, etc-- and handle pagination for you
4
+ class EventPaginator < Paginator
5
+ include Enumerable
6
+
7
+ # Initialize a new paginator for events from the API
8
+ # @param [String] org the github organization which contains the repo for
9
+ # which we'll gather events
10
+ # @param [String] repo the github repo name for which we'll gather events
11
+ # @param [optional Fixnum] issue_number if present, the number of the issue
12
+ # for which we'll sfind events
13
+ def initialize(org:, repo:, issue_number: nil)
14
+ @org = org
15
+ @repo = repo
16
+ @issue_number = issue_number
17
+
18
+ opts =
19
+ if issue_number.present?
20
+ { issue_number: issue_number }
21
+ else
22
+ {}
23
+ end
24
+
25
+ super() do |i|
26
+ Cooldowner.with_cooldown do
27
+ Hubkit.client.issues.events.list(
28
+ @org,
29
+ @repo,
30
+ opts.merge(page: i),
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,185 @@
1
+ module Hubkit
2
+ # Represents one issue in github
3
+ # @attr [String] org the github organization name of the issue
4
+ # @attr [String] repo the github repo name
5
+ # @attr [Fixnum] number the issue's number
6
+ class Issue
7
+ # A regex which matches a textual name of an issue i.e. dingus/123 or
8
+ # foobar/dingus/123
9
+ ISSUE_PATTERN = %r{
10
+ (
11
+ (?<org>[^/]+)/(?<repo>[^/]+)
12
+ |
13
+ (?<repo>[^/]+)
14
+ )
15
+ /
16
+ (?<number>[0-9]+)
17
+ }x
18
+
19
+ # Create an Issue model from the textual name of the issue
20
+ # @example
21
+ # Hubkit::Issue.from_name('foobar/dingus/123')
22
+ # @param [String] name a string name for an issue, like dingus/123 or
23
+ # foobar/dingus/123
24
+ # @return [Hubkit::Issue] the issue matching this org, repo, and number
25
+ def self.from_name(name)
26
+ new(**parameters_from_name(name))
27
+ end
28
+
29
+ # Parse an issue name into org, repo, and number
30
+ # @param [String] name a string like dingus/123 or foobar/dingus/123
31
+ # @return [Hash] a hash containing org:, repo:, and number: values
32
+ def self.parameters_from_name(name)
33
+ match_data = Issue::ISSUE_PATTERN.match(name)
34
+ {
35
+ org: match_data[:org] || Hubkit::Configuration.default_org,
36
+ repo: match_data[:repo],
37
+ number: match_data[:number],
38
+ }
39
+ end
40
+
41
+ # Create an Issue model from a github api payload. Preseeds the github
42
+ # payload of the model to avoid refetching from the API later.
43
+ # @param [String] org the github organization of the issue
44
+ # @param [String] repo the repo name of the issue
45
+ # @param [Hash] gh the payload hash from the API
46
+ def self.from_gh(org:, repo:, gh:)
47
+ new(org: org, repo: repo, number: gh['number']).tap do |issue|
48
+ issue.instance_variable_set(:@_to_gh, gh)
49
+ end
50
+ end
51
+
52
+ attr_accessor :org, :repo, :number
53
+
54
+ # Create a new issue model
55
+ # @param [String] org the github organization name of the issue
56
+ # @param [String] repo the github repo name
57
+ # @param [Fixnum] number the issue's number
58
+ def initialize(org:, repo:, number:)
59
+ @org = org
60
+ @repo = repo
61
+ @number = number
62
+ end
63
+
64
+ # The issue payload from the github API
65
+ # @return [Github::Mash] a hash-like object of the github API response
66
+ def to_gh
67
+ @_to_gh ||= Github.issues.get(@org, @repo, @number).body
68
+ end
69
+
70
+ # A list of all of the current labels of the issue
71
+ # @return [Enumerable] the list of labels
72
+ def labels
73
+ @_labels ||= to_gh['labels'].map { |label| label.name.downcase }
74
+ end
75
+
76
+ # Add a label to an issue
77
+ # @param [String] label the label to add to the issue
78
+ # @return [Enumerable] the new list of labels including the addition
79
+ def label!(label)
80
+ Hubkit.client.issues.labels.add @org, @repo, @number, label
81
+ labels.append(label).uniq!
82
+ end
83
+
84
+ # Remove a label from an issue
85
+ # @yieldparam [String] label_name a current label. if the block returns
86
+ # true, this label will be removed from the list
87
+ # @return [Enumerable] the new list of labels after any removals
88
+ def unlabel!(&block)
89
+ fail('Block is required for unlabel') unless block_given?
90
+ to_gh.labels.map(&:name)
91
+ .select do |label_name|
92
+ yield label_name
93
+ end
94
+ .each do |label_name|
95
+ Hubkit.client.issues.labels.remove @org, @repo, @number, label_name: label_name
96
+ labels.delete(label_name)
97
+ end
98
+ labels
99
+ end
100
+
101
+ delegate :title, :number, :body, to: :to_gh
102
+
103
+ # Returns true if the issue is a pull request
104
+ # @return [Boolean] true if the issue is a pull request, otherwise false
105
+ def pull?
106
+ to_gh['pull_request'].present?
107
+ end
108
+
109
+ # Returns true if the issue is not a pull request
110
+ # @return [Boolean] false if the issue is a pull request, otherwise true
111
+ def true_issue?
112
+ !pull?
113
+ end
114
+
115
+ # Returns the time at which the issue was labeled `label`. It will return
116
+ # the time of the latest such event.
117
+ # @param [String] label the label to search for
118
+ # @return [Date] the date when that issue was labeled such.
119
+ def when_labeled(label)
120
+ return unless events.labeled(label).any?
121
+ Time.parse(events.labeled(label).first['created_at'])
122
+ end
123
+
124
+ # Returns when the issue was opened
125
+ # @return [DateTime] the DateTime when the issue was created
126
+ def when_opened
127
+ Time.parse(created_at)
128
+ end
129
+
130
+ # Returns when the issue was closed
131
+ # @return [DateTime] the DateTime when the issue was closed
132
+ def when_closed
133
+ return unless events.closed.any?
134
+ Time.parse(events.closed.first['created_at'])
135
+ end
136
+
137
+ # The EventCollection of events related to this Issue
138
+ # @return [EventCollection] the events for this issue, in reverse chronological order
139
+ def events
140
+ @_events ||=
141
+ EventCollection.new(
142
+ event_paginator,
143
+ )
144
+ .reverse_chronological
145
+ end
146
+
147
+ # Returns a paginator for fetching all events for this issue
148
+ # @return [EventPaginator] the paginator for fetching all events for this issue
149
+ def event_paginator
150
+ EventPaginator.new(
151
+ org: @org,
152
+ repo: @repo,
153
+ issue_number: @number,
154
+ )
155
+ end
156
+
157
+ # Return the list of usernames of every user ever assigned to an issue
158
+ # @return [Enumerable] the usernames of the users which have been assigned
159
+ # to the issue
160
+ def users_ever_assigned
161
+ events
162
+ .select { |event| event.event == 'assigned' }
163
+ .map { |event| event.assignee.login }
164
+ .uniq
165
+ end
166
+
167
+ # A plaintext version of the issue, with the name, title, and body
168
+ # @return [String] a string containing the name, title, and body, suitable
169
+ # for uses like export or chat integration
170
+ def to_text
171
+ "#{number}: #{title}\n---\n#{body}\n"
172
+ end
173
+
174
+ # Allow access to elements of the github payload by calling the names as
175
+ # methods
176
+ # @param [String, Symbol] name
177
+ # @param [Array] args should be empty, only needed to match the expected
178
+ # signature from Ruby
179
+ # @return the value of to_gh[name]
180
+ def method_missing(name, *args, &block)
181
+ return to_gh[name] if args.length == 0 && !block_given? && to_gh.key?(name)
182
+ super
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,101 @@
1
+ module Hubkit
2
+ # A collection of GitHub issues with chainable filters
3
+ class IssueCollection < ChainableCollection
4
+ # Filters to issues which where at some point labeled with the given label
5
+ # regardless of whether it currently has that label or not
6
+ # @param [String] label the label in question
7
+ # @return [IssueCollection] a collection of issues which were ever labeled
8
+ # with that label
9
+ def ever_labeled(label)
10
+ wrap(
11
+ @inner.select do |issue|
12
+ issue.when_labeled(label).present?
13
+ end,
14
+ )
15
+ end
16
+
17
+ # Return a collection of issues which are open
18
+ # @return [IssueCollection] the issues in the collection which are open
19
+ def open
20
+ wrap(@inner.select { |issue| issue.state == "open" })
21
+ end
22
+
23
+ # Return a collection of issues which are closed
24
+ # @return [IssueCollection] the issues in the collection which are closed
25
+ def closed
26
+ wrap(@inner.select { |issue| issue.state == "closed" })
27
+ end
28
+
29
+ # Return a collection of issues which are labeled with any of the given
30
+ # labels
31
+ # @param [Enumerable, String] labels if a string, then this is the single
32
+ # label used to filter issues. If it is an enumerable set of strings,
33
+ # any issues matching any of the labels will be returned.
34
+ # @return [IssueCollection] a collection of matching the label
35
+ def labeled(labels)
36
+ return wrap(labels.flat_map { |label| labeled(label) }) if labels.respond_to?(:map)
37
+
38
+ wrap(@inner.select do |issue|
39
+ issue.labels.include?(labels.downcase)
40
+ end)
41
+ end
42
+
43
+ # Return a collection of issues which are not labeled
44
+ # @return [IssueCollection] a collection of issues without labels
45
+ def unlabeled
46
+ wrap(@inner.select { |issue| issue.labels.empty? })
47
+ end
48
+
49
+ # Return a collection of issues which have labels matching a pattern
50
+ # @param [Regex] label_pattern a pattern which issues must match to be
51
+ # included in the collection
52
+ # @return [IssueCollection] a collection of issues with labels matching
53
+ # the pattern
54
+ def labeled_like(label_pattern)
55
+ wrap(@inner.select do |issue|
56
+ issue.labels.any? { |label| label_pattern.match(label) }
57
+ end)
58
+ end
59
+
60
+ # Return issues which are not pull requests
61
+ # @return [IssueCollection] a collection of issues which are not pull
62
+ # requests
63
+ def true_issues
64
+ wrap(@inner.select(&:true_issue?))
65
+ end
66
+
67
+ # Return issues which are pull requests
68
+ # @return [IssueCollection] a collection of issues which are pull requests
69
+ def pulls
70
+ wrap(@inner.select(&:pull?))
71
+ end
72
+
73
+ # Return issues which are unassigned
74
+ # @return [IssueCollection] a collection of issues which are unassigned
75
+ def unassigned
76
+ wrap(@inner.select { |issue| issue.assignee.nil? })
77
+ end
78
+
79
+ # Returns issues opened within a date window between start_dt and end_dt
80
+ # @param [Date] start_dt the beginning date of the filter window (inclusive)
81
+ # @param [Date] end_dt the end date of the filter window (exclusive)
82
+ # @return [IssueCollection] a collection of issues opened within the window
83
+ def opened_between(start_dt, end_dt)
84
+ wrap(
85
+ @inner.select do |issue|
86
+ start_dt <= issue.when_opened && issue.when_opened < end_dt
87
+ end,
88
+ )
89
+ end
90
+
91
+ # Group the issue collection by the current assignee
92
+ # @return [Hash] a hash, where the assignee's username is the key, and the
93
+ # values are IssueCollections of issues assigned to that user
94
+ def by_assignee
95
+ groups = group_by { |issue| issue.assignee.try(:login) }
96
+ groups.each_with_object(groups) do |(key, list), memo|
97
+ memo[key] = wrap(list)
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,23 @@
1
+ module Hubkit
2
+ # Returns the list of issues for a repo, handling pagination for you
3
+ class IssuePaginator < Paginator
4
+ include Enumerable
5
+
6
+ # Initialize a new paginator for issues from the API
7
+ # @param [String] org the github organization which contains the repo for
8
+ # which we'll gather issues
9
+ # @param [String] repo the github repo name for which we'll gather issues
10
+ # @param [optional String] state if missing or open, the paginator will
11
+ # only have open issues returned. If 'all', the paginator will give you
12
+ # open and closed issues.
13
+ def initialize(org:, repo:, state: 'open')
14
+ @org = org
15
+ @repo = repo
16
+ super() do |i|
17
+ Cooldowner.with_cooldown do
18
+ Github.issues.list(user: @org, repo: @repo, state: state, page: i)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,62 @@
1
+ require 'logger'
2
+
3
+ module Hubkit
4
+ # The logger Hubkit uses for diagnostic info. If used in a Rails app and
5
+ # that Rails app already has a logger, it will use that same logger.
6
+ # Prepends [HUBKIT] to all log statements for easy filtering.
7
+ class Logger
8
+ # Write a debug level message to the log
9
+ # @param [String] msg the message to log
10
+ # @return [Boolean] true
11
+ def self.debug(msg)
12
+ inner_logger.debug('[HUBKIT] ' + msg)
13
+ end
14
+
15
+ # Write a info level message to the log
16
+ # @param [String] msg the message to log
17
+ # @return [Boolean] true
18
+ def self.info(msg)
19
+ inner_logger.info('[HUBKIT] ' + msg)
20
+ end
21
+
22
+ # Write a warning level message to the log
23
+ # @param [String] msg the message to log
24
+ # @return [Boolean] true
25
+ def self.warn(msg)
26
+ inner_logger.warn('[HUBKIT] ' + msg)
27
+ end
28
+
29
+ # Write a error level message to the log
30
+ # @param [String] msg the message to log
31
+ # @return [Boolean] true
32
+ def self.error(msg)
33
+ inner_logger.error('[HUBKIT] ' + msg)
34
+ end
35
+
36
+ # Write a fatal level message to the log
37
+ # @param [String] msg the message to log
38
+ # @return [Boolean] true
39
+ def self.fatal(msg)
40
+ inner_logger.fatal('[HUBKIT] ' + msg)
41
+ end
42
+
43
+ # Write a unknown level message to the log
44
+ # @param [String] msg the message to log
45
+ # @return [Boolean] true
46
+ def self.unknown(msg)
47
+ inner_logger.unknown('[HUBKIT] ' + msg)
48
+ end
49
+
50
+ # Return the ruby logger used by Hubkit
51
+ # @return [Logger] the logger. Rails is in use, will reuse the Rails
52
+ # logger.
53
+ def self.inner_logger
54
+ @_inner_logger ||=
55
+ if Kernel.const_defined? 'Rails'
56
+ Rails.logger
57
+ else
58
+ ::Logger.new(STDERR)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,28 @@
1
+ module Hubkit
2
+ # @abstract A class which implements an Enumerable which yields each resource returned
3
+ # by GitHub in turn, and handles the pagination for you (acts like a flat array,
4
+ # not an array of pages)
5
+ class Paginator
6
+ include Enumerable
7
+
8
+ # Construct a new paginator
9
+ # @yieldparam [Object] result each page of results
10
+ def initialize(&block)
11
+ @block = block
12
+ end
13
+
14
+ # Iterate through each page of the results and perform a block
15
+ # @yieldparam [Object] result each page of the
16
+ def each
17
+ i = 1
18
+ loop do
19
+ results = @block.call i
20
+ results.each do |result|
21
+ yield result
22
+ end
23
+ i += 1
24
+ break if results.length == 0
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,113 @@
1
+ module Hubkit
2
+ # Represents one GitHub repo
3
+ # @attr [String] org the github organization containing the repo
4
+ # @attr [String] repo the github repo name
5
+ class Repo
6
+ attr_accessor :org, :repo
7
+
8
+ # List available repos
9
+ # @param [optional String] visibility if missing or 'all', retrieves all
10
+ # repos. if 'public', only retrieves public repos
11
+ # @return [Enumerable] a list of Repo models accessible with the current
12
+ # credentials
13
+ def self.list(visibility='all')
14
+ RepoCollection.new(
15
+ RepoPaginator
16
+ .new(visibility)
17
+ .map { |repo| { org: repo.owner.login, repo: repo.name } }
18
+ .map { |params| new(**params) }
19
+ )
20
+ end
21
+
22
+ # Initialize a Repo model from a github API payload
23
+ # @param [Github::Mash] gh the github API response
24
+ # @return [Repo] the matching Repo model
25
+ def self.from_gh(gh)
26
+ new(org: gh['owner']['login'], repo: gh.name)
27
+ end
28
+
29
+ # Initialize a Repo model from the name of the repo within the default
30
+ # organization
31
+ # @param [String] name the name of the repo
32
+ # @return [Repo] the matching Repo model
33
+ def self.from_name_with_default_org(name)
34
+ fail('Hubkit::Configuration.default_org is not set') if Hubkit::Configuration.default_org.nil?
35
+ return from_name(name) if name.include?('/')
36
+ from_name "#{Hubkit::Configuration.default_org}/#{name}"
37
+ end
38
+
39
+ # Initialize a Repo model from the organization and repo name
40
+ # @param [String] name the name of the organization/repo separated by a slash (/)
41
+ # @return [Repo] the matching Repo model
42
+ def self.from_name(name)
43
+ org, repo = name.split('/')
44
+ self.new(org: org, repo: repo)
45
+ end
46
+
47
+ # Construct a Repo model from the organization and repo name
48
+ # @param [String] org the name of the github organization
49
+ # @param [String] repo the name of the github repo
50
+ def initialize(org:, repo:)
51
+ @org = org
52
+ @repo = repo
53
+ end
54
+
55
+ # A list of issues for this Repo
56
+ # @param [Boolean] include_closed if true, will include closed issue. if
57
+ # false or missing, the result will only include open issues
58
+ # @return [IssueCollection] the list of issues for the repo
59
+ def issues(include_closed = false)
60
+ issues_and_pulls(include_closed).true_issues
61
+ end
62
+
63
+ delegate :pulls, to: :issues_and_pulls
64
+
65
+ # A list of issues and pull requests for this Repo
66
+ # @param [Boolean] include_closed if true, will include closed issue. if
67
+ # false or missing, the result will only include open issues
68
+ # @return [IssueCollection] the list of issues and pulls for the repo
69
+ def issues_and_pulls(include_closed = false)
70
+ @_issues_and_pulls ||= {}
71
+
72
+ @_issues_and_pulls[include_closed] ||=
73
+ IssueCollection.new(
74
+ paginator_for_status(include_closed).to_a.flat_map do |gh|
75
+ Issue.from_gh(org: @org, repo: @repo, gh: gh)
76
+ end,
77
+ )
78
+ end
79
+
80
+ # Get an IssuePaginator for a given open/closed issue status
81
+ # @param [Boolean] include_closed if true, paginator will return results
82
+ # with closed issues. if false, closed issues will be excluded
83
+ # @return [IssuePaginator] the issue paginator for this status and repo
84
+ def paginator_for_status(include_closed)
85
+ state_flag = include_closed ? 'all' : 'open'
86
+
87
+ IssuePaginator.new(
88
+ org: @org,
89
+ repo: @repo,
90
+ state: state_flag,
91
+ )
92
+ end
93
+
94
+ # Get an array of label strings which are available on this repo
95
+ # @return [Enumerable] a list of strings which are the names of labels
96
+ # available on this repo
97
+ def labels
98
+ @_labels ||=
99
+ Github
100
+ .issues.labels.list(@org, @repo, per_page: 100)
101
+ .map(&:name)
102
+ .map(&:downcase)
103
+ .uniq
104
+ end
105
+
106
+ # Return a human readable description of the Repo model
107
+ # @return [String] the human readable representation of the Repo model
108
+ # for the console
109
+ def inspect
110
+ "#<Hubkit::Repo:0x#{(object_id << 1).to_s(16)} #{@org}/#{@repo}>"
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,14 @@
1
+ module Hubkit
2
+ # A collection of Repos with chainable filters
3
+ class RepoCollection < ChainableCollection
4
+ scope :organization do |x|
5
+ @inner.select do |repo|
6
+ repo['owner']['login'] == x
7
+ end
8
+ end
9
+
10
+ scope :fork do
11
+ wrap(@inner.select { |repo| repo['fork'] })
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ module Hubkit
2
+ # Retrieves all visible repos in one flat array, handling GitHub pagination
3
+ class RepoPaginator < Paginator
4
+ include Enumerable
5
+
6
+ # Construct a new repo paginator
7
+ # @param [optional String] visibility if missing or 'all', retrieves all
8
+ # repos. if 'public', only retrieves public repos
9
+ def initialize(visibility='all')
10
+ super() do |i|
11
+ Cooldowner.with_cooldown do
12
+ Hubkit.client.repos.list(visibility: visibility, page: i)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,4 @@
1
+ module Hubkit
2
+ # The current version of the Hubkit library
3
+ VERSION = "0.1.0"
4
+ end
metadata ADDED
@@ -0,0 +1,150 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hubkit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Robert Prehn
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2017-06-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: github_api
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.16.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.16.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '4'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '6'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '4'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '6'
47
+ - !ruby/object:Gem::Dependency
48
+ name: bundler
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.14'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.14'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rake
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '10.0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '10.0'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rspec
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.0'
89
+ description: |2
90
+ Hubkit provides methods for querying the github API at a higher level than
91
+ making individual API calls. Think of it like an ORM for the github API.
92
+ email:
93
+ - robert@revelry.co
94
+ executables: []
95
+ extensions: []
96
+ extra_rdoc_files: []
97
+ files:
98
+ - ".gitignore"
99
+ - ".rspec"
100
+ - ".travis.yml"
101
+ - CODE_OF_CONDUCT.md
102
+ - CONTRIBUTING.md
103
+ - Gemfile
104
+ - LICENSE
105
+ - README.md
106
+ - Rakefile
107
+ - bin/console
108
+ - bin/setup
109
+ - hubkit.gemspec
110
+ - lib/hubkit.rb
111
+ - lib/hubkit/chainable_collection.rb
112
+ - lib/hubkit/configuration.rb
113
+ - lib/hubkit/cooldowner.rb
114
+ - lib/hubkit/event_collection.rb
115
+ - lib/hubkit/event_paginator.rb
116
+ - lib/hubkit/issue.rb
117
+ - lib/hubkit/issue_collection.rb
118
+ - lib/hubkit/issue_paginator.rb
119
+ - lib/hubkit/logger.rb
120
+ - lib/hubkit/paginator.rb
121
+ - lib/hubkit/repo.rb
122
+ - lib/hubkit/repo_collection.rb
123
+ - lib/hubkit/repo_paginator.rb
124
+ - lib/hubkit/version.rb
125
+ homepage: https://github.com/prehnRA/hubkit
126
+ licenses:
127
+ - MIT
128
+ metadata: {}
129
+ post_install_message:
130
+ rdoc_options: []
131
+ require_paths:
132
+ - lib
133
+ required_ruby_version: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ required_rubygems_version: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - ">="
141
+ - !ruby/object:Gem::Version
142
+ version: '0'
143
+ requirements: []
144
+ rubyforge_project:
145
+ rubygems_version: 2.5.1
146
+ signing_key:
147
+ specification_version: 4
148
+ summary: Higher level abstractions for querying the github API
149
+ test_files: []
150
+ has_rdoc: