From 1d6426b34045b9a62a4b54e1d1b9ca7203a49bf0 Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Wed, 30 Sep 2026 16:57:26 -0500 Subject: [PATCH 1/4] feat(output): add custom templates and preserve compatible formats Add Mustache-based custom output with canonical template containment, use ISO 8601 dates, and match version labels precisely. Preserve object-based YAML serialization and compatible Markdown table output. Update runtime and development dependencies, require Ruby 3.3, and bump the version to 2.1.0. --- Gemfile.lock | 97 +++++++++++++++++++++++++- lib/template/.whatsup.yml | 9 +++ lib/template/table.mustache | 3 + lib/whatsup_github/config_reader.rb | 9 +++ lib/whatsup_github/custom_formatter.rb | 42 +++++++++++ lib/whatsup_github/row.rb | 20 +++++- lib/whatsup_github/runner.rb | 9 +++ lib/whatsup_github/table.rb | 17 +++-- lib/whatsup_github/version.rb | 2 +- lib/whatsup_github/yaml_formatter.rb | 20 +----- whatsup_github.gemspec | 7 +- 11 files changed, 205 insertions(+), 30 deletions(-) create mode 100644 lib/template/table.mustache create mode 100644 lib/whatsup_github/custom_formatter.rb diff --git a/Gemfile.lock b/Gemfile.lock index 62a8053..40c61d3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,10 @@ PATH remote: . specs: - whatsup_github (2.0.0) + whatsup_github (2.1.0) dotenv (~> 3.0) faraday-retry (~> 2.2) + mustache (~> 1.1) netrc (~> 0.11) octokit (~> 10.0) thor (~> 1.3) @@ -11,6 +12,19 @@ PATH GEM remote: https://rubygems.org/ specs: + activesupport (8.1.4) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) aruba (2.3.3) @@ -19,10 +33,16 @@ GEM cucumber (>= 8.0, < 11.0) rspec-expectations (>= 3.4, < 5.0) thor (~> 1.0) + ast (2.4.3) base64 (0.3.0) bigdecimal (4.1.1) builder (3.3.0) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) contracts (0.17.3) + crack (1.0.1) + bigdecimal + rexml cucumber (10.2.0) base64 (~> 0.2) builder (~> 3.2) @@ -49,8 +69,10 @@ GEM cucumber-messages (32.2.0) cucumber-tag-expressions (8.1.0) diff-lcs (1.6.2) + docile (1.4.1) dotenv (3.2.0) - faraday (2.14.1) + drb (2.2.3) + faraday (2.14.4) faraday-net_http (>= 2.0, < 3.5) json logger @@ -61,19 +83,38 @@ GEM ffi (1.17.4) ffi (1.17.4-arm64-darwin) fileutils (1.8.0) - json (2.19.3) + hashdiff (1.2.1) + i18n (1.15.2) + concurrent-ruby (~> 1.0) + json (3.0.2) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) logger (1.7.0) memoist3 (1.0.0) mini_mime (1.1.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) multi_test (1.1.0) + mustache (1.1.3) net-http (0.9.1) uri (>= 0.11.1) netrc (0.11.0) octokit (10.0.0) faraday (>= 1, < 3) sawyer (~> 0.9) + parallel (2.2.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + prism (1.9.0) public_suffix (7.0.5) + racc (1.8.1) + rack (3.2.7) + rainbow (3.1.1) rake (13.3.1) + regexp_parser (2.13.1) + rexml (3.4.4) rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -87,14 +128,60 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.7) + rubocop (1.91.0) + json (>= 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-github (0.27.0) + rubocop (>= 1.76) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.23) + rubocop-performance (1.27.0) + lint_roller (~> 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.38.0) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + ruby-progressbar (1.13.0) sawyer (0.9.3) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) + securerandom (0.4.1) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) sys-uname (1.5.1) ffi (~> 1.1) memoist3 (~> 1.0.0) thor (1.5.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.3.0) + unicode-emoji (~> 4.3) + unicode-emoji (4.3.0) uri (1.1.1) + vcr (6.4.0) + webmock (3.26.4) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) PLATFORMS arm64-darwin-22 @@ -107,6 +194,10 @@ DEPENDENCIES fileutils (~> 1.7) rake (~> 13.1) rspec (~> 3.12) + rubocop-github (~> 0.27) + simplecov (~> 0.22) + vcr (~> 6.2) + webmock (~> 3.19) whatsup_github! BUNDLED WITH diff --git a/lib/template/.whatsup.yml b/lib/template/.whatsup.yml index 7b1129c..6ed4808 100755 --- a/lib/template/.whatsup.yml +++ b/lib/template/.whatsup.yml @@ -23,6 +23,15 @@ labels: output_format: - yaml # - markdown +# - custom + +# Required when output_format includes 'custom'. +# 'templates.custom' must be a path inside the project, relative to where +# whatsup_github is run. Rows are exposed to the template as {{#rows}}, each +# with: description, versions, type, date, link, merge_commit, contributor, labels. +# templates: +# custom: path/to/your-template.mustache +# custom_output: tmp/whats-new-custom.txt # The phrase used as a separator in the pull request description. # All lines following this phrase are captured as 'description' in the output. diff --git a/lib/template/table.mustache b/lib/template/table.mustache new file mode 100644 index 0000000..f54a3e4 --- /dev/null +++ b/lib/template/table.mustache @@ -0,0 +1,3 @@ +{{#rows}} +| {{{description}}} | {{{versions}}} | {{{type}}} | {{{date}}} | +{{/rows}} diff --git a/lib/whatsup_github/config_reader.rb b/lib/whatsup_github/config_reader.rb index 4f0c6c3..43552c3 100644 --- a/lib/whatsup_github/config_reader.rb +++ b/lib/whatsup_github/config_reader.rb @@ -73,6 +73,15 @@ def membership def magic_word read['magic_word'] end + + # Path to a project-supplied Mustache template, used when output_format includes 'custom'. + def template_path + read.dig('templates', 'custom') + end + + def custom_output + read['custom_output'] + end end end diff --git a/lib/whatsup_github/custom_formatter.rb b/lib/whatsup_github/custom_formatter.rb new file mode 100644 index 0000000..421545a --- /dev/null +++ b/lib/whatsup_github/custom_formatter.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'mustache' + +module WhatsupGithub + # Renders rows through a project-supplied Mustache template (output_format: custom) + class CustomFormatter + def generate_output_from(content) + path = resolved_template_path + rows = content.map(&:to_h) + Mustache.render(File.read(path), 'rows' => rows) + end + + private + + def resolved_template_path + configured = Config.instance.template_path + abort "ERROR: 'templates.custom' is not set in your configuration file." if configured.nil? || configured.empty? + + root = File.expand_path(Dir.pwd) + full_path = File.expand_path(configured, root) + root_prefix = root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}" + + unless full_path.start_with?(root_prefix) + abort "ERROR: 'templates.custom' must point to a file inside the project directory (got: '#{configured}')." + end + + root = File.realpath(root) + canonical_path = File.realpath(full_path) + root_prefix = root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}" + + unless canonical_path.start_with?(root_prefix) + abort "ERROR: 'templates.custom' must point to a file inside the project directory (got: '#{configured}')." + end + abort "ERROR: Template file not found: '#{full_path}'" unless File.file?(canonical_path) + + canonical_path + rescue SystemCallError + abort "ERROR: Template file not found: '#{full_path || configured}'" + end + end +end diff --git a/lib/whatsup_github/row.rb b/lib/whatsup_github/row.rb index 7a61e2d..b22f66d 100755 --- a/lib/whatsup_github/row.rb +++ b/lib/whatsup_github/row.rb @@ -42,11 +42,15 @@ def magic_word end def versions - label_versions = labels.select { |label| label.start_with?(/\d\./) } + label_versions = labels.select { |label| label.match?(/\A\d+\./) } label_versions.join(', ') end def date + Date.parse(@date.to_s).to_s + end + + def date_string @date.strftime('%B %-e, %Y') end @@ -76,5 +80,19 @@ def description message end end + + # Raw field set shared by formatters that need the full row (YAML, custom templates). + def to_h + { + 'description' => description, + 'versions' => versions, + 'type' => type, + 'date' => date, + 'link' => link, + 'merge_commit' => merge_commit, + 'contributor' => author, + 'labels' => labels + } + end end end diff --git a/lib/whatsup_github/runner.rb b/lib/whatsup_github/runner.rb index 704f321..c50beaa 100755 --- a/lib/whatsup_github/runner.rb +++ b/lib/whatsup_github/runner.rb @@ -5,6 +5,7 @@ require_relative 'config_reader' require_relative 'yaml_formatter' require_relative 'table' +require_relative 'custom_formatter' module WhatsupGithub class Runner @@ -20,6 +21,7 @@ def run table if format.include? 'markdown' data if format.include? 'yaml' + custom if format.include? 'custom' end def write_results(file, formatter) @@ -40,5 +42,12 @@ def table def data write_results 'tmp/whats-new.yml', YAMLFormatter.new end + + def custom + output = @config.custom_output + abort "ERROR: 'custom_output' is not set in your configuration file." if output.nil? || output.empty? + + write_results output, CustomFormatter.new + end end end diff --git a/lib/whatsup_github/table.rb b/lib/whatsup_github/table.rb index a06cee5..ebc64ca 100755 --- a/lib/whatsup_github/table.rb +++ b/lib/whatsup_github/table.rb @@ -1,18 +1,23 @@ # frozen_string_literal: true +require 'mustache' require_relative 'row_collector' module WhatsupGithub # Table containing Rows class Table - # def initialize(since) - # @collector = RowCollector.new(since: since) - # end + TEMPLATE = File.read(File.expand_path('../template/table.mustache', __dir__)) def generate_output_from(content) - content.collect do |object| - "| #{object.description} | #{object.versions} | #{object.type} | #{object.date} |\n".tr_s(' ', ' ') - end.join + rows = content.collect do |object| + { + 'description' => object.description, + 'versions' => object.versions, + 'type' => object.type, + 'date' => object.date + } + end + Mustache.render(TEMPLATE, 'rows' => rows).tr_s(' ', ' ') end end end diff --git a/lib/whatsup_github/version.rb b/lib/whatsup_github/version.rb index 729917a..ceb0750 100644 --- a/lib/whatsup_github/version.rb +++ b/lib/whatsup_github/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module WhatsupGithub - VERSION = '2.0.0' + VERSION = '2.1.0' end diff --git a/lib/whatsup_github/yaml_formatter.rb b/lib/whatsup_github/yaml_formatter.rb index dd6d556..76dfb54 100644 --- a/lib/whatsup_github/yaml_formatter.rb +++ b/lib/whatsup_github/yaml_formatter.rb @@ -1,31 +1,15 @@ # frozen_string_literal: true require 'yaml' + module WhatsupGithub # Table containing Rows class YAMLFormatter - # def initialize(since) - # @collector = RowCollector.new(since: since) - # end - def generate_output_from(content) - entries = - content.collect do |object| - { - 'description' => object.description, - 'versions' => object.versions, - 'type' => object.type, - 'date' => object.date, - 'link' => object.link, - 'merge_commit' => object.merge_commit, - 'contributor' => object.author, - 'labels' => object.labels - } - end output = { 'updated' => Time.now.strftime('%c').tr_s(' ', ' '), - 'entries' => entries + 'entries' => content.collect(&:to_h) } output.to_yaml end diff --git a/whatsup_github.gemspec b/whatsup_github.gemspec index 247cc7e..be06e02 100644 --- a/whatsup_github.gemspec +++ b/whatsup_github.gemspec @@ -34,10 +34,11 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] - spec.required_ruby_version = '>= 3.0' + spec.required_ruby_version = '>= 3.3' spec.add_dependency 'dotenv', '~> 3.0' spec.add_dependency 'faraday-retry', '~> 2.2' + spec.add_dependency 'mustache', '~> 1.1' spec.add_dependency 'netrc', '~> 0.11' spec.add_dependency 'octokit', '~> 10.0' spec.add_dependency 'thor', '~> 1.3' @@ -47,5 +48,9 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'cucumber', '~> 10.1' spec.add_development_dependency 'rake', '~> 13.1' spec.add_development_dependency 'rspec', '~> 3.12' + spec.add_development_dependency 'rubocop-github', '~> 0.27' spec.add_development_dependency 'fileutils', '~> 1.7' + spec.add_development_dependency 'simplecov', '~> 0.22' + spec.add_development_dependency 'vcr', '~> 6.2' + spec.add_development_dependency 'webmock', '~> 3.19' end From 5005374c4c065097c4e479b9531f746e77a77811 Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Wed, 30 Sep 2026 16:57:26 -0500 Subject: [PATCH 2/4] test: expand unit coverage and add CLI integration scenarios Cover configuration, clients, pulls, formatters, rows, tables, and CLI behavior. Add network-free Cucumber scenarios and VCR-backed replay tooling, excluding VCR scenarios from the default test task. --- .simplecov | 6 + Rakefile | 3 +- cucumber.yml | 4 + ..._requests_for_a_real_public_repository.yml | 78 +++++++++++ features/since.feature | 111 +++++++++++++++ features/since_vcr.feature | 31 ++++ features/support/cli_runner.rb | 42 ++++++ features/support/hooks.rb | 30 ++++ features/support/vcr.rb | 10 ++ spec/spec_helper.rb | 16 +++ spec/support/netrc_helper.rb | 16 +++ spec/support/singleton_helper.rb | 8 ++ spec/whatsup_github/cli_spec.rb | 42 ++++++ spec/whatsup_github/client_spec.rb | 74 ++++++++++ spec/whatsup_github/config_reader_spec.rb | 118 ++++++++++++++++ spec/whatsup_github/custom_formatter_spec.rb | 132 ++++++++++++++++++ spec/whatsup_github/enterprise_client_spec.rb | 105 ++++++++++++++ spec/whatsup_github/generator_spec.rb | 25 ++++ spec/whatsup_github/pulls_spec.rb | 98 +++++++++++++ spec/whatsup_github/row_collector_spec.rb | 86 ++++++++++++ spec/whatsup_github/row_spec.rb | 111 +++++++++++++++ spec/whatsup_github/runner_spec.rb | 65 +++++++++ spec/whatsup_github/table_spec.rb | 33 +++++ spec/whatsup_github/yaml_formatter_spec.rb | 58 ++++++++ 24 files changed, 1301 insertions(+), 1 deletion(-) create mode 100644 .simplecov create mode 100644 cucumber.yml create mode 100644 features/cassettes/fetches_merged_pull_requests_for_a_real_public_repository.yml create mode 100644 features/since_vcr.feature create mode 100644 features/support/cli_runner.rb create mode 100644 features/support/hooks.rb create mode 100644 features/support/vcr.rb create mode 100644 spec/support/netrc_helper.rb create mode 100644 spec/support/singleton_helper.rb create mode 100644 spec/whatsup_github/cli_spec.rb create mode 100644 spec/whatsup_github/client_spec.rb create mode 100644 spec/whatsup_github/config_reader_spec.rb create mode 100644 spec/whatsup_github/custom_formatter_spec.rb create mode 100644 spec/whatsup_github/enterprise_client_spec.rb create mode 100644 spec/whatsup_github/generator_spec.rb create mode 100644 spec/whatsup_github/pulls_spec.rb create mode 100644 spec/whatsup_github/row_collector_spec.rb create mode 100644 spec/whatsup_github/row_spec.rb create mode 100644 spec/whatsup_github/runner_spec.rb create mode 100644 spec/whatsup_github/table_spec.rb create mode 100644 spec/whatsup_github/yaml_formatter_spec.rb diff --git a/.simplecov b/.simplecov new file mode 100644 index 0000000..72803a6 --- /dev/null +++ b/.simplecov @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +SimpleCov.start do + add_filter '/spec/' + add_filter '/features/' +end diff --git a/Rakefile b/Rakefile index 2e94734..23bd750 100644 --- a/Rakefile +++ b/Rakefile @@ -10,7 +10,8 @@ RSpec::Core::RakeTask.new(:spec) task default: :test Cucumber::Rake::Task.new(:features) do |t| - t.cucumber_opts = '--format pretty' # Any valid command line option can go here. + # @vcr scenarios need a recorded cassette (see features/since_vcr.feature) and are excluded by default. + t.cucumber_opts = '--format pretty --tags "not @vcr"' end task test: %i[spec features] diff --git a/cucumber.yml b/cucumber.yml new file mode 100644 index 0000000..d523cac --- /dev/null +++ b/cucumber.yml @@ -0,0 +1,4 @@ +# @vcr scenarios replay recorded HTTP interactions and need a real cassette +# recorded first (see features/since_vcr.feature); excluded by default. +default: --tags 'not @vcr' +vcr: --tags @vcr diff --git a/features/cassettes/fetches_merged_pull_requests_for_a_real_public_repository.yml b/features/cassettes/fetches_merged_pull_requests_for_a_real_public_repository.yml new file mode 100644 index 0000000..189a263 --- /dev/null +++ b/features/cassettes/fetches_merged_pull_requests_for_a_real_public_repository.yml @@ -0,0 +1,78 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.github.com/search/issues?per_page=100&q=repo:octokit/octokit.rb%20label:%22documentation%22%20merged:%3E=2026-01-01%20base:main%20is:pull-request + body: + encoding: US-ASCII + string: '' + headers: + Accept: + - application/vnd.github.v3+json + User-Agent: + - Octokit Ruby Gem 10.0.0 + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 29 Sep 2026 20:19:28 GMT + Content-Type: + - application/json; charset=utf-8 + Cache-Control: + - no-cache + Vary: + - Accept,Accept-Encoding, Accept, X-Requested-With + X-Github-Media-Type: + - github.v3; format=json + X-Github-Api-Version-Selected: + - '2022-11-28' + Access-Control-Expose-Headers: + - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, + X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, + X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, + X-GitHub-Request-Id, Deprecation, Sunset, Warning + Access-Control-Allow-Origin: + - "*" + Strict-Transport-Security: + - max-age=31536000; includeSubdomains; preload + X-Frame-Options: + - deny + X-Content-Type-Options: + - nosniff + X-Xss-Protection: + - '0' + Referrer-Policy: + - origin-when-cross-origin, strict-origin-when-cross-origin + Content-Security-Policy: + - default-src 'none' + Server: + - github.com + Accept-Ranges: + - bytes + X-Ratelimit-Limit: + - '10' + X-Ratelimit-Remaining: + - '9' + X-Ratelimit-Used: + - '1' + X-Ratelimit-Resource: + - search + X-Ratelimit-Reset: + - '1790713228' + Content-Length: + - '79' + X-Github-Request-Id: + - 8346:39061A:994F:A9BB:6ABC1D50 + X-Github-Edge-Region: + - westus3 + body: + encoding: ASCII-8BIT + string: '{"total_count":0,"incomplete_results":false,"items":[],"search_type":"lexical"}' + recorded_at: Tue, 29 Sep 2026 20:19:28 GMT +recorded_with: VCR 6.4.0 diff --git a/features/since.feature b/features/since.feature index 8379d07..658a579 100644 --- a/features/since.feature +++ b/features/since.feature @@ -27,3 +27,114 @@ Scenario: Check version Scenario: With no subcommand or argument When I run `whatsup_github` Then the output should contain "Commands:" + +Scenario: Rejects a config path outside the project directory + When I run `whatsup_github since 'jun 10' --config=../evil.yml` + Then the output should contain "ERROR: Invalid config path" + +Scenario: Markdown output is empty when there are no matching pull requests + Given a file named ".whatsup.yml" with: + """ + --- + base_branch: main + repos: + - octokit/octokit.rb + output_format: + - markdown + magic_word: whatsnew + """ + When I run `whatsup_github since 'jun 10'` + Then the output should contain "Done!" + And the file "tmp/whats-new-on-devdocs.md" should contain exactly: + """ + """ + +Scenario: YAML output is produced when there are no matching pull requests + Given a file named ".whatsup.yml" with: + """ + --- + base_branch: main + repos: + - octokit/octokit.rb + output_format: + - yaml + magic_word: whatsnew + """ + When I run `whatsup_github since 'jun 10'` + Then the output should contain "Done!" + And the file "tmp/whats-new.yml" should contain "entries:" + +Scenario: Custom template path traversal outside the project is rejected + Given a file named ".whatsup.yml" with: + """ + --- + base_branch: main + repos: + - octokit/octokit.rb + output_format: + - custom + templates: + custom: "../../../../etc/passwd" + custom_output: tmp/whats-new-custom.md + magic_word: whatsnew + """ + When I run `whatsup_github since 'jun 10'` + Then the output should contain "must point to a file inside the project directory" + +Scenario: Custom output renders through a project-supplied template + Given a file named "custom.mustache" with: + """ + {{#rows}} + {{{description}}} + {{/rows}} + """ + And a file named ".whatsup.yml" with: + """ + --- + base_branch: main + repos: + - octokit/octokit.rb + output_format: + - custom + templates: + custom: custom.mustache + custom_output: tmp/whats-new-custom.md + magic_word: whatsnew + """ + When I run `whatsup_github since 'jun 10'` + Then the output should contain "Done!" + And the file "tmp/whats-new-custom.md" should exist + +Scenario: Missing custom_output aborts before writing anything + Given a file named ".whatsup.yml" with: + """ + --- + base_branch: main + repos: + - octokit/octokit.rb + output_format: + - custom + templates: + custom: custom.mustache + magic_word: whatsnew + """ + When I run `whatsup_github since 'jun 10'` + Then the output should contain "'custom_output' is not set" + +Scenario: Enterprise repo with a private hostname is rejected before any network call + Given a file named ".whatsup.yml" with: + """ + --- + base_branch: main + repos: + - "enterprise:org/repo" + labels: + required: + - enhancement + output_format: + - yaml + magic_word: whatsnew + """ + And I set the environment variable "WHATSUP_GITHUB_ENTERPRISE_HOSTNAME" to "localhost" + When I run `whatsup_github since 'jun 10'` + Then the output should contain "Private/internal addresses are not allowed" diff --git a/features/since_vcr.feature b/features/since_vcr.feature new file mode 100644 index 0000000..48774c6 --- /dev/null +++ b/features/since_vcr.feature @@ -0,0 +1,31 @@ +@vcr +Feature: Fetching real pull requests from GitHub with recorded HTTP interactions + These scenarios replay HTTP interactions recorded by VCR into cassette + files under features/cassettes/, so they run without live network access. + + To (re-)record a cassette: + 1. Delete the corresponding cassette file under features/cassettes/. + 2. Export a valid WHATSUP_GITHUB_ACCESS_TOKEN in your shell (it must be + set before Cucumber boots, since the token is read into a frozen + constant the first time WhatsupGithub::Client loads). + 3. Temporarily change the cassette's record mode from :none to + :once in features/support/vcr.rb, run + `bundle exec cucumber --tags @vcr`, then change it back to :none. + + Scenario: Fetches merged pull requests for a real public repository + Given a file named ".whatsup.yml" with: + """ + --- + base_branch: main + repos: + - octokit/octokit.rb + labels: + required: + - documentation + output_format: + - yaml + magic_word: whatsnew + """ + When I run `whatsup_github since '2026-01-01'` + Then the output should contain "Done!" + And the file "tmp/whats-new.yml" should contain "entries:" diff --git a/features/support/cli_runner.rb b/features/support/cli_runner.rb new file mode 100644 index 0000000..c06b9be --- /dev/null +++ b/features/support/cli_runner.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'whatsup_github/cli' + +module WhatsupGithub + # Wraps WhatsupGithub::CLI.start for Aruba's in-process command launcher. + # + # Aruba's :in_process launcher instantiates this class as + # `main_class.new(argv, stdin, stdout, stderr, kernel).execute!`, so the + # CLI's use of global $stdout/$stderr/$stdin must be redirected to the + # io objects aruba hands us, and Kernel#exit/#abort (which raise + # SystemExit) must be converted into aruba's fake kernel exit instead of + # terminating the whole Cucumber process. + class CLIRunner + def initialize(argv, stdin, stdout, stderr, kernel) + @argv = argv + @stdin = stdin + @stdout = stdout + @stderr = stderr + @kernel = kernel + end + + def execute! + original_stdin = $stdin + original_stdout = $stdout + original_stderr = $stderr + $stdin = @stdin + $stdout = @stdout + $stderr = @stderr + + begin + WhatsupGithub::CLI.start(@argv) + rescue SystemExit => e + @kernel.exit(e.status) + end + ensure + $stdin = original_stdin + $stdout = original_stdout + $stderr = original_stderr + end + end +end diff --git a/features/support/hooks.rb b/features/support/hooks.rb new file mode 100644 index 0000000..73559c0 --- /dev/null +++ b/features/support/hooks.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative 'cli_runner' +require 'whatsup_github/config_reader' +require 'whatsup_github/client' +require 'whatsup_github/enterprise_client' + +# @vcr scenarios replay recorded HTTP interactions, so they must run through +# aruba's in-process launcher (subprocess requests can't be intercepted by +# WebMock/VCR running in the test process). +Before('@vcr') do |scenario| + cassette_name = scenario.name.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/\A_+|_+\z/, '') + + aruba.config.command_launcher = :in_process + aruba.config.main_class = WhatsupGithub::CLIRunner + + VCR.insert_cassette(cassette_name) +end + +After('@vcr') do + VCR.eject_cassette + aruba.config.command_launcher = :spawn + + # These are process-wide Singletons; reset them so the next scenario + # (in-process or spawned) gets a fresh instance instead of reusing state + # (e.g. a stale config file path) left over from this one. + [WhatsupGithub::Config, WhatsupGithub::Client, WhatsupGithub::EnterpriseClient].each do |klass| + klass.instance_variable_set(:@singleton__instance__, nil) + end +end diff --git a/features/support/vcr.rb b/features/support/vcr.rb new file mode 100644 index 0000000..5ccc570 --- /dev/null +++ b/features/support/vcr.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'vcr' + +VCR.configure do |config| + config.cassette_library_dir = 'features/cassettes' + config.hook_into :webmock + config.default_cassette_options = { record: :none } + config.filter_sensitive_data('') { ENV.fetch('WHATSUP_GITHUB_ACCESS_TOKEN', nil) } +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index fe45cc4..84c923c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,7 +1,14 @@ # frozen_string_literal: true +require 'simplecov' + require 'bundler/setup' +require 'webmock/rspec' require 'whatsup_github' +require_relative 'support/singleton_helper' +require_relative 'support/netrc_helper' + +WebMock.disable_net_connect!(allow_localhost: true) RSpec.configure do |config| # Enable flags like --only-failures and --next-failure @@ -13,4 +20,13 @@ config.expect_with :rspec do |c| c.syntax = :expect end + + config.include SingletonHelper + config.include NetrcHelper + + config.before do + reset_singleton!(WhatsupGithub::Config) + reset_singleton!(WhatsupGithub::Client) + reset_singleton!(WhatsupGithub::EnterpriseClient) + end end diff --git a/spec/support/netrc_helper.rb b/spec/support/netrc_helper.rb new file mode 100644 index 0000000..222d78b --- /dev/null +++ b/spec/support/netrc_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Stubs ~/.netrc presence and permissions, shared by Client and EnterpriseClient specs. +module NetrcHelper + def stub_netrc(exists:) + path = File.expand_path('~/.netrc') + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with(path).and_return(exists) + end + + def stub_netrc_permissions(mode) + stub_netrc(exists: true) + allow(File).to receive(:stat).and_call_original + allow(File).to receive(:stat).with(File.expand_path('~/.netrc')).and_return(double(mode: mode)) + end +end diff --git a/spec/support/singleton_helper.rb b/spec/support/singleton_helper.rb new file mode 100644 index 0000000..52adff9 --- /dev/null +++ b/spec/support/singleton_helper.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Clears cached Singleton instances so each example starts from a clean state. +module SingletonHelper + def reset_singleton!(klass) + klass.instance_variable_set(:@singleton__instance__, nil) + end +end diff --git a/spec/whatsup_github/cli_spec.rb b/spec/whatsup_github/cli_spec.rb new file mode 100644 index 0000000..977174b --- /dev/null +++ b/spec/whatsup_github/cli_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'whatsup_github/cli' + +RSpec.describe WhatsupGithub::CLI do + describe '#since' do + let(:runner) { instance_double(WhatsupGithub::Runner, run: nil) } + + before do + allow(WhatsupGithub::Runner).to receive(:new).and_return(runner) + allow(WhatsupGithub::Config).to receive(:filename=) + end + + it 'sets Config.filename from the --config option and runs the runner' do + described_class.start(['since', 'jun 10', '--config', 'custom.yml']) + + expect(WhatsupGithub::Config).to have_received(:filename=).with('custom.yml') + expect(WhatsupGithub::Runner).to have_received(:new).with(Date.parse('jun 10')) + expect(runner).to have_received(:run) + end + + it 'defaults --config to .whatsup.yml' do + described_class.start(['since', 'jun 10']) + + expect(WhatsupGithub::Config).to have_received(:filename=).with('.whatsup.yml') + end + + it 'defaults to 7 days ago when no date is given' do + allow(Date).to receive(:today).and_return(Date.new(2026, 9, 29)) + + described_class.start(['since']) + + expect(WhatsupGithub::Runner).to have_received(:new).with(Date.new(2026, 9, 22)) + end + end + + describe '#version' do + it 'prints the current gem version' do + expect { described_class.start(['version']) }.to output(/Current version is #{WhatsupGithub::VERSION}/).to_stdout + end + end +end diff --git a/spec/whatsup_github/client_spec.rb b/spec/whatsup_github/client_spec.rb new file mode 100644 index 0000000..ebfd7e9 --- /dev/null +++ b/spec/whatsup_github/client_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'octokit' +require 'tmpdir' +require 'whatsup_github/client' + +RSpec.describe WhatsupGithub::Client do + describe '#initialize' do + it 'authenticates with an access token when present' do + stub_const('WhatsupGithub::Client::WHATSUP_GITHUB_ACCESS_TOKEN', 'tok123') + expect(Octokit::Client).to receive(:new).with(access_token: 'tok123') + + described_class.instance + end + + it 'authenticates via netrc when no token is present and netrc exists' do + stub_const('WhatsupGithub::Client::WHATSUP_GITHUB_ACCESS_TOKEN', nil) + stub_netrc_permissions(0o100_600) + expect(Octokit::Client).to receive(:new).with(netrc: true) + + described_class.instance + end + + it 'warns about insecure netrc permissions' do + stub_const('WhatsupGithub::Client::WHATSUP_GITHUB_ACCESS_TOKEN', nil) + stub_netrc_permissions(0o100_644) + allow(Octokit::Client).to receive(:new) + + expect { described_class.instance }.to output(/insecure permissions/).to_stderr + end + + it 'falls back to an unauthenticated guest client with a warning' do + stub_const('WhatsupGithub::Client::WHATSUP_GITHUB_ACCESS_TOKEN', nil) + stub_netrc(exists: false) + expect(Octokit::Client).to receive(:new).with(no_args) + + expect { described_class.instance }.to output(/rate limit: 60/).to_stderr + end + end + + describe 'delegation' do + let(:octokit) { instance_double(Octokit::Client) } + + before do + stub_const('WhatsupGithub::Client::WHATSUP_GITHUB_ACCESS_TOKEN', 'tok123') + allow(Octokit::Client).to receive(:new).and_return(octokit) + end + + it '#search_issues delegates to the underlying client' do + allow(octokit).to receive(:search_issues).with('query').and_return('result') + + expect(described_class.instance.search_issues('query')).to eq('result') + end + + it '#pull_request delegates to the underlying client' do + allow(octokit).to receive(:pull_request).with('org/repo', 42).and_return('pr') + + expect(described_class.instance.pull_request('org/repo', 42)).to eq('pr') + end + + it '#org_members delegates to the underlying client' do + allow(octokit).to receive(:org_members).with('org').and_return(['member']) + + expect(described_class.instance.org_members('org')).to eq(['member']) + end + + it '#pull_requests_by_node_ids posts the GraphQL query and returns the nodes' do + response = double('response', data: double('data', nodes: %w[a b])) + expect(octokit).to receive(:post).with('/graphql', anything).and_return(response) + + expect(described_class.instance.pull_requests_by_node_ids(['id1'])).to eq(%w[a b]) + end + end +end diff --git a/spec/whatsup_github/config_reader_spec.rb b/spec/whatsup_github/config_reader_spec.rb new file mode 100644 index 0000000..1be6e86 --- /dev/null +++ b/spec/whatsup_github/config_reader_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'tmpdir' +require 'whatsup_github/config_reader' + +RSpec.describe WhatsupGithub::Config do + around do |example| + Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } + end + + describe '.filename=' do + it 'accepts a relative path' do + expect { described_class.filename = 'custom.yml' }.not_to raise_error + end + + it 'rejects paths containing ..' do + expect { described_class.filename = '../evil.yml' }.to raise_error(SystemExit) + end + + it 'rejects absolute paths' do + expect { described_class.filename = '/etc/passwd' }.to raise_error(SystemExit) + end + end + + describe '#read' do + it 'parses an existing config file' do + File.write('.whatsup.yml', "---\nbase_branch: main\n") + described_class.filename = '.whatsup.yml' + + expect(described_class.instance.read['base_branch']).to eq('main') + end + + it 'copies the scaffold config when the file is missing' do + described_class.filename = '.whatsup.yml' + described_class.instance.read + + expect(File.exist?('.whatsup.yml')).to be true + end + end + + describe 'accessors' do + before do + File.write('.whatsup.yml', <<~YAML) + --- + base_branch: main + repos: + - octokit/octokit.rb + labels: + required: + - enhancement + optional: + - technical + output_format: + - yaml + magic_word: whatsnew + membership: AdobeDocs + templates: + custom: custom.mustache + custom_output: tmp/out.md + YAML + described_class.filename = '.whatsup.yml' + end + + it 'exposes repos' do + expect(described_class.instance.repos).to eq(['octokit/octokit.rb']) + end + + it 'exposes base_branch' do + expect(described_class.instance.base_branch).to eq('main') + end + + it 'exposes output_format' do + expect(described_class.instance.output_format).to eq(['yaml']) + end + + it 'merges required and optional labels' do + expect(described_class.instance.labels).to eq(%w[enhancement technical]) + end + + it 'exposes required_labels and optional_labels separately' do + expect(described_class.instance.required_labels).to eq(['enhancement']) + expect(described_class.instance.optional_labels).to eq(['technical']) + end + + it 'exposes membership' do + expect(described_class.instance.membership).to eq('AdobeDocs') + end + + it 'exposes magic_word' do + expect(described_class.instance.magic_word).to eq('whatsnew') + end + + it 'exposes the custom template_path' do + expect(described_class.instance.template_path).to eq('custom.mustache') + end + + it 'exposes custom_output' do + expect(described_class.instance.custom_output).to eq('tmp/out.md') + end + end + + describe 'missing optional sections' do + before do + File.write('.whatsup.yml', "---\nbase_branch: main\n") + described_class.filename = '.whatsup.yml' + end + + it 'defaults required_labels and optional_labels to an empty array' do + expect(described_class.instance.required_labels).to eq([]) + expect(described_class.instance.optional_labels).to eq([]) + end + + it 'defaults template_path and custom_output to nil' do + expect(described_class.instance.template_path).to be_nil + expect(described_class.instance.custom_output).to be_nil + end + end +end diff --git a/spec/whatsup_github/custom_formatter_spec.rb b/spec/whatsup_github/custom_formatter_spec.rb new file mode 100644 index 0000000..b670d18 --- /dev/null +++ b/spec/whatsup_github/custom_formatter_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' +require 'whatsup_github/config_reader' +require 'whatsup_github/custom_formatter' + +RSpec.describe WhatsupGithub::CustomFormatter do + def build_row(overrides = {}) + double('row', to_h: { 'description' => 'Added a thing' }.merge(overrides)) + end + + around do |example| + Dir.mktmpdir do |dir| + Dir.chdir(dir) { example.run } + end + end + + def stub_template_path(path) + config = instance_double(WhatsupGithub::Config, template_path: path) + allow(WhatsupGithub::Config).to receive(:instance).and_return(config) + end + + it 'renders rows through the configured project-relative template' do + File.write('custom.mustache', "{{#rows}}{{{description}}}\n{{/rows}}") + stub_template_path('custom.mustache') + + expect(described_class.new.generate_output_from([build_row])).to eq("Added a thing\n") + end + + it 'renders rows through a template in a nested subdirectory' do + FileUtils.mkdir_p('templates') + File.write('templates/custom.mustache', "{{#rows}}{{{description}}}\n{{/rows}}") + stub_template_path('templates/custom.mustache') + + expect(described_class.new.generate_output_from([build_row])).to eq("Added a thing\n") + end + + it 'renders an internal symlink using the validated canonical path' do + File.write('custom.mustache', '{{#rows}}{{{description}}}{{/rows}}') + File.symlink('custom.mustache', 'linked.mustache') + stub_template_path('linked.mustache') + expect(File).to receive(:read).with(File.realpath('custom.mustache')).and_call_original + + expect(described_class.new.generate_output_from([build_row])).to eq('Added a thing') + end + + it 'renders a template when the working root is a symlink' do + File.write('custom.mustache', '{{#rows}}{{{description}}}{{/rows}}') + File.symlink(Dir.pwd, 'project-link') + linked_root = File.expand_path('project-link') + allow(Dir).to receive(:pwd).and_return(linked_root) + stub_template_path('custom.mustache') + + expect(described_class.new.generate_output_from([build_row])).to eq('Added a thing') + end + + it 'aborts on a sibling directory sharing the project path prefix' do + Dir.mktmpdir("#{File.basename(Dir.pwd)}-sibling", File.dirname(Dir.pwd)) do |sibling| + template = File.join(sibling, 'custom.mustache') + File.write(template, 'Outside template') + stub_template_path(template) + + expect { described_class.new.generate_output_from([]) } + .to raise_error(SystemExit) + .and output(/must point to a file inside the project directory/).to_stderr + end + end + + it 'aborts on an in-project symlink to an outside template' do + Dir.mktmpdir do |outside| + template = File.join(outside, 'custom.mustache') + File.write(template, 'Outside template') + File.symlink(template, 'linked.mustache') + stub_template_path('linked.mustache') + + expect { described_class.new.generate_output_from([]) } + .to raise_error(SystemExit) + .and output(/must point to a file inside the project directory/).to_stderr + end + end + + it 'aborts when templates.custom is not configured' do + stub_template_path(nil) + + expect { described_class.new.generate_output_from([]) } + .to raise_error(SystemExit) + .and output(/'templates.custom' is not set/).to_stderr + end + + it 'aborts on path traversal outside the project directory' do + stub_template_path('../../../../etc/passwd') + + expect { described_class.new.generate_output_from([]) } + .to raise_error(SystemExit) + .and output(/must point to a file inside the project directory/).to_stderr + end + + it 'aborts on an absolute path outside the project directory' do + stub_template_path('/etc/passwd') + + expect { described_class.new.generate_output_from([]) } + .to raise_error(SystemExit) + .and output(/must point to a file inside the project directory/).to_stderr + end + + it 'aborts when the configured template file does not exist' do + stub_template_path('missing.mustache') + + expect { described_class.new.generate_output_from([]) } + .to raise_error(SystemExit) + .and output(/Template file not found/).to_stderr + end + + it 'aborts when the configured template is a dangling symlink' do + File.symlink('missing.mustache', 'linked.mustache') + stub_template_path('linked.mustache') + + expect { described_class.new.generate_output_from([]) } + .to raise_error(SystemExit) + .and output(/Template file not found/).to_stderr + end + + it 'aborts when the working root cannot be canonicalized' do + allow(Dir).to receive(:pwd).and_return(File.expand_path('missing-project')) + stub_template_path('custom.mustache') + + expect { described_class.new.generate_output_from([]) } + .to raise_error(SystemExit) + .and output(/Template file not found/).to_stderr + end +end diff --git a/spec/whatsup_github/enterprise_client_spec.rb b/spec/whatsup_github/enterprise_client_spec.rb new file mode 100644 index 0000000..407a403 --- /dev/null +++ b/spec/whatsup_github/enterprise_client_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'octokit' +require 'whatsup_github/enterprise_client' + +RSpec.describe WhatsupGithub::EnterpriseClient do + before do + stub_const('WhatsupGithub::EnterpriseClient::WHATSUP_ENTERPRISE_ACCESS_TOKEN', nil) + stub_const('WhatsupGithub::EnterpriseClient::WHATSUP_GITHUB_ENTERPRISE_HOSTNAME', nil) + end + + describe '.host=' do + it 'defaults to github.com when blank' do + expect { described_class.host = '' }.not_to raise_error + end + + it 'accepts a valid custom hostname' do + expect { described_class.host = 'git.example.com' }.not_to raise_error + end + + it 'rejects an invalid hostname format' do + expect { described_class.host = 'not a host!' }.to raise_error(SystemExit) + end + + it 'rejects private/internal hostnames' do + %w[localhost 127.0.0.1 10.0.0.5 192.168.1.1 172.16.0.1 172.31.255.255 169.254.1.1].each do |host| + expect { described_class.host = host }.to raise_error(SystemExit) + end + end + + it 'allows addresses just outside the private ranges' do + expect { described_class.host = '172.15.0.1' }.not_to raise_error + expect { described_class.host = '172.32.0.1' }.not_to raise_error + end + end + + describe '#initialize' do + it 'authenticates with the enterprise access token when present' do + stub_const('WhatsupGithub::EnterpriseClient::WHATSUP_ENTERPRISE_ACCESS_TOKEN', 'tok123') + expect(Octokit::Client).to receive(:new).with(access_token: 'tok123') + + described_class.instance + end + + it 'authenticates via netrc when no token is present and netrc exists' do + stub_netrc_permissions(0o100_600) + expect(Octokit::Client).to receive(:new).with(netrc: true) + + described_class.instance + end + + it 'aborts when no credentials are configured' do + stub_netrc(exists: false) + + expect { described_class.instance }.to raise_error(SystemExit) + end + + it 'configures a custom API endpoint for a non-default hostname' do + stub_const('WhatsupGithub::EnterpriseClient::WHATSUP_GITHUB_ENTERPRISE_HOSTNAME', 'git.example.com') + stub_const('WhatsupGithub::EnterpriseClient::WHATSUP_ENTERPRISE_ACCESS_TOKEN', 'tok123') + allow(Octokit::Client).to receive(:new) + expect(Octokit).to receive(:configure).and_yield(Octokit) + expect(Octokit).to receive(:api_endpoint=).with('https://git.example.com/api/v3/') + + described_class.instance + end + end + + describe '#search_issues' do + it 'strips the enterprise: prefix before querying' do + stub_const('WhatsupGithub::EnterpriseClient::WHATSUP_ENTERPRISE_ACCESS_TOKEN', 'tok123') + octokit = instance_double(Octokit::Client) + allow(Octokit::Client).to receive(:new).and_return(octokit) + allow(octokit).to receive(:search_issues).with('repo:org/repo label:"x"').and_return('result') + + result = described_class.instance.search_issues('repo:enterprise:org/repo label:"x"') + + expect(result).to eq('result') + end + end + + describe '#pull_requests_by_node_ids' do + it 'posts to /graphql for the default github.com hostname' do + stub_const('WhatsupGithub::EnterpriseClient::WHATSUP_ENTERPRISE_ACCESS_TOKEN', 'tok123') + octokit = instance_double(Octokit::Client) + allow(Octokit::Client).to receive(:new).and_return(octokit) + response = double('response', data: double('data', nodes: %w[a])) + expect(octokit).to receive(:post).with('/graphql', anything).and_return(response) + + expect(described_class.instance.pull_requests_by_node_ids(['id1'])).to eq(%w[a]) + end + + it 'posts to the enterprise GraphQL endpoint for a custom hostname' do + stub_const('WhatsupGithub::EnterpriseClient::WHATSUP_GITHUB_ENTERPRISE_HOSTNAME', 'git.example.com') + stub_const('WhatsupGithub::EnterpriseClient::WHATSUP_ENTERPRISE_ACCESS_TOKEN', 'tok123') + allow(Octokit).to receive(:configure) + octokit = instance_double(Octokit::Client) + allow(Octokit::Client).to receive(:new).and_return(octokit) + response = double('response', data: double('data', nodes: %w[a])) + expect(octokit).to receive(:post).with('https://git.example.com/api/graphql', anything).and_return(response) + + expect(described_class.instance.pull_requests_by_node_ids(['id1'])).to eq(%w[a]) + end + end +end diff --git a/spec/whatsup_github/generator_spec.rb b/spec/whatsup_github/generator_spec.rb new file mode 100644 index 0000000..8a6f3d7 --- /dev/null +++ b/spec/whatsup_github/generator_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'whatsup_github/generator' +require 'whatsup_github/row_collector' + +RSpec.describe WhatsupGithub::Generator do + let(:since) { Date.new(2026, 9, 1) } + let(:collector) { instance_double(WhatsupGithub::RowCollector, sort_by_date: %w[row1 row2]) } + + before { allow(WhatsupGithub::RowCollector).to receive(:new).with(since:).and_return(collector) } + + describe '#content' do + it 'delegates to RowCollector#sort_by_date' do + expect(described_class.new(since).content).to eq(%w[row1 row2]) + end + end + + describe '#run' do + it 'delegates to the formatter' do + formatter = double('formatter', generate_output_from: 'rendered') + expect(described_class.new(since).run(formatter, %w[row1 row2])).to eq('rendered') + expect(formatter).to have_received(:generate_output_from).with(%w[row1 row2]) + end + end +end diff --git a/spec/whatsup_github/pulls_spec.rb b/spec/whatsup_github/pulls_spec.rb new file mode 100644 index 0000000..b2e9b45 --- /dev/null +++ b/spec/whatsup_github/pulls_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'octokit' +require 'faraday' +require 'whatsup_github/pulls' + +RSpec.describe WhatsupGithub::Pulls do + let(:since) { Date.new(2026, 9, 1) } + let(:config) do + instance_double(WhatsupGithub::Config, required_labels: ['enhancement'], optional_labels: [], + magic_word: 'whatsnew', base_branch: 'main') + end + + before { allow(WhatsupGithub::Config).to receive(:instance).and_return(config) } + + def build_pulls(repo: 'octokit/octokit.rb') + described_class.new(repo: repo, since: since) + end + + def stub_search(client, node_ids: ['node1'], expected_query: nil) + issues = node_ids.map { |id| double('issue', node_id: id) } + result = double('result', items: issues) + if expected_query + allow(client).to receive(:search_issues).with(expected_query).and_return(result) + else + allow(client).to receive(:search_issues).and_return(result) + end + end + + shared_examples 'a Pulls client failure' do |raising_method, error, message| + it "aborts with #{message.inspect}" do + client = instance_double(WhatsupGithub::Client) + allow(WhatsupGithub::Client).to receive(:instance).and_return(client) + stub_search(client) if raising_method == :pull_requests_by_node_ids + allow(client).to receive(raising_method).and_raise(error) + + expect { build_pulls.data }.to raise_error(SystemExit).and output(message).to_stderr + end + end + + describe '#data' do + it 'returns [] without contacting the client when there are no configured labels' do + allow(config).to receive_messages(required_labels: [], optional_labels: []) + client = instance_double(WhatsupGithub::Client) + allow(client).to receive(:search_issues) + allow(WhatsupGithub::Client).to receive(:instance).and_return(client) + + expect(build_pulls.data).to eq([]) + expect(client).not_to have_received(:search_issues) + end + + it 'fetches PRs for a standard repo, building the expected search query' do + client = instance_double(WhatsupGithub::Client) + allow(WhatsupGithub::Client).to receive(:instance).and_return(client) + expected_query = 'repo:octokit/octokit.rb label:"enhancement" merged:>=2026-09-01 base:main is:pull-request' + stub_search(client, expected_query: expected_query) + allow(client).to receive(:pull_requests_by_node_ids).with(['node1']).and_return(['pr']) + + expect(build_pulls.data).to eq(['pr']) + end + + it 'searches optional labels with the magic word appended to the query' do + allow(config).to receive_messages(required_labels: [], optional_labels: ['technical']) + client = instance_double(WhatsupGithub::Client) + allow(WhatsupGithub::Client).to receive(:instance).and_return(client) + expected_query = 'repo:octokit/octokit.rb label:"technical" merged:>=2026-09-01 base:main ' \ + 'is:pull-request "whatsnew" in:body' + stub_search(client, expected_query: expected_query) + allow(client).to receive(:pull_requests_by_node_ids).with(['node1']).and_return(['pr']) + + expect(build_pulls.data).to eq(['pr']) + end + + it 'routes enterprise-prefixed repos through the enterprise client' do + enterprise_client = instance_double(WhatsupGithub::EnterpriseClient) + allow(WhatsupGithub::EnterpriseClient).to receive(:instance).and_return(enterprise_client) + stub_search(enterprise_client) + allow(enterprise_client).to receive(:pull_requests_by_node_ids).with(['node1']).and_return(['pr']) + + expect(build_pulls(repo: 'enterprise:org/repo').data).to eq(['pr']) + end + + context 'when the search request fails' do + it_behaves_like 'a Pulls client failure', :search_issues, Octokit::Unauthorized, /Authentication failed/ + it_behaves_like 'a Pulls client failure', :search_issues, Octokit::Forbidden, /Access forbidden/ + it_behaves_like 'a Pulls client failure', :search_issues, Octokit::NotFound, /Repository not found/ + it_behaves_like 'a Pulls client failure', :search_issues, Octokit::Error, /GitHub API error/ + it_behaves_like 'a Pulls client failure', :search_issues, Faraday::Error.new('boom'), /Network error/ + end + + context 'when fetching PR nodes by id fails' do + it_behaves_like 'a Pulls client failure', :pull_requests_by_node_ids, Octokit::Unauthorized, /Authentication failed/ + it_behaves_like 'a Pulls client failure', :pull_requests_by_node_ids, Octokit::Forbidden, /Access forbidden/ + it_behaves_like 'a Pulls client failure', :pull_requests_by_node_ids, Octokit::NotFound, /GitHub API error/ + it_behaves_like 'a Pulls client failure', :pull_requests_by_node_ids, Faraday::Error.new('boom'), /Network error/ + end + end +end diff --git a/spec/whatsup_github/row_collector_spec.rb b/spec/whatsup_github/row_collector_spec.rb new file mode 100644 index 0000000..ccd0a6f --- /dev/null +++ b/spec/whatsup_github/row_collector_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'time' +require 'whatsup_github/row_collector' + +RSpec.describe WhatsupGithub::RowCollector do + let(:since) { Date.new(2026, 9, 1) } + let(:config) { instance_double(WhatsupGithub::Config, repos: ['octokit/octokit.rb']) } + + before { allow(WhatsupGithub::Config).to receive(:instance).and_return(config) } + + def build_pull(overrides = {}) + defaults = { + number: 42, + title: 'Add a feature', + body: 'whatsnew stuff', + merged_at: Time.parse('2026-09-01T12:00:00Z'), + labels: double('labels', nodes: [double('label', name: 'technical')]), + assignees: double('assignees', nodes: []), + merge_commit: double('merge_commit', oid: 'abc123'), + author: double('author', login: 'octocat', url: 'https://github.com/octocat'), + url: 'https://github.com/org/repo/pull/42' + } + double('pull', defaults.merge(overrides)) + end + + def stub_pulls(repo: 'octokit/octokit.rb', data:) + pulls = instance_double(WhatsupGithub::Pulls, data: data) + allow(WhatsupGithub::Pulls).to receive(:new).with(repo: repo, since: since).and_return(pulls) + end + + describe '#collect_rows' do + it 'maps GraphQL pull data into Row objects' do + stub_pulls(data: [build_pull]) + + row = described_class.new(since: since).collect_rows.first + + expect(row.pr_number).to eq(42) + expect(row.title).to eq('Add a feature') + expect(row.labels).to eq(['technical']) + expect(row.assignee).to eq('NOBODY') + expect(row.merge_commit).to eq('abc123') + expect(row.author).to eq('octocat') + expect(row.author_url).to eq('https://github.com/octocat') + expect(row.link).to eq('https://github.com/org/repo/pull/42') + end + + it 'joins multiple assignee logins' do + pull = build_pull(assignees: double('assignees', nodes: [ + double('a', login: 'alice'), double('a', login: 'bob') + ])) + stub_pulls(data: [pull]) + + expect(described_class.new(since: since).collect_rows.first.assignee).to eq('alice, bob') + end + + it 'rewrites the link for enterprise-prefixed repos' do + allow(config).to receive(:repos).and_return(['enterprise:org/repo']) + stub_pulls(repo: 'enterprise:org/repo', data: [build_pull]) + + expect(described_class.new(since: since).collect_rows.first.link).to eq('enterprise:org/repo/pull/42') + end + + it 'handles a nil merge_commit and author gracefully' do + stub_pulls(data: [build_pull(merge_commit: nil, author: nil)]) + + row = described_class.new(since: since).collect_rows.first + + expect(row.merge_commit).to be_nil + expect(row.author).to be_nil + expect(row.author_url).to be_nil + end + end + + describe '#sort_by_date' do + it 'sorts rows by date, most recent first' do + older = build_pull(number: 1, merged_at: Time.parse('2026-08-01T00:00:00Z')) + newer = build_pull(number: 2, merged_at: Time.parse('2026-09-01T00:00:00Z')) + stub_pulls(data: [older, newer]) + + sorted = described_class.new(since: since).sort_by_date + + expect(sorted.map(&:pr_number)).to eq([2, 1]) + end + end +end diff --git a/spec/whatsup_github/row_spec.rb b/spec/whatsup_github/row_spec.rb new file mode 100644 index 0000000..632bf6e --- /dev/null +++ b/spec/whatsup_github/row_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'time' +require 'whatsup_github/config_reader' +require 'whatsup_github/row' + +RSpec.describe WhatsupGithub::Row do + let(:config) do + instance_double(WhatsupGithub::Config, labels: %w[technical release-notes 2.4], magic_word: 'whatsnew') + end + + before { allow(WhatsupGithub::Config).to receive(:instance).and_return(config) } + + def build_row(overrides = {}) + described_class.new({ + pr_number: 42, + pr_title: 'Add a feature', + pr_body: "Some intro\nwhatsnew\n* Added a thing\r\n* Fixed another\n", + date: Time.parse('2026-09-01T12:00:00Z'), + pr_labels: %w[technical 2.4], + assignee: 'octocat', + author: 'octocat', + author_url: 'https://github.com/octocat', + pr_url: 'https://github.com/org/repo/pull/42', + merge_commit_sha: 'abc123' + }.merge(overrides)) + end + + describe '#date' do + it 'returns the ISO date' do + expect(build_row.date).to eq('2026-09-01') + end + end + + describe '#date_string' do + it 'returns a human-readable date' do + expect(build_row.date_string).to eq('September 1, 2026') + end + end + + describe '#type' do + it 'intersects row labels with configured labels' do + expect(build_row.type).to eq('technical, 2.4') + end + + it 'is empty when no labels match' do + expect(build_row(pr_labels: %w[unrelated]).type).to eq('') + end + end + + describe '#required_labels' do + it 'returns the configured required labels' do + allow(config).to receive(:required_labels).and_return(['best-practices']) + + expect(build_row.required_labels).to eq(['best-practices']) + end + end + + describe '#versions' do + it 'selects labels that look like version numbers' do + expect(build_row(pr_labels: %w[technical 2.4 3.1]).versions).to eq('2.4, 3.1') + end + + it 'selects version labels with multiple-digit prefixes' do + expect(build_row(pr_labels: %w[2.4 12.3 123.4]).versions).to eq('2.4, 12.3, 123.4') + end + + it 'is empty when no version-like labels are present' do + expect(build_row(pr_labels: ['technical', 'v2.4', 'release-12.3', '12', '12x3', "notes\n2.4"]).versions).to eq('') + end + end + + describe '#magic_word' do + it 'returns the configured magic word' do + expect(build_row.magic_word).to eq('whatsnew') + end + + it 'aborts when the magic word is not configured' do + allow(config).to receive(:magic_word).and_return(nil) + expect { build_row.magic_word }.to raise_error(SystemExit) + end + end + + describe '#description' do + it 'parses the body after the magic word into
-joined lines' do + expect(build_row.description).to eq('Added a thing
Fixed another') + end + + it 'warns and returns a message when the magic word is missing from the body' do + row = build_row(pr_body: 'No magic word here') + message = nil + expect { message = row.description }.to output(/MISSING whatsnew/).to_stdout + expect(message).to include('#42').and include('octocat').and include('pull/42') + end + end + + describe '#to_h' do + it 'returns the full raw field set' do + expect(build_row.to_h).to eq( + 'description' => 'Added a thing
Fixed another', + 'versions' => '2.4', + 'type' => 'technical, 2.4', + 'date' => '2026-09-01', + 'link' => 'https://github.com/org/repo/pull/42', + 'merge_commit' => 'abc123', + 'contributor' => 'octocat', + 'labels' => %w[technical 2.4] + ) + end + end +end diff --git a/spec/whatsup_github/runner_spec.rb b/spec/whatsup_github/runner_spec.rb new file mode 100644 index 0000000..97a4c17 --- /dev/null +++ b/spec/whatsup_github/runner_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'tmpdir' +require 'whatsup_github/runner' + +RSpec.describe WhatsupGithub::Runner do + let(:date) { Date.new(2026, 9, 1) } + let(:config) { instance_double(WhatsupGithub::Config, output_format: ['markdown'], custom_output: nil) } + let(:generator) { instance_double(WhatsupGithub::Generator, content: ['row1'], run: 'rendered') } + + before do + allow(WhatsupGithub::Config).to receive(:instance).and_return(config) + allow(WhatsupGithub::Generator).to receive(:new).with(date).and_return(generator) + end + + around do |example| + Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } + end + + describe '#run' do + it 'raises when output_format is missing' do + allow(config).to receive(:output_format).and_return(nil) + + expect { described_class.new(date).run }.to raise_error(/Cannot find "output_format"/) + end + + it 'writes markdown output when configured' do + described_class.new(date).run + + expect(File.exist?('tmp/whats-new-on-devdocs.md')).to be true + end + + it 'writes yaml output when configured' do + allow(config).to receive(:output_format).and_return(['yaml']) + + described_class.new(date).run + + expect(File.exist?('tmp/whats-new.yml')).to be true + end + + it 'writes custom output when configured and custom_output is set' do + allow(config).to receive_messages(output_format: ['custom'], custom_output: 'tmp/custom.md') + + described_class.new(date).run + + expect(File.exist?('tmp/custom.md')).to be true + end + + it 'aborts when output_format includes custom but custom_output is not set' do + allow(config).to receive(:output_format).and_return(['custom']) + + expect { described_class.new(date).run }.to raise_error(SystemExit) + end + end + + describe '#write_results' do + it 'creates missing directories, writes the file, and prints a completion message' do + formatter = double('formatter', generate_output_from: 'content') + runner = described_class.new(date) + + expect { runner.write_results('nested/dir/out.md', formatter) }.to output(/Done!/).to_stdout + expect(File.read('nested/dir/out.md')).to eq('rendered') + end + end +end diff --git a/spec/whatsup_github/table_spec.rb b/spec/whatsup_github/table_spec.rb new file mode 100644 index 0000000..d4d799a --- /dev/null +++ b/spec/whatsup_github/table_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'whatsup_github/table' + +RSpec.describe WhatsupGithub::Table do + def build_row(description:, versions:, type:, date:) + double('row', description:, versions:, type:, date:) + end + + it 'renders four-column Markdown rows without a header' do + rows = [build_row(description: 'Sample entry', versions: '2.4', type: 'technical', date: '2026-09-01')] + + expect(described_class.new.generate_output_from(rows)).to eq(<<~MARKDOWN) + | Sample entry | 2.4 | technical | 2026-09-01 | + MARKDOWN + end + + it 'matches the 2.0.0 serializer for multiple rows and empty fields' do + rows = [ + build_row(description: '[Sample](https://example.com) &
details', versions: '2.4, 2.5', type: 'technical', date: '2026-09-01'), + build_row(description: 'Another entry', versions: '', type: '', date: '2026-09-02') + ] + legacy_output = rows.collect do |row| + "| #{row.description} | #{row.versions} | #{row.type} | #{row.date} |\n".tr_s(' ', ' ') + end.join + + expect(described_class.new.generate_output_from(rows)).to eq(legacy_output) + end + + it 'renders an empty string when there are no rows' do + expect(described_class.new.generate_output_from([])).to eq('') + end +end diff --git a/spec/whatsup_github/yaml_formatter_spec.rb b/spec/whatsup_github/yaml_formatter_spec.rb new file mode 100644 index 0000000..c1730ea --- /dev/null +++ b/spec/whatsup_github/yaml_formatter_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'yaml' +require 'whatsup_github/yaml_formatter' + +RSpec.describe WhatsupGithub::YAMLFormatter do + def build_row(overrides = {}) + double('row', to_h: { + 'description' => 'Added a thing', + 'versions' => '2.4', + 'type' => 'technical', + 'date' => '2026-09-01', + 'link' => 'https://github.com/org/repo/pull/42', + 'merge_commit' => 'abc123', + 'contributor' => 'octocat', + 'labels' => %w[technical 2.4] + }.merge(overrides)) + end + + it 'renders YAML that round-trips through YAML.safe_load' do + output = described_class.new.generate_output_from([build_row]) + parsed = YAML.safe_load(output) + + expect(parsed['entries']).to eq( + [ + { + 'description' => 'Added a thing', + 'versions' => '2.4', + 'type' => 'technical', + 'date' => '2026-09-01', + 'link' => 'https://github.com/org/repo/pull/42', + 'merge_commit' => 'abc123', + 'contributor' => 'octocat', + 'labels' => %w[technical 2.4] + } + ] + ) + end + + it 'serializes the output object directly to YAML' do + now = Time.now + allow(Time).to receive(:now).and_return(now) + row = build_row + output = described_class.new.generate_output_from([row]) + + expect(output).to eq( + { + 'updated' => now.strftime('%c').tr_s(' ', ' '), + 'entries' => [row.to_h] + }.to_yaml + ) + end + + it 'renders an empty entries list without error' do + output = described_class.new.generate_output_from([]) + expect(YAML.safe_load(output)['entries']).to eq([]) + end +end From 0db27dd46d76fcb41d68c8794828bc24189ca52a Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Wed, 30 Sep 2026 16:57:26 -0500 Subject: [PATCH 3/4] ci(lint): fix linter configuration and formatter conflicts --- .github/workflows/linter.yml | 15 ++++++++++----- .markdownlint.json | 12 ++++++------ .rubocop.yml | 1 + 3 files changed, 17 insertions(+), 11 deletions(-) create mode 120000 .rubocop.yml diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 6615ba3..ef22ec9 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -17,9 +17,7 @@ name: Lint Code Base on: pull_request -permissions: - contents: read - pull-requests: read +permissions: {} ############### # Set the Job # @@ -29,6 +27,11 @@ jobs: # Set the agent to run on runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + statuses: write + ################## # Load all steps # ################## @@ -37,21 +40,23 @@ jobs: # Checkout the code base # ########################## - name: Checkout Code - uses: actions/checkout@v5 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: # Full git history is needed to get a proper list of changed files within `super-linter` fetch-depth: 0 + persist-credentials: false ################################ # Run Linter against code base # ################################ - name: Lint Code Base - uses: github/super-linter/slim@v7 + uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8 env: DEFAULT_BRANCH: main GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VALIDATE_ALL_CODEBASE: false VALIDATE_GITLEAKS: false + VALIDATE_JSON_PRETTIER: false VALIDATE_MARKDOWN_PRETTIER: false VALIDATE_NATURAL_LANGUAGE: false VALIDATE_YAML_PRETTIER: false diff --git a/.markdownlint.json b/.markdownlint.json index e710966..e759cae 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,8 +1,8 @@ { - "MD013": { - "line_length": 120, - "code_blocks": false, - "tables": false, - "headings": false - } + "MD013": { + "line_length": 120, + "code_blocks": false, + "tables": false, + "headings": false + } } diff --git a/.rubocop.yml b/.rubocop.yml new file mode 120000 index 0000000..7160446 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1 @@ +.github/linters/.ruby-lint.yml \ No newline at end of file From aa8467a7934a01c66660f459fef864c2dd3cb50f Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Wed, 30 Sep 2026 16:57:26 -0500 Subject: [PATCH 4/4] docs: document 2.1.0 output formats and test workflows Clarify CLI version output, custom templates, and VCR tooling. Record release changes and ignore local AI-assistant directories. --- .gitignore | 2 ++ CHANGELOG.md | 13 +++++++++++++ README.md | 49 +++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index e1f68f9..8998254 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ /output/ *.gem .cursor/ +.claude/ +.scout/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4edf07f..c05648f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2.1.0 + +### New features + +- Added a `custom` `output_format` that renders rows through a project-supplied Mustache template (`templates.custom` / `custom_output` config keys) +- `markdown` output now renders through a Mustache template instead of hand-built strings + +### Testing + +- Added RSpec coverage (SimpleCov, WebMock) across Config, Client, EnterpriseClient, Pulls, RowCollector, Row, Runner, CLI, and the output formatters +- Added network-free Cucumber scenarios for config validation, all output formats, and the enterprise SSRF guard +- Added VCR-backed Cucumber scenarios (tagged `@vcr`, excluded from the default run) that replay recorded GitHub API responses + ## 2.0.0 ### Breaking changes diff --git a/README.md b/README.md index cdcc310..72c24d5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # whatsup_github -[![Gem version](https://img.shields.io/gem/v/whatsup_github.svg?style=flat)](https://rubygems.org/gems/whatsup_github) - -This tool helps updating data for [Whats New on DevDocs](http://devdocs.magento.com/whats-new.html). +This tool helps updating data for What's New section on the public Adobe Commerce technical documentation resources. It filters GitHub pull requests and generates a data file. One pull request sources one data entity. All filtering parameters are set in a configuration file, except dates. @@ -26,7 +24,27 @@ Options: ## What's generated -A resulting YAML file `tmp/whats-new.yml` is generated from GitHub data. +Output is controlled by `output_format` in `.whatsup.yml`, which accepts one or more of: + +- `yaml` — writes `tmp/whats-new.yml`. +- `markdown` — writes headerless Markdown table rows to `tmp/whats-new-on-devdocs.md` with description, versions, type, and date (`YYYY-MM-DD`). With no matching pull requests, the file is empty. +- `custom` — renders rows through a project-supplied [Mustache](https://github.com/mustache/mustache) template. + +```yaml +output_format: + - yaml + - custom +templates: + custom: path/to/your-template.mustache +custom_output: tmp/whats-new-custom.txt +``` + +For `custom`, `templates.custom` must point to a file inside the project directory (relative to where +`whatsup_github` is run), and `custom_output` sets the destination path. The template receives +`{{#rows}}`, each with `description`, `versions`, `type`, `date`, `link`, `merge_commit`, `contributor`, +and `labels`. + +The fields below describe each row, as produced for the `yaml` and `custom` formats. ### `description` @@ -53,8 +71,12 @@ Set as a list of `labels` in `.whatsup.yml`. There are two types of labels in co ### `versions` -Any GitHub label that starts from a digit followed by a period like in regular expression `\d\.`. -Examples: `2.3.x`, `1.0.3-msi`, `2.x` +Generated from pull request labels that begin with one or more digits followed by a period, matching `\A\d+\.`. +Examples: `2.3.x`, `1.0.3-msi`, `2.x`, `12.4`. + +Matching labels are kept unchanged and joined with a comma and a space in their original order. +For example, labels `whatsnew`, `2.4`, and `12.4` produce `2.4, 12.4`. +If no labels match, `versions` is an empty string. ### `date` @@ -206,6 +228,9 @@ To run rspec tests: rake spec ``` +Specs disable real network access ([WebMock](https://github.com/bblimke/webmock)) and generate a +[SimpleCov](https://github.com/simplecov-ruby/simplecov) coverage report at `coverage/index.html`. + #### features To run Cucumber tests: @@ -223,6 +248,18 @@ bundle exec cucumber features/since.feature NOTE: Cucumber tests will use the configuration file from code `lib/template/.whatsup.yml`. +Scenarios tagged `@vcr` (in `features/since_vcr.feature`) replay HTTP interactions recorded by +[VCR](https://github.com/vcr/vcr) into cassette files under `features/cassettes/`, and are excluded +from `rake features`/`bundle exec cucumber` by default (see `cucumber.yml`). Run them explicitly with: + +```bash +bundle exec cucumber -p vcr +``` + +To (re-)record a cassette, delete the corresponding file under `features/cassettes/`, export a +valid `WHATSUP_GITHUB_ACCESS_TOKEN`, temporarily switch the record mode from `:none` to `:once` in +`features/support/vcr.rb`, then run the command above and switch the record mode back. + #### Individual files Individual files can have tests at the end of a file in a format like: