From 837cbc2475c89886eba5997d45be24dabf4ec86a Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Thu, 1 Oct 2026 12:29:39 -0500 Subject: [PATCH 1/4] feat(output)!: add custom templates for 2.1.0 Add project-contained Mustache templates and custom output configuration. Preserve compatible Markdown and YAML formats, use ISO 8601 dates, and match version labels precisely. Add rendering and development dependencies and bump the gem version to 2.1.0. BREAKING CHANGE: Raise the minimum supported Ruby version from 3.0 to 3.3. --- 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 84e27c8b9562345660b0265be998ca71616d9f58 Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Thu, 1 Oct 2026 14:31:38 -0500 Subject: [PATCH 2/4] test: consolidate unit and CLI coverage in RSpec Expand unit coverage across configuration, clients, pull requests, collection, and output formatting. Replace Cucumber and Aruba with deterministic RSpec CLI integration tests. Include offline VCR replay, shared template fixtures, nested template paths, and directory-symlink escape regressions. Add coverage reporting and remove obsolete feature-test dependencies and support files. --- .simplecov | 5 + Gemfile.lock | 43 ---- Rakefile | 8 +- features/since.feature | 29 --- features/support/setup.rb | 3 - ..._requests_for_a_real_public_repository.yml | 78 +++++++ spec/fixtures/templates/custom.mustache | 1 + spec/spec_helper.rb | 16 ++ spec/support/netrc_helper.rb | 16 ++ spec/support/singleton_helper.rb | 8 + spec/whatsup_github/cli_integration_spec.rb | 190 ++++++++++++++++++ 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 ++++++ whatsup_github.gemspec | 2 - 24 files changed, 1262 insertions(+), 84 deletions(-) create mode 100644 .simplecov delete mode 100644 features/since.feature delete mode 100644 features/support/setup.rb create mode 100644 spec/fixtures/cassettes/fetches_merged_pull_requests_for_a_real_public_repository.yml create mode 100644 spec/fixtures/templates/custom.mustache create mode 100644 spec/support/netrc_helper.rb create mode 100644 spec/support/singleton_helper.rb create mode 100644 spec/whatsup_github/cli_integration_spec.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..9550662 --- /dev/null +++ b/.simplecov @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +SimpleCov.start do + add_filter '/spec/' +end diff --git a/Gemfile.lock b/Gemfile.lock index 40c61d3..7750860 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -27,47 +27,14 @@ GEM uri (>= 0.13.1) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) - aruba (2.3.3) - bundler (>= 1.17) - contracts (>= 0.16.0, < 0.18.0) - 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) - cucumber-ci-environment (> 9, < 12) - cucumber-core (> 15, < 17) - cucumber-cucumber-expressions (> 17, < 20) - cucumber-html-formatter (> 21, < 23) - diff-lcs (~> 1.5) - logger (~> 1.6) - mini_mime (~> 1.1) - multi_test (~> 1.1) - sys-uname (~> 1.3) - cucumber-ci-environment (11.0.0) - cucumber-core (16.2.0) - cucumber-gherkin (> 36, < 40) - cucumber-messages (> 31, < 33) - cucumber-tag-expressions (> 6, < 9) - cucumber-cucumber-expressions (19.0.0) - bigdecimal - cucumber-gherkin (39.0.0) - cucumber-messages (>= 31, < 33) - cucumber-html-formatter (22.3.0) - cucumber-messages (> 23, < 33) - cucumber-messages (32.2.0) - cucumber-tag-expressions (8.1.0) diff-lcs (1.6.2) docile (1.4.1) dotenv (3.2.0) @@ -80,8 +47,6 @@ GEM net-http (~> 0.5) faraday-retry (2.4.0) faraday (~> 2.0) - ffi (1.17.4) - ffi (1.17.4-arm64-darwin) fileutils (1.8.0) hashdiff (1.2.1) i18n (1.15.2) @@ -90,12 +55,9 @@ GEM 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) @@ -167,9 +129,6 @@ GEM 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) @@ -188,9 +147,7 @@ PLATFORMS ruby DEPENDENCIES - aruba (~> 2.2) bundler (~> 2.5) - cucumber (~> 10.1) fileutils (~> 1.7) rake (~> 13.1) rspec (~> 3.12) diff --git a/Rakefile b/Rakefile index 2e94734..769c878 100644 --- a/Rakefile +++ b/Rakefile @@ -2,15 +2,9 @@ require 'bundler/gem_tasks' require 'rspec/core/rake_task' -require 'cucumber' -require 'cucumber/rake/task' 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. -end - -task test: %i[spec features] +task test: :spec diff --git a/features/since.feature b/features/since.feature deleted file mode 100644 index 8379d07..0000000 --- a/features/since.feature +++ /dev/null @@ -1,29 +0,0 @@ -Feature: Since - In order to get updates since some date - As a CLI - I want to get valid formatted content - -Scenario: Basic - Given a file named ".whatsup.yml" with: - """ - --- - base_branch: main - repos: - - octokit/octokit.rb - labels: - required: - - enhancement - output_format: - - yaml - magic_word: whatsnew - """ - When I run `whatsup_github since 'jun 10'` - Then the output should contain "Done!" - -Scenario: Check version - When I run `whatsup_github version` - Then the output should contain "Current version is" - -Scenario: With no subcommand or argument - When I run `whatsup_github` - Then the output should contain "Commands:" diff --git a/features/support/setup.rb b/features/support/setup.rb deleted file mode 100644 index 751674f..0000000 --- a/features/support/setup.rb +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true - -require 'aruba/cucumber' diff --git a/spec/fixtures/cassettes/fetches_merged_pull_requests_for_a_real_public_repository.yml b/spec/fixtures/cassettes/fetches_merged_pull_requests_for_a_real_public_repository.yml new file mode 100644 index 0000000..00e4b29 --- /dev/null +++ b/spec/fixtures/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-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/spec/fixtures/templates/custom.mustache b/spec/fixtures/templates/custom.mustache new file mode 100644 index 0000000..1281df5 --- /dev/null +++ b/spec/fixtures/templates/custom.mustache @@ -0,0 +1 @@ +Updates:{{#rows}} {{{description}}}{{/rows}} 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_integration_spec.rb b/spec/whatsup_github/cli_integration_spec.rb new file mode 100644 index 0000000..fc611e8 --- /dev/null +++ b/spec/whatsup_github/cli_integration_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'open3' +require 'rbconfig' +require 'tmpdir' +require 'yaml' +require 'vcr' +require 'whatsup_github/cli' + +VCR.configure do |config| + config.cassette_library_dir = File.expand_path('../fixtures/cassettes', __dir__) + config.hook_into :webmock + config.default_cassette_options = { record: :none } + config.allow_http_connections_when_no_cassette = true + config.filter_sensitive_data('') { ENV.fetch('WHATSUP_GITHUB_ACCESS_TOKEN', nil) } +end + +RSpec.describe 'CLI integration' do + let(:configuration) do + { + 'base_branch' => 'main', + 'repos' => ['octokit/octokit.rb'], + 'labels' => { 'required' => ['enhancement'] }, + 'output_format' => ['yaml'], + 'magic_word' => 'whatsnew' + } + end + + around do |example| + Dir.mktmpdir { |directory| Dir.chdir(directory) { example.run } } + end + + before do + stub_netrc(exists: false) + File.write('.whatsup.yml', configuration.to_yaml) + end + + def run_executable(*arguments, environment: {}) + root = File.expand_path('../..', __dir__) + Open3.capture3( + { 'HOME' => Dir.pwd, 'WHATSUP_GITHUB_ACCESS_TOKEN' => nil }.merge(environment), + RbConfig.ruby, '-I', File.join(root, 'lib'), File.join(root, 'exe/whatsup_github'), *arguments + ) + end + + def run_since + WhatsupGithub::CLI.start(['since', '2026-01-01']) + end + + def read_output + YAML.safe_load(File.read('tmp/whats-new.yml')) + end + + describe 'the executable' do + it 'prints the gem version and exits successfully' do + stdout, stderr, result = run_executable('version') + + expect(result).to be_success + expect(stdout).to include("Current version is #{WhatsupGithub::VERSION}") + expect(stderr).to be_empty + end + + it 'prints available commands with no arguments' do + stdout, stderr, result = run_executable + + expect(result).to be_success + expect(stdout).to include('Commands:', 'since', 'version') + expect(stderr).to be_empty + end + + it 'rejects a config path outside the project directory' do + stdout, stderr, result = run_executable('since', '2026-01-01', '--config=../evil.yml') + + expect(result.exitstatus).to eq(1) + expect(stderr).to include('ERROR: Invalid config path') + expect(stdout).not_to include('Done!') + expect(Dir.exist?('tmp')).to be false + end + + it 'rejects a private enterprise hostname before any network call' do + configuration['repos'] = ['enterprise:org/repo'] + File.write('.whatsup.yml', configuration.to_yaml) + stdout, stderr, result = run_executable( + 'since', '2026-01-01', environment: { 'WHATSUP_GITHUB_ENTERPRISE_HOSTNAME' => 'localhost' } + ) + + expect(result).not_to be_success + expect(stderr).to include('Private/internal addresses are not allowed') + expect(stdout).not_to include('Done!') + expect(Dir.exist?('tmp')).to be false + end + end + + describe 'output generation with no matching pull requests' do + let(:custom_template_fixture) { File.expand_path('../fixtures/templates/custom.mustache', __dir__) } + + let(:search_request) do + stub_request(:get, 'https://api.github.com/search/issues') + .with(query: { + 'per_page' => '100', + 'q' => 'repo:octokit/octokit.rb label:"enhancement" merged:>=2026-01-01 base:main is:pull-request' + }) + .to_return(body: { total_count: 0, incomplete_results: false, items: [] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + end + + before { search_request } + + def configure_custom_output(template_path) + configuration.merge!('output_format' => ['custom'], + 'templates' => { 'custom' => template_path }, + 'custom_output' => 'tmp/whats-new-custom.md') + File.write('.whatsup.yml', configuration.to_yaml) + end + + it 'completes the basic since workflow' do + expect { run_since }.to output(/Done!/).to_stdout + expect(read_output['entries']).to eq([]) + expect(search_request).to have_been_requested.once + end + + it 'writes empty markdown' do + configuration['output_format'] = ['markdown'] + File.write('.whatsup.yml', configuration.to_yaml) + + expect { run_since }.to output(/Done!/).to_stdout + expect(File.read('tmp/whats-new-on-devdocs.md')).to eq('') + end + + it 'writes valid YAML with an empty entries array and an update timestamp' do + expect { run_since }.to output(/Done!/).to_stdout + expect(read_output).to include('entries' => [], 'updated' => a_kind_of(String)) + end + + it 'rejects a custom template outside the project directory' do + configure_custom_output('../../../../etc/passwd') + + expect { run_since }.to raise_error(SystemExit) { |error| expect(error.status).to eq(1) } + .and output(/must point to a file inside the project directory/).to_stderr + expect(Dir.exist?('tmp')).to be false + end + + it 'renders a fixture template from a nested project path' do + FileUtils.mkdir_p('templates/releases') + FileUtils.cp(custom_template_fixture, 'templates/releases/custom.mustache') + configure_custom_output('templates/releases/custom.mustache') + + expect { run_since }.to output(/Done!/).to_stdout + expect(File.read('tmp/whats-new-custom.md')).to eq("Updates:\n") + end + + it 'rejects a fixture template reached through a directory symlink escaping the project' do + Dir.mktmpdir do |outside| + FileUtils.cp(custom_template_fixture, File.join(outside, 'custom.mustache')) + FileUtils.mkdir_p('templates') + File.symlink(outside, 'templates/releases') + configure_custom_output('templates/releases/custom.mustache') + + expect { run_since }.to raise_error(SystemExit) { |error| expect(error.status).to eq(1) } + .and output(/must point to a file inside the project directory/).to_stderr + .and output('').to_stdout + expect(Dir.exist?('tmp')).to be false + end + end + + it 'aborts without writing output when custom_output is missing' do + configuration.merge!('output_format' => ['custom'], 'templates' => { 'custom' => 'custom.mustache' }) + File.write('.whatsup.yml', configuration.to_yaml) + + expect { run_since }.to raise_error(SystemExit) { |error| expect(error.status).to eq(1) } + .and output(/'custom_output' is not set/).to_stderr + expect(Dir.exist?('tmp')).to be false + end + end + + describe 'recorded GitHub API interactions' do + it 'replays a public repository search without live network access' do + configuration['labels']['required'] = ['documentation'] + File.write('.whatsup.yml', configuration.to_yaml) + + VCR.use_cassette('fetches_merged_pull_requests_for_a_real_public_repository', + allow_unused_http_interactions: false) do + expect { run_since }.to output(/Done!/).to_stdout + end + + expect(read_output['entries']).to eq([]) + end + 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 diff --git a/whatsup_github.gemspec b/whatsup_github.gemspec index be06e02..916d081 100644 --- a/whatsup_github.gemspec +++ b/whatsup_github.gemspec @@ -43,9 +43,7 @@ Gem::Specification.new do |spec| spec.add_dependency 'octokit', '~> 10.0' spec.add_dependency 'thor', '~> 1.3' - spec.add_development_dependency 'aruba', '~> 2.2' spec.add_development_dependency 'bundler', '~> 2.5' - 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' From 386456743e957ea788a586fc99eb4dc8805300ce Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Thu, 1 Oct 2026 14:31:40 -0500 Subject: [PATCH 3/4] ci(lint): align validation and local tooling Pin checkout and Super-Linter actions, scope workflow permissions, and disable conflicting JSON formatting checks. Align Markdown formatting and reuse the repository Ruby lint configuration locally. Ignore local Claude and Scout directories. --- .github/workflows/linter.yml | 15 ++++++++++----- .gitignore | 2 ++ .markdownlint.json | 12 ++++++------ .rubocop.yml | 1 + 4 files changed, 19 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/.gitignore b/.gitignore index e1f68f9..8998254 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ /output/ *.gem .cursor/ +.claude/ +.scout/ 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 d6d9d463d6dbb94f430443c2dd1070c64ddf4b74 Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Thu, 1 Oct 2026 14:31:40 -0500 Subject: [PATCH 4/4] docs: document 2.1.0 outputs and test workflows Document custom templates, supported output formats, CLI version output, and the unified RSpec workflow. Explain offline cassette replay and safe re-recording, and record release changes. --- CHANGELOG.md | 14 +++++++++++ README.md | 65 +++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4edf07f..94c6f22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # 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 +- Replaced Cucumber and Aruba with RSpec integration tests covering config validation, output formats, and the enterprise SSRF guard +- Included offline VCR replay of recorded GitHub API responses in the default test suite +- Added fixture-based regression tests for nested template paths and directory-symlink escapes + ## 2.0.0 ### Breaking changes diff --git a/README.md b/README.md index cdcc310..2c442a3 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` @@ -195,33 +217,38 @@ You can also run `bin/console` for an interactive prompt that will allow you to ### Testing -The project contains [rspec](https://rspec.info/) tests in `spec` and -cucumber tests in `features`. - -#### specs +The project contains [RSpec](https://rspec.info/) unit and CLI integration tests in `spec`. -To run rspec tests: +To run all tests, including offline replay of recorded GitHub API interactions: ```bash -rake spec +bundle exec rake ``` -#### features +In-process 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`. +Subprocess tests exercise executable startup and configuration rejection without making API requests. -To run Cucumber tests: +To run only CLI integration tests: ```bash -rake features +bundle exec rspec spec/whatsup_github/cli_integration_spec.rb ``` -To pass the `output_file.feature` tests, you need to generate a non-empty `whats-new.yml`. -To test just file: +Integration tests create their own configuration and output files in temporary directories. +The recorded API test uses [VCR](https://github.com/vcr/vcr) with a cassette under +`spec/fixtures/cassettes/`. Recording is disabled by default, so it runs without live network access. +The existing cassette contains an empty search result; it verifies API replay and empty YAML output, +not rendering of non-empty pull request data. Run it separately with: ```bash -bundle exec cucumber features/since.feature +bundle exec rspec spec/whatsup_github/cli_integration_spec.rb --example 'recorded GitHub API interactions' ``` -NOTE: Cucumber tests will use the configuration file from code `lib/template/.whatsup.yml`. +To re-record a cassette, delete the corresponding file under `spec/fixtures/cassettes/`, export a +valid `WHATSUP_GITHUB_ACCESS_TOKEN` before starting RSpec, and temporarily switch the record mode +from `:none` to `:once` in `spec/whatsup_github/cli_integration_spec.rb`. Run the command above, +then restore `:none` and review the cassette for sensitive data before committing it. #### Individual files