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 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/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/Gemfile.lock b/Gemfile.lock index 62a8053..7750860 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,46 +12,34 @@ 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) - 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) - contracts (0.17.3) - 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) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + crack (1.0.1) 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) + rexml 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 @@ -58,22 +47,36 @@ 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) - 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) - multi_test (1.1.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + 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,26 +90,71 @@ 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) - sys-uname (1.5.1) - ffi (~> 1.1) - memoist3 (~> 1.0.0) + 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) 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 ruby DEPENDENCIES - aruba (~> 2.2) bundler (~> 2.5) - cucumber (~> 10.1) 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/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 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/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/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 247cc7e..916d081 100644 --- a/whatsup_github.gemspec +++ b/whatsup_github.gemspec @@ -34,18 +34,21 @@ 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' - 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' 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