hubkit 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: