inq 26.0.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.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/.cirrus.yml +84 -0
  3. data/.codeclimate.yml +23 -0
  4. data/.github_changelog_generator +2 -0
  5. data/.gitignore +15 -0
  6. data/.rspec +2 -0
  7. data/.rubocop.yml +260 -0
  8. data/.travis.yml +24 -0
  9. data/CHANGELOG.md +499 -0
  10. data/CODE_OF_CONDUCT.md +49 -0
  11. data/CONTRIBUTING.md +34 -0
  12. data/Gemfile +15 -0
  13. data/ISSUES.md +62 -0
  14. data/LICENSE.txt +21 -0
  15. data/README.md +91 -0
  16. data/Rakefile +76 -0
  17. data/bin/console +14 -0
  18. data/bin/prerelease-generate-changelog +28 -0
  19. data/bin/setup +8 -0
  20. data/bors.toml +17 -0
  21. data/build-debug.rb +20 -0
  22. data/exe/inq +7 -0
  23. data/fixtures/vcr_cassettes/how-is-example-empty-repository.yml +597 -0
  24. data/fixtures/vcr_cassettes/how-is-example-repository.yml +768 -0
  25. data/fixtures/vcr_cassettes/how-is-from-config-frontmatter.yml +23940 -0
  26. data/fixtures/vcr_cassettes/how-is-how-is-travis-api-repos-builds.yml +66 -0
  27. data/fixtures/vcr_cassettes/how-is-with-config-file.yml +23940 -0
  28. data/fixtures/vcr_cassettes/how_is_contributions_additions_count.yml +247 -0
  29. data/fixtures/vcr_cassettes/how_is_contributions_all_contributors.yml +247 -0
  30. data/fixtures/vcr_cassettes/how_is_contributions_changed_files.yml +247 -0
  31. data/fixtures/vcr_cassettes/how_is_contributions_changes.yml +247 -0
  32. data/fixtures/vcr_cassettes/how_is_contributions_commits.yml +247 -0
  33. data/fixtures/vcr_cassettes/how_is_contributions_compare_url.yml +81 -0
  34. data/fixtures/vcr_cassettes/how_is_contributions_default_branch.yml +81 -0
  35. data/fixtures/vcr_cassettes/how_is_contributions_deletions_count.yml +247 -0
  36. data/fixtures/vcr_cassettes/how_is_contributions_new_contributors.yml +402 -0
  37. data/fixtures/vcr_cassettes/how_is_contributions_summary.yml +325 -0
  38. data/fixtures/vcr_cassettes/how_is_contributions_summary_2.yml +325 -0
  39. data/inq.gemspec +45 -0
  40. data/lib/inq.rb +63 -0
  41. data/lib/inq/cacheable.rb +71 -0
  42. data/lib/inq/cli.rb +135 -0
  43. data/lib/inq/config.rb +123 -0
  44. data/lib/inq/constants.rb +9 -0
  45. data/lib/inq/date_time_helpers.rb +48 -0
  46. data/lib/inq/exe.rb +66 -0
  47. data/lib/inq/frontmatter.rb +51 -0
  48. data/lib/inq/report.rb +140 -0
  49. data/lib/inq/report_collection.rb +113 -0
  50. data/lib/inq/sources.rb +11 -0
  51. data/lib/inq/sources/ci/appveyor.rb +87 -0
  52. data/lib/inq/sources/ci/travis.rb +159 -0
  53. data/lib/inq/sources/github.rb +57 -0
  54. data/lib/inq/sources/github/contributions.rb +204 -0
  55. data/lib/inq/sources/github/issue_fetcher.rb +148 -0
  56. data/lib/inq/sources/github/issues.rb +126 -0
  57. data/lib/inq/sources/github/pulls.rb +29 -0
  58. data/lib/inq/sources/github_helpers.rb +106 -0
  59. data/lib/inq/template.rb +9 -0
  60. data/lib/inq/templates/contributions_partial.html +1 -0
  61. data/lib/inq/templates/issues_or_pulls_partial.html +7 -0
  62. data/lib/inq/templates/new_contributors_partial.html +5 -0
  63. data/lib/inq/templates/report.html +19 -0
  64. data/lib/inq/templates/report_partial.html +12 -0
  65. data/lib/inq/text.rb +26 -0
  66. data/lib/inq/version.rb +6 -0
  67. metadata +263 -0
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "inq"
4
+ require "inq/constants"
5
+ require "okay/simple_opts"
6
+
7
+ module Inq
8
+ ##
9
+ # Parses command-line arguments for inq.
10
+ class CLI
11
+ MissingArgument = Class.new(OptionParser::MissingArgument)
12
+
13
+ REPO_REGEXP = /.+\/.+/
14
+ DATE_REGEXP = /\d\d\d\d-\d\d-\d\d/
15
+
16
+ attr_reader :options, :help_text
17
+
18
+ def self.parse(*args)
19
+ new.parse(*args)
20
+ end
21
+
22
+ def initialize
23
+ @options = nil
24
+ @help_text = nil
25
+ end
26
+
27
+ # Parses an Array of command-line arguments into an equivalent Hash.
28
+ #
29
+ # The results of this can be used to control the behavior of the rest
30
+ # of the library.
31
+ #
32
+ # @params argv [Array] An array of command-line arguments, e.g. +ARGV+.
33
+ # @return [Hash] A Hash containing data used to control Inq's behavior.
34
+ def parse(argv)
35
+ parser, options = parse_main(argv)
36
+
37
+ # Options that are mutually-exclusive with everything else.
38
+ options = {:help => true} if options[:help]
39
+ options = {:version => true} if options[:version]
40
+
41
+ validate_options!(options)
42
+
43
+ @options = options
44
+ @help_text = parser.to_s
45
+
46
+ self
47
+ end
48
+
49
+ private
50
+
51
+ # parse_main() is as short as can be managed. It's fine as-is.
52
+ # rubocop:disable Metrics/MethodLength
53
+
54
+ # This does a significant chunk of the work for parse().
55
+ #
56
+ # @return [Array] An array containing the +OptionParser+ and the result
57
+ # of running it.
58
+ def parse_main(argv)
59
+ defaults = {
60
+ report: Inq::DEFAULT_REPORT_FILE,
61
+ }
62
+
63
+ opts = Okay::SimpleOpts.new(defaults: defaults)
64
+
65
+ opts.banner = <<~EOF
66
+ Usage: inq --repository REPOSITORY --date REPORT_DATE [--output REPORT_FILE]
67
+ inq --config CONFIG_FILE --date REPORT_DATE
68
+ EOF
69
+
70
+ opts.separator "\nOptions:"
71
+
72
+ opts.simple("--config CONFIG_FILE",
73
+ "YAML config file for automated reports.",
74
+ :config)
75
+
76
+ opts.simple("--no-user-config",
77
+ "Don't load user configuration file.",
78
+ :no_user_config)
79
+
80
+ opts.simple("--env-config",
81
+ "Use environment variables for configuration.",
82
+ "Read this first: https://inqrb.com/config",
83
+ :env_login)
84
+
85
+ opts.simple("--repository USER/REPO", REPO_REGEXP,
86
+ "Repository to generate a report for.",
87
+ :repository)
88
+
89
+ opts.simple("--date YYYY-MM-DD", DATE_REGEXP, "Last date of the report.",
90
+ :date)
91
+
92
+ opts.simple("--output REPORT_FILE", format_regexp,
93
+ "Output file for the report.",
94
+ "Supported file formats: #{formats}.",
95
+ :report)
96
+
97
+ opts.simple("--verbose", "Print debug information.", :verbose)
98
+ opts.simple("-v", "--version", "Prints version information.", :version)
99
+ opts.simple("-h", "--help", "Print help text.", :help)
100
+
101
+ [opts, opts.parse(argv)]
102
+ end
103
+
104
+ # rubocop:enable Metrics/MethodLength
105
+
106
+ # Given an +options+ Hash, determine if we got a valid combination of
107
+ # options.
108
+ #
109
+ # 1. Anything with `--help` and `--version` is always valid.
110
+ # 2. Otherwise, `--repository` or `--config` is required.
111
+ # 3. If `--repository` or `--config` is required, so is `--date`.
112
+ #
113
+ # @param options [Hash] The result of CLI#parse().
114
+ # @raise [MissingArgument] if we did not get a valid options Hash.
115
+ def validate_options!(options)
116
+ return if options[:help] || options[:version]
117
+ raise MissingArgument, "--date" unless options[:date]
118
+ raise MissingArgument, "--repository or --config" unless
119
+ options[:repository] || options[:config]
120
+ end
121
+
122
+ # @return [String] A comma-separated list of supported formats.
123
+ def formats
124
+ Inq.supported_formats.join(", ")
125
+ end
126
+
127
+ # @return [Regexp] a +Regexp+ object which matches any path ending
128
+ # with an extension corresponding to a supported format.
129
+ def format_regexp
130
+ regexp_parts = Inq.supported_formats.map { |x| Regexp.escape(x) }
131
+
132
+ /.+\.(#{regexp_parts.join("|")})/
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "inq/text"
5
+
6
+ module Inq
7
+ HOME_CONFIG = File.join(Dir.home, ".config", "inq", "config.yml")
8
+
9
+ # Usage:
10
+ # Inq::Config
11
+ # .load_site_configs("/path/to/config1.yml", "/path/to/config2.yml")
12
+ # .load_file("./repo-config.yml")
13
+ # Or:
14
+ # Inq::Config
15
+ # .load_defaults
16
+ # .load_file("./repo-config.yml")
17
+ # Or:
18
+ # Inq::Config
19
+ # .load_defaults
20
+ # .load({"repository" => "how-is/example-repository"})
21
+ class Config < Hash
22
+ attr_reader :site_configs
23
+
24
+ # If the INQ_USE_ENV+ environment variable is set, load config from
25
+ # the environment.
26
+ #
27
+ # Otherwise, load the the default config file.
28
+ #
29
+ # @return [Hash] A Hash representation of the config.
30
+ def load_defaults
31
+ if ENV["INQ_USE_ENV"] == "true"
32
+ load_env
33
+ else
34
+ load_site_configs(HOME_CONFIG)
35
+ end
36
+ end
37
+
38
+ def initialize
39
+ super()
40
+ @site_configs = []
41
+ end
42
+
43
+ # Load the config files as specified via +files+.
44
+ #
45
+ # @param files [Array<String>] The path(s) for config files.
46
+ # @return [Config] The config hash. (+self+)
47
+ def load_site_configs(*files)
48
+ # Allows both:
49
+ # load_site_configs('foo', 'bar')
50
+ # load_site_configs(['foo', bar'])
51
+ # but not:
52
+ # load_site_configs(['foo'], 'bar')
53
+ files = files[0] if files.length == 1 && files[0].is_a?(Array)
54
+
55
+ load_files(*files)
56
+ end
57
+
58
+ # TODO: See if this can be consolidated with load_site_configs.
59
+ def load_files(*file_paths)
60
+ files = (site_configs + file_paths).map { |f| Pathname.new(f) }
61
+
62
+ # Keep only files that exist.
63
+ files.select!(&:file?)
64
+
65
+ # Load the YAML files into Hashes.
66
+ configs = files.map { |file| YAML.safe_load(file.read) }
67
+
68
+ # Apply configs.
69
+ load(*configs)
70
+ end
71
+
72
+ # Take a collection of config hashes and cascade them, meaning values
73
+ # in later ones override values in earlier ones.
74
+ #
75
+ # E.g., this results in +{'a'=>'x', 'c'=>'d'}+:
76
+ # load({'a'=>'b'}, {'c'=>'d'}, {'a'=>'x'})
77
+ #
78
+ # And this results in +{'a'=>['b', 'c']}+:
79
+ # load({'a'=>['b']}, {'a'=>['c']})
80
+ #
81
+ # @param [Array<Hash>] The configuration hashes.
82
+ # @return [Config] The final configuration value.
83
+ def load(*configs)
84
+ configs.each do |config|
85
+ config.each do |k, v|
86
+ if self[k] && self[k].is_a?(Array)
87
+ self[k] += v
88
+ else
89
+ self[k] = v
90
+ end
91
+ end
92
+ end
93
+
94
+ self
95
+ end
96
+
97
+ # Load config info from environment variables.
98
+ #
99
+ # Supported environment variables:
100
+ # - INQ_GITHUB_TOKEN: a GitHub authentication token.
101
+ # - INQ_GITHUB_USERNAME: the GitHub username corresponding to the token.
102
+ #
103
+ # @return [Config] The resulting configuration.
104
+ def load_env
105
+ Inq::Text.puts "Using configuration from environment variables."
106
+
107
+ gh_token = ENV["INQ_GITHUB_TOKEN"] || ENV["HOWIS_GITHUB_TOKEN"]
108
+ gh_username = ENV["INQ_GITHUB_USERNAME"] || ENV["HOWIS_GITHUB_USERNAME"]
109
+
110
+ raise "INQ_GITHUB_TOKEN environment variable is not set" \
111
+ unless gh_token
112
+ raise "INQ_GITHUB_USERNAME environment variable is not set" \
113
+ unless gh_username
114
+
115
+ load({
116
+ "sources/github" => {
117
+ "username" => gh_username,
118
+ "token" => gh_token,
119
+ },
120
+ })
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inq
4
+ # The file name used for a report if one isn't specified.
5
+ DEFAULT_REPORT_FILE = "report.html"
6
+
7
+ # Used by things making HTTP requests.
8
+ USER_AGENT = "inq/#{Inq::VERSION} (https://github.com/duckinator/inq)"
9
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "inq/version"
4
+ require "date"
5
+
6
+ module Inq
7
+ ##
8
+ # Various helper functions for working with DateTime objects.
9
+ module DateTimeHelpers
10
+ # Check if +left+ is less than or equal to +right+, where both are string
11
+ # representations of a date.
12
+ #
13
+ # @param left [String] A string representation of a date.
14
+ # @param right [String] A string representation of a date.
15
+ # @return [Boolean] True if +left+ is less-than-or-equal to +right+,
16
+ # otherwise false.
17
+ def date_le(left, right)
18
+ left = str_to_dt(left)
19
+ right = str_to_dt(right)
20
+
21
+ left <= right
22
+ end
23
+
24
+ # Check if +left+ is greater than or equal to +right+, where both are string
25
+ # representations of a date.
26
+ #
27
+ # @param left [String] A string representation of a date.
28
+ # @param right [String] A string representation of a date.
29
+ # @return [Boolean] True if +left+ is greater-than-or-equal to +right+,
30
+ # otherwise false.
31
+ def date_ge(left, right)
32
+ left = str_to_dt(left)
33
+ right = str_to_dt(right)
34
+
35
+ left >= right
36
+ end
37
+
38
+ private
39
+
40
+ # Converts a +String+ representation of a date to a +DateTime+.
41
+ #
42
+ # @param str [String] A date.
43
+ # @return [DateTime] A DateTime representation of +str+.
44
+ def str_to_dt(str)
45
+ DateTime.parse(str)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "inq"
4
+ require "inq/cli"
5
+ require "inq/config"
6
+ require "inq/text"
7
+
8
+ module Inq
9
+ ##
10
+ # A module which implements the entire command-line interface for Inq.
11
+ module Exe
12
+ def self.run(argv)
13
+ cli = parse_args(argv)
14
+ options = cli.options
15
+
16
+ abort cli.help_text if options[:help]
17
+ abort Inq::VERSION_STRING if options[:version]
18
+
19
+ execute(options)
20
+ end
21
+
22
+ def self.parse_args(argv)
23
+ Inq::CLI.parse(argv)
24
+ rescue OptionParser::ParseError => e
25
+ abort "inq: error: #{e.message}"
26
+ end
27
+ private_class_method :parse_args
28
+
29
+ def self.load_config(options)
30
+ config = Inq::Config.new
31
+
32
+ config.load_defaults unless options[:no_user_config]
33
+ config.load_env if options[:env_config]
34
+
35
+ if options[:config]
36
+ config.load_files(options[:config])
37
+ else
38
+ config.load(Inq.default_config(options[:repository]))
39
+ end
40
+
41
+ config
42
+ end
43
+ private_class_method :load_config
44
+
45
+ def self.save_reports(reports)
46
+ files = reports.save_all
47
+ Inq::Text.puts "Saved reports to:"
48
+ files.each { |file| Inq::Text.puts "- #{file}" }
49
+ end
50
+ private_class_method :save_reports
51
+
52
+ def self.execute(options)
53
+ date = options[:date]
54
+ config = load_config(options)
55
+ reports = Inq.from_config(config, date)
56
+ save_reports(reports)
57
+ rescue => e
58
+ raise if options[:verbose]
59
+
60
+ warn "inq: error: #{e.message} (Pass --verbose for more details.)"
61
+ warn " at: #{e.backtrace_locations.first}"
62
+ exit 1
63
+ end
64
+ private_class_method :execute
65
+ end
66
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "inq/version"
4
+ require "okay/warning_helpers"
5
+
6
+ module Inq
7
+ ##
8
+ # Module for generating YAML frontmatter, as used by Jekyll and other
9
+ # blog engines.
10
+ module Frontmatter
11
+ extend Okay::WarningHelpers
12
+
13
+ # Generates YAML frontmatter, as is used in Jekyll and other blog engines.
14
+ #
15
+ # E.g.,
16
+ # generate_frontmatter({'foo' => "bar %{baz}"}, {'baz' => "asdf"})
17
+ # => "---\nfoo: bar asdf\n"
18
+ #
19
+ # @param frontmatter [Hash] Frontmatter for the report.
20
+ # @param report_data [Hash] The report data itself.
21
+ # @return [String] A YAML dump of the generated frontmatter.
22
+ def self.generate(frontmatter, report_data)
23
+ return "" if frontmatter.nil?
24
+
25
+ frontmatter = convert_keys(frontmatter, :to_s)
26
+ report_data = convert_keys(report_data, :to_sym)
27
+
28
+ frontmatter = frontmatter.map { |k, v|
29
+ # Sometimes report_data has unused keys, which generates a warning, but
30
+ # we're okay with it.
31
+ v = silence_warnings { v % report_data }
32
+
33
+ [k, v]
34
+ }.to_h
35
+
36
+ YAML.dump(frontmatter) + "---\n\n"
37
+ end
38
+
39
+ # @example
40
+ # convert_keys({'foo' => 'bar'}, :to_sym)
41
+ # # => {:foo => 'bar'}
42
+ # @param data [Hash] The input hash.
43
+ # @param method_name [Symbol] The method name used to convert keys.
44
+ # (E.g. :to_s, :to_sym, etc.)
45
+ # @return [Hash] The converted result.
46
+ def self.convert_keys(data, method_name)
47
+ data.map { |k, v| [k.send(method_name), v] }.to_h
48
+ end
49
+ private_class_method :convert_keys
50
+ end
51
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "inq/frontmatter"
4
+ require "inq/cacheable"
5
+ require "inq/sources/github/contributions"
6
+ require "inq/sources/github/issues"
7
+ require "inq/sources/github/pulls"
8
+ require "inq/sources/ci/travis"
9
+ require "inq/sources/ci/appveyor"
10
+ require "inq/template"
11
+ require "json"
12
+
13
+ module Inq
14
+ ##
15
+ # Class for generating a report.
16
+ class Report
17
+ def initialize(config, end_date)
18
+ @config = config
19
+ @repository = config["repository"]
20
+
21
+ # NOTE: Use DateTime because it defaults to UTC and that's less gross
22
+ # than trying to get Date to use UTC.
23
+ #
24
+ # Not using UTC for this results in #compare_url giving different
25
+ # results for different time zones, which makes it harder to test.
26
+ #
27
+ # (I'm also guessing/hoping that GitHub's URLs use UTC.)
28
+ end_dt = DateTime.strptime(end_date, "%Y-%m-%d")
29
+ start_dt = start_dt_from_end_dt(end_dt)
30
+
31
+ @end_date = end_dt.strftime("%Y-%m-%d")
32
+ @start_date = start_dt.strftime("%Y-%m-%d")
33
+ end
34
+
35
+ def cache
36
+ @cache ||= Cacheable.new(@config, @start_date, @end_date)
37
+ end
38
+
39
+ def contributions
40
+ @gh_contributions ||= Sources::Github::Contributions.new(@config, @start_date, @end_date, cache)
41
+ end
42
+
43
+ def issues
44
+ @gh_issues ||= Sources::Github::Issues.new(@config, @start_date, @end_date, cache)
45
+ end
46
+
47
+ def pulls
48
+ @gh_pulls ||= Sources::Github::Pulls.new(@config, @start_date, @end_date, cache)
49
+ end
50
+
51
+ def travis
52
+ @travis ||= Sources::CI::Travis.new(@config, @start_date, @end_date, cache)
53
+ end
54
+
55
+ def appveyor
56
+ @appveyor ||= Sources::CI::Appveyor.new(@config, @start_date, @end_date, cache)
57
+ end
58
+
59
+ def to_h(frontmatter_data = nil)
60
+ @report_hash ||= report_hash
61
+ frontmatter = Frontmatter.generate(frontmatter_data, @report_hash)
62
+
63
+ @report_hash.merge(frontmatter: frontmatter)
64
+ end
65
+
66
+ def to_html_partial(frontmatter = nil)
67
+ Inq::Template.apply("report_partial.html", to_h(frontmatter))
68
+ end
69
+
70
+ def to_html(frontmatter = nil)
71
+ template_data = to_h(frontmatter).merge({report: to_html_partial})
72
+ Inq::Template.apply("report.html", template_data)
73
+ end
74
+
75
+ def to_json(frontmatter = nil)
76
+ frontmatter.to_s + JSON.pretty_generate(to_h)
77
+ end
78
+
79
+ def save_as(filename)
80
+ File.write(filename, to_format_for(filename))
81
+ end
82
+
83
+ def to_format_for(filename)
84
+ format = File.extname(filename)[1..-1]
85
+ send("to_#{format}")
86
+ end
87
+ private :to_format_for
88
+
89
+ def start_dt_from_end_dt(end_dt)
90
+ d = end_dt.day
91
+ m = end_dt.month
92
+ y = end_dt.year
93
+ start_year = y
94
+ start_month = m - 1
95
+ if start_month <= 0
96
+ start_month = 12 - start_month
97
+ start_year -= 1
98
+ end
99
+
100
+ DateTime.new(start_year, start_month, d)
101
+ end
102
+
103
+ # rubocop:disable Metrics/AbcSize
104
+ def report_hash
105
+ {
106
+ title: "How is #{@repository}?",
107
+ repository: @repository,
108
+
109
+ contributions_summary: contributions.to_html,
110
+ new_contributors: contributions.new_contributors_html,
111
+ issues_summary: issues.to_html,
112
+ pulls_summary: pulls.to_html,
113
+
114
+ issues: issues.to_a,
115
+ pulls: issues.to_a,
116
+
117
+ average_issue_age: issues.average_age,
118
+ average_pull_age: pulls.average_age,
119
+
120
+ oldest_issue_link: issues.oldest["url"],
121
+ oldest_issue_date: issues.oldest["createdAt"],
122
+
123
+ newest_issue_link: issues.newest["url"],
124
+ newest_issue_date: issues.newest["createdAt"],
125
+
126
+ newest_pull_link: pulls.newest["url"],
127
+ newest_pull_date: pulls.newest["createdAt"],
128
+
129
+ oldest_pull_link: pulls.oldest["url"],
130
+ oldest_pull_date: pulls.oldest["createdAt"],
131
+
132
+ travis_builds: travis.builds,
133
+ appveyor_builds: appveyor.builds,
134
+
135
+ date: @end_date,
136
+ }
137
+ end
138
+ # rubocop:enable Metrics/AbcSize
139
+ end
140
+ end