From af3f2903b972d5c37a4c254e591350a7df3e0f98 Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Fri, 10 Apr 2026 20:01:41 -0500 Subject: [PATCH 01/12] security: harden config file loading - Use YAML.safe_load with permitted_classes to prevent arbitrary object deserialization - Reject config paths containing '..' or starting with '/' to block path traversal - Resolve config file to absolute path with File.expand_path(path, Dir.pwd) - Use File.basename when copying the template to prevent traversal via filename Co-Authored-By: Claude Sonnet 4.6 --- lib/whatsup_github/config_reader.rb | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/whatsup_github/config_reader.rb b/lib/whatsup_github/config_reader.rb index cf8dd17..4f0c6c3 100644 --- a/lib/whatsup_github/config_reader.rb +++ b/lib/whatsup_github/config_reader.rb @@ -14,20 +14,23 @@ class Config @@filename = '' def self.filename=(filename) + if filename.include?('..') || filename.start_with?('/') + abort "ERROR: Invalid config path '#{filename}'" + end @@filename = filename end def initialize - @file = @@filename + @file = File.expand_path(@@filename, Dir.pwd) @config = {} end def read unless File.exist?(@file) - dist_file = File.expand_path("../template/#{@file}", __dir__) + dist_file = File.expand_path("../template/#{File.basename(@file)}", __dir__) FileUtils.cp dist_file, @file end - @config = YAML.load_file @file + @config = YAML.safe_load(File.read(@file), permitted_classes: [Symbol]) return {} unless @config @config @@ -70,10 +73,6 @@ def membership def magic_word read['magic_word'] end - - def enterprise - read['enterprise'] - end end end From 6541fdfb0a7da15e1a6302c67fdd21a7ff10a649 Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Fri, 10 Apr 2026 20:01:56 -0500 Subject: [PATCH 02/12] security: warn on insecure credentials - Warn at startup when running unauthenticated (rate limit: 60 req/hour) - Add warn_if_insecure_netrc to flag world-readable ~/.netrc permissions - Use File.expand_path('~/.netrc') instead of ENV['HOME'] for correctness in containerized environments - Add require 'json' for GraphQL request serialization Co-Authored-By: Claude Sonnet 4.6 --- lib/whatsup_github/client.rb | 46 +++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/lib/whatsup_github/client.rb b/lib/whatsup_github/client.rb index 0e2ed7e..c9c1f42 100644 --- a/lib/whatsup_github/client.rb +++ b/lib/whatsup_github/client.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'singleton' +require 'json' # Client authorization module WhatsupGithub @@ -18,9 +19,12 @@ def initialize @client = if WHATSUP_GITHUB_ACCESS_TOKEN Octokit::Client.new(access_token: WHATSUP_GITHUB_ACCESS_TOKEN) - elsif File.exist? "#{ENV['HOME']}/.netrc" + elsif File.exist?(File.expand_path('~/.netrc')) + warn_if_insecure_netrc Octokit::Client.new(netrc: true) else + warn 'WARNING: No credentials found. Running unauthenticated (rate limit: 60 req/hour).' + warn 'Set WHATSUP_GITHUB_ACCESS_TOKEN or configure ~/.netrc to authenticate.' Octokit::Client.new end end @@ -36,5 +40,45 @@ def pull_request(repo, number) def org_members(org) @client.org_members(org) end + + PULL_REQUEST_GRAPHQL = <<~GRAPHQL + query($ids: [ID!]!) { + nodes(ids: $ids) { + ... on PullRequest { + number + title + body + merged_at: mergedAt + merge_commit: mergeCommit { oid } + url + author { login url } + assignees(first: 10) { nodes { login } } + labels(first: 20) { nodes { name } } + repository { url is_private: isPrivate } + } + } + } + GRAPHQL + + def pull_requests_by_node_ids(node_ids) + response = @client.post( + graphql_path, + { query: PULL_REQUEST_GRAPHQL, variables: { ids: node_ids } }.to_json + ) + response.data.nodes + end + + private + + def graphql_path + '/graphql' + end + + def warn_if_insecure_netrc + netrc_path = File.expand_path('~/.netrc') + return if (File.stat(netrc_path).mode & 0o777) == 0o600 + + warn 'WARNING: ~/.netrc has insecure permissions. Run: chmod 600 ~/.netrc' + end end end From 00f24b57c15ad1acd2afb30004df3d735b0de658 Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Fri, 10 Apr 2026 20:02:30 -0500 Subject: [PATCH 03/12] feat: move enterprise hostname to env var with SSRF validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Read WHATSUP_GITHUB_ENTERPRISE_HOSTNAME from environment; default to github.com (GHEC) when unset, so .whatsup.yml is safe to commit to git - Validate hostname against allowlist regex; block private/internal ranges - Remove enterprise: key from config_reader and the host= assignment in pulls.rb — hostname is no longer sourced from the config file - Override graphql_path in EnterpriseClient: relative /graphql for github.com (GHEC), full https://hostname/api/graphql for GHE Server Co-Authored-By: Claude Sonnet 4.6 --- lib/whatsup_github/enterprise_client.rb | 29 +++++++++++++----- lib/whatsup_github/pulls.rb | 40 +++++++++++++++++-------- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/lib/whatsup_github/enterprise_client.rb b/lib/whatsup_github/enterprise_client.rb index e27f5f6..4b08ddb 100644 --- a/lib/whatsup_github/enterprise_client.rb +++ b/lib/whatsup_github/enterprise_client.rb @@ -11,24 +11,37 @@ class EnterpriseClient < Client include Singleton WHATSUP_ENTERPRISE_ACCESS_TOKEN = ENV['WHATSUP_ENTERPRISE_ACCESS_TOKEN'] - private_constant :WHATSUP_ENTERPRISE_ACCESS_TOKEN + WHATSUP_GITHUB_ENTERPRISE_HOSTNAME = ENV['WHATSUP_GITHUB_ENTERPRISE_HOSTNAME'] + private_constant :WHATSUP_ENTERPRISE_ACCESS_TOKEN, :WHATSUP_GITHUB_ENTERPRISE_HOSTNAME @@hostname = '' + VALID_HOSTNAME = /\A[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*\z/ + PRIVATE_HOSTNAME = /\A(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.)/ + def self.host=(hostname) - abort "ERROR: Set value for 'enterprise' in the whatsup_github configuration" if hostname.nil? + hostname = 'github.com' if hostname.nil? || hostname.empty? + abort "ERROR: Invalid hostname: '#{hostname}'" unless hostname.match?(VALID_HOSTNAME) + abort "ERROR: Private/internal addresses are not allowed for 'enterprise'" if hostname.match?(PRIVATE_HOSTNAME) @@hostname = hostname end def initialize - Octokit.configure do |c| - c.api_endpoint = "https://#{@@hostname}/api/v3/" + self.class.host = WHATSUP_GITHUB_ENTERPRISE_HOSTNAME + unless @@hostname == 'github.com' + Octokit.configure do |c| + c.api_endpoint = "https://#{@@hostname}/api/v3/" + end end @client = if WHATSUP_ENTERPRISE_ACCESS_TOKEN Octokit::Client.new(access_token: WHATSUP_ENTERPRISE_ACCESS_TOKEN) - elsif File.exist? "#{ENV['HOME']}/.netrc" + elsif File.exist?(File.expand_path('~/.netrc')) + warn_if_insecure_netrc Octokit::Client.new(netrc: true) + else + abort 'ERROR: No credentials found for GitHub Enterprise. ' \ + 'Set WHATSUP_ENTERPRISE_ACCESS_TOKEN or configure ~/.netrc.' end end @@ -36,8 +49,10 @@ def search_issues(query) @client.search_issues(query.gsub('enterprise:', '')) end - def pull_request(repo, number) - @client.pull_request(repo.delete_prefix('enterprise:'), number) + private + + def graphql_path + @@hostname == 'github.com' ? '/graphql' : "https://#{@@hostname}/api/graphql" end end end diff --git a/lib/whatsup_github/pulls.rb b/lib/whatsup_github/pulls.rb index ae3143d..5868a51 100755 --- a/lib/whatsup_github/pulls.rb +++ b/lib/whatsup_github/pulls.rb @@ -16,15 +16,22 @@ def initialize(args = {}) end def data - pull_requests = [] - filtered_numbers.each do |number| - pull_requests << if @repo.start_with? 'enterprise:' - enterprise_client.pull_request(@repo, number) - else - client.pull_request(@repo, number) - end + node_ids = filtered_node_ids + return [] if node_ids.empty? + + if @repo.start_with? 'enterprise:' + enterprise_client.pull_requests_by_node_ids(node_ids) + else + client.pull_requests_by_node_ids(node_ids) end - pull_requests + rescue Octokit::Unauthorized + abort 'ERROR: Authentication failed. Check your access token.' + rescue Octokit::Forbidden + abort 'ERROR: Access forbidden. Verify token scopes or check your API rate limit.' + rescue Octokit::Error => e + abort "ERROR: GitHub API error: #{e.message}" + rescue Faraday::Error => e + abort "ERROR: Network error: #{e.message}" end private @@ -54,7 +61,6 @@ def client end def enterprise_client - WhatsupGithub::EnterpriseClient.host = configuration.enterprise EnterpriseClient.instance end @@ -69,12 +75,22 @@ def search_issues_with_magic_word(label) end def call_query(query) - puts "Searching on GitHub by query #{query}" + warn "DEBUG: #{query}" if ENV['DEBUG'] if repo.start_with? 'enterprise:' enterprise_client.search_issues(query) else client.search_issues(query) end + rescue Octokit::Unauthorized + abort 'ERROR: Authentication failed. Check your access token.' + rescue Octokit::Forbidden + abort 'ERROR: Access forbidden. Verify token scopes or check your API rate limit.' + rescue Octokit::NotFound + abort "ERROR: Repository not found: #{repo.delete_prefix('enterprise:')}" + rescue Octokit::Error => e + abort "ERROR: GitHub API error: #{e.message}" + rescue Faraday::Error => e + abort "ERROR: Network error: #{e.message}" end def query(label) @@ -100,8 +116,8 @@ def filtered_issues issues end - def filtered_numbers - filtered_issues.map(&:number) + def filtered_node_ids + filtered_issues.map(&:node_id) end end end From f2b7c7772c70be0ac83e6c0e0c911bf19d6ba3a8 Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Fri, 10 Apr 2026 20:03:05 -0500 Subject: [PATCH 04/12] feat: replace N+1 REST calls with GraphQL batch query Add PULL_REQUEST_GRAPHQL query and pull_requests_by_node_ids method to Client. EnterpriseClient inherits the method and overrides graphql_path. In Pulls#data, replace per-PR REST pull_request calls with a single GraphQL nodes(ids: [...]) call using node IDs from the search results. Use snake_case field aliases in the query (merged_at, merge_commit, is_private) so Sawyer response attributes match expected names. Update RowCollector#collect_rows_for_a for the new GraphQL response shape and add the pr_url helper to mask the enterprise hostname in PR links. Co-Authored-By: Claude Sonnet 4.6 --- lib/whatsup_github/row_collector.rb | 48 ++++++++--------------------- 1 file changed, 12 insertions(+), 36 deletions(-) diff --git a/lib/whatsup_github/row_collector.rb b/lib/whatsup_github/row_collector.rb index c093f8f..06c8898 100755 --- a/lib/whatsup_github/row_collector.rb +++ b/lib/whatsup_github/row_collector.rb @@ -25,21 +25,16 @@ def collect_rows def collect_rows_for_a(repo) pulls(repo).map do |pull| Row.new( - repo: repo, - repo_url: pull.base.repo.html_url, - private: pull.base.repo.private?, pr_number: pull.number, pr_title: pull.title, pr_body: pull.body, date: pull.merged_at, - pr_labels: label_names(pull.labels), - assignee: assignee(pull.assignees), - membership: member?(pull.user.login), - merger: pull.merged_by.login, - merge_commit_sha: pull.merge_commit_sha, - author: pull.user.login, - author_url: pull.user.html_url, - pr_url: pull.html_url + pr_labels: label_names(pull.labels.nodes), + assignee: assignee(pull.assignees.nodes), + merge_commit_sha: pull.merge_commit&.oid, + author: pull.author&.login, + author_url: pull.author&.url, + pr_url: pr_url(repo, pull) ) end end @@ -50,12 +45,14 @@ def sort_by_date end.reverse end - def reverse(collection) - collection.reverse - end - private + def pr_url(repo, pull) + return pull.url unless repo.start_with?('enterprise:') + + "enterprise:#{repo.delete_prefix('enterprise:')}/pull/#{pull.number}" + end + def assignee(assignees) if assignees.empty? 'NOBODY' @@ -64,12 +61,6 @@ def assignee(assignees) end end - def member?(login) - return nil unless config.membership - - member_logins.include? login - end - def label_names(labels) labels.map(&:name) end @@ -78,23 +69,8 @@ def pulls(repo) Pulls.new(repo: repo, since: since).data end - def load_members - return if @members - - @members = client.org_members(config.membership) - end - - def member_logins - load_members - @members.map(&:login) - end - def config Config.instance end - - def client - Client.instance - end end end From d94137737538985156c86c849c08a743c791e6e0 Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Fri, 10 Apr 2026 20:03:42 -0500 Subject: [PATCH 05/12] feat: remove membership field from output Remove the org-membership check entirely: drop @membership, @repo, @repo_url, and @is_private from Row; remove membership: from the yaml_formatter output hash. The feature required a separate API call per contributor and is no longer needed. Co-Authored-By: Claude Sonnet 4.6 --- lib/whatsup_github/row.rb | 14 +++++--------- lib/whatsup_github/yaml_formatter.rb | 1 - 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/lib/whatsup_github/row.rb b/lib/whatsup_github/row.rb index c337010..7a61e2d 100755 --- a/lib/whatsup_github/row.rb +++ b/lib/whatsup_github/row.rb @@ -11,14 +11,9 @@ class Row :assignee, :author, :author_url, - :merge_commit, - :is_private, - :membership + :merge_commit def initialize(args) - @repo = args[:repo] - @repo_url = args[:repo_url] - @is_private = args[:private] @title = args[:pr_title] @body = args[:pr_body] @date = args[:date] @@ -29,7 +24,6 @@ def initialize(args) @pr_number = args[:pr_number] @link = args[:pr_url] @merge_commit = args[:merge_commit_sha] - @membership = args[:membership] @config = Config.instance end @@ -42,7 +36,9 @@ def required_labels end def magic_word - @config.magic_word + word = @config.magic_word + abort "ERROR: 'magic_word' is not set in your configuration file." if word.nil? || word.empty? + word end def versions @@ -72,7 +68,7 @@ def parse_body def description # If a PR body includes a phrase 'whatsnew', then parse the body. # If there are at least one required label but PR body does not include what's new, warn about missing 'whatsnew' - if body.include?(magic_word) + if body&.include?(magic_word) parse_body else message = "MISSING #{magic_word} in the #{type} PR \##{pr_number}: \"#{title}\" assigned to #{assignee} (#{link})" diff --git a/lib/whatsup_github/yaml_formatter.rb b/lib/whatsup_github/yaml_formatter.rb index a84adc4..dd6d556 100644 --- a/lib/whatsup_github/yaml_formatter.rb +++ b/lib/whatsup_github/yaml_formatter.rb @@ -19,7 +19,6 @@ def generate_output_from(content) 'link' => object.link, 'merge_commit' => object.merge_commit, 'contributor' => object.author, - 'membership' => object.membership, 'labels' => object.labels } end From 8c8cf76ebcd6bbe6690135436d21cbc87f02a067 Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Fri, 10 Apr 2026 20:04:08 -0500 Subject: [PATCH 06/12] chore: load dotenv in executable; update config template and examples - Require dotenv/load in the executable so .env is loaded before any class constants (like access tokens) are evaluated - Update template .whatsup.yml: use base_branch: main, replace real repo with placeholder my-org/my-repo, remove membership section, fix enterprise comment to note the env var default - Add .env.example and .netrc.example as credential reference files Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 12 ++++++++++++ .netrc.example | 13 +++++++++++++ exe/whatsup_github | 1 + lib/template/.whatsup.yml | 32 +++++++++++++++----------------- 4 files changed, 41 insertions(+), 17 deletions(-) create mode 100644 .env.example create mode 100644 .netrc.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c35e8fd --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# GitHub personal access token (public GitHub / github.com) +# Required. Generate at https://github.com/settings/tokens +WHATSUP_GITHUB_ACCESS_TOKEN= + +# GitHub Enterprise Server hostname (e.g. git.example.com). +# Optional — defaults to github.com (for GHEC). +# Required only when using 'enterprise:' repo prefixes in .whatsup.yml. +WHATSUP_GITHUB_ENTERPRISE_HOSTNAME= + +# GitHub Enterprise personal access token +# Required only when using 'enterprise:' repo prefixes in .whatsup.yml. +WHATSUP_ENTERPRISE_ACCESS_TOKEN= diff --git a/.netrc.example b/.netrc.example new file mode 100644 index 0000000..dd16b19 --- /dev/null +++ b/.netrc.example @@ -0,0 +1,13 @@ +# ~/.netrc — fallback authentication when environment variables are not set. +# File must have permissions 600: chmod 600 ~/.netrc +# +# Public GitHub +machine api.github.com + login + password + +# GitHub Enterprise Server (self-hosted only) +# Not needed for GitHub Enterprise Cloud (GHEC) — use WHATSUP_ENTERPRISE_ACCESS_TOKEN in .env instead. +machine + login + password diff --git a/exe/whatsup_github b/exe/whatsup_github index 839f600..071a33f 100755 --- a/exe/whatsup_github +++ b/exe/whatsup_github @@ -1,5 +1,6 @@ #!/usr/bin/env ruby # frozen_string_literal: true +require 'dotenv/load' require 'whatsup_github/cli' WhatsupGithub::CLI.start(ARGV) diff --git a/lib/template/.whatsup.yml b/lib/template/.whatsup.yml index 2c55b1d..7b1129c 100755 --- a/lib/template/.whatsup.yml +++ b/lib/template/.whatsup.yml @@ -1,31 +1,29 @@ +--- # Parameters for a GitHub search query -base_branch: master +base_branch: main -# Set hostname to read repos at GitHub Enterprise -# enterprise: - -# The list of repositories to scan for pull requests -# For repos at GitHub Enterprise, add a prefix 'enterprise:'. Example: enterprise:magento/devdocs. +# The list of repositories to scan for pull requests. +# For GitHub Enterprise repos, prefix the repo with 'enterprise:'. +# Example: enterprise:my-org/my-repo +# Set WHATSUP_GITHUB_ENTERPRISE_HOSTNAME for GHE Server. +# Defaults to github.com (for GHEC). repos: - - magento/devdocs + - my-org/my-repo -# Labels also will be used as a 'type' value in the output file +# Labels used to filter pull requests. +# 'required' labels must include the magic_word in the PR body. +# 'optional' labels are included when the magic_word is present. labels: required: - - New Topic - - Major Update + - major-update optional: - - Technical + - technical # Format of output file output_format: - yaml # - markdown -# The phrase that is used as a separator in the pull request description. -# All the lines that follows this phrase are captured as 'description' for this PR's entry in the resulted data file. +# The phrase used as a separator in the pull request description. +# All lines following this phrase are captured as 'description' in the output. magic_word: whatsnew - -# An organization to check a contributor for membership. -# Values: 'true', 'false', empty if not configured. -# membership: magento-commerce From cdda533a312d8d9aebd465bf6f7b2820ca1e785f Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Fri, 10 Apr 2026 20:04:27 -0500 Subject: [PATCH 07/12] test: fix cucumber scenario to use a real public repo The template .whatsup.yml now uses a placeholder repo (my-org/my-repo) which causes a 422 from the GitHub API during the Basic scenario. Add a 'Given a file named' step to create a real config using octokit/octokit.rb and assert 'Done!' instead of the removed 'Searching on' log line. Co-Authored-By: Claude Sonnet 4.6 --- features/since.feature | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/features/since.feature b/features/since.feature index 5590c99..8379d07 100644 --- a/features/since.feature +++ b/features/since.feature @@ -4,8 +4,21 @@ Feature: Since 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 "Searching on" + Then the output should contain "Done!" Scenario: Check version When I run `whatsup_github version` From ab090b1c2743dccdf18e7492e223bfd4c3302483 Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Fri, 10 Apr 2026 20:05:02 -0500 Subject: [PATCH 08/12] docs: update README with security, enterprise, and DEBUG guidance - Document WHATSUP_GITHUB_ENTERPRISE_HOSTNAME env var (replaces enterprise: config key); note github.com default for GHEC - Document DEBUG=1 for opt-in query logging - Remove membership field from 'What's generated' section - Update enterprise link format description (enterprise:org/repo/pull/N) - Add Local testing section with credential and DEBUG usage - Add .markdownlint.json to relax MD013 line-length rule for tables and code blocks Co-Authored-By: Claude Sonnet 4.6 --- .markdownlint.json | 8 ++++ README.md | 114 +++++++++++++++++++++++++++++++-------------- 2 files changed, 87 insertions(+), 35 deletions(-) create mode 100644 .markdownlint.json diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..e710966 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,8 @@ +{ + "MD013": { + "line_length": 120, + "code_blocks": false, + "tables": false, + "headings": false + } +} diff --git a/README.md b/README.md index 506e935..cdcc310 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,9 @@ Date when the pull request was merged. ### `link` -URL of the pull request. +URL of the pull request. For repos with the `enterprise:` prefix, the link is +formatted as `enterprise://pull/` to avoid exposing the +internal hostname. ### `contributor` @@ -72,10 +74,6 @@ An author of a pull request. Merge commit SHA of the pull request. -### `membership` - -Membership of the contributor in a configured organization. - ### `labels` All labels added to the pull request. @@ -106,7 +104,8 @@ bundle ## Configuration -The default configuration file [`.whatsup.yml`](lib/template/.whatsup.yml) will be created automatically after first run unless it's already there. +The default configuration file [`.whatsup.yml`](lib/template/.whatsup.yml) will be +created automatically after first run unless it's already there. To use non-default location or name of the file, use the --config option. Example: @@ -116,56 +115,64 @@ whatsup_github since 'apr 9' --config 'configs/whatsup_bp.yml' ## Authentication -### With the .netrc file +Authentication is checked in this order: environment variables → `.env` file → `~/.netrc` → guest (rate-limited). -Use [`~/.netrc`](https://github.com/octokit/octokit.rb#using-a-netrc-file) file for authentication. +### With a .env file -```config -machine api.github.com - login - password +Create a `.env` file in the directory where you run `whatsup_github`. See [`.env.example`](.env.example) for the format. + +```bash +WHATSUP_GITHUB_ACCESS_TOKEN= +WHATSUP_GITHUB_ENTERPRISE_HOSTNAME= +WHATSUP_ENTERPRISE_ACCESS_TOKEN= ``` -Example: +### With environment variables -```config -machine api.github.com - login mypubliclogin - password y9o6YvEoa7IukRWUFdnkpuxNjJ3uwiDQp4zkAdU0 +```bash +WHATSUP_GITHUB_ACCESS_TOKEN=askk494nmfodic68mk whatsup_github since 'apr 2' ``` -Example with GitHub Enterprise: +`WHATSUP_GITHUB_ENTERPRISE_HOSTNAME` sets the hostname for GitHub Enterprise Server +(e.g. `git.example.com`). Optional for GHEC — defaults to `github.com`. -```config -machine api.github.com - login mypubliclogin - password y9o6YvEoa7IukRWUFdnkpuxNjJ3uwiDQp4zkAdU0 +`WHATSUP_ENTERPRISE_ACCESS_TOKEN` is used for repos prefixed with `enterprise:` in +`.whatsup.yml`. Useful when you need two separate accounts — for example, a public +GitHub account and a GHEC org account. -machine git.enterprise.example.com - login myenterpriselogin - password GtH7yhvEoa7Iuksdo&TFuxNjJ3uwiDQhjbiu8&yhJhG -``` +### With the .netrc file -### With an environment variable +Use [`~/.netrc`](https://github.com/octokit/octokit.rb#using-a-netrc-file) for +authentication. See [`.netrc.example`](.netrc.example) for the format. -Assign the `WHATSUP_GITHUB_ACCESS_TOKEN` to the GitHub token you want to use, prior the `whatsup_github` command. +```config +machine api.github.com + login + password +``` -Example: +For GitHub Enterprise Server (self-hosted), add a second entry using the server hostname: -```bash -WHATSUP_GITHUB_ACCESS_TOKEN=askk494nmfodic68mk whatsup_github since 'apr 2' +```config +machine git.enterprise.example.com + login + password ``` +> **Note:** `.netrc` only supports one entry per host. If you need two different +> accounts on `api.github.com` (e.g., public GitHub + GHEC), use `.env` or +> environment variables instead. + ## Usage ```bash whatsup_github since 'apr 2' ``` -If run with no arguments, it generates data for the past week: +To use the default date range of the past 7 days, omit the date: ```bash -whatsup_github +whatsup_github since ``` You can use different date formats like `'April 2'`, `'2 April'`, `'apr 2'`, `'2 Apr'`, `2018-04-02`. @@ -188,7 +195,8 @@ 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](https://app.cucumber.pro/p/af1681aa-415f-44f0-8260-5454a69c472a/aruba/documents/branch/master/features/03_testing_frameworks/cucumber/steps/filesystem/check_existence_of_file.feature) tests in `features`. +The project contains [rspec](https://rspec.info/) tests in `spec` and +cucumber tests in `features`. #### specs @@ -233,9 +241,45 @@ ruby lib/whatsup_github/config_reader.rb The tests use the root `.whatsup.yml` file to read configuration. +### Local testing against live GitHub data + +The tool makes live GitHub API calls, so end-to-end local testing requires +real credentials and a real repository. + +#### Set up credentials + +Copy `.env.example` to `.env` and fill in your tokens: + +```bash +cp .env.example .env +``` + +#### Use a narrow date range + +A short window keeps the result set small and avoids burning API rate limits: + +```bash +bundle exec whatsup_github since 'yesterday' +``` + +#### Check the output + +Results are written to `tmp/whats-new.yml`. The `tmp/` directory is +gitignored, so test output won't be accidentally committed. + +#### Debug search queries + +To inspect the exact GitHub search queries being sent, run with `DEBUG=1`: + +```bash +DEBUG=1 bundle exec whatsup_github since 'yesterday' +``` + ## Contributing -Bug reports and pull requests are welcome. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. +Bug reports and pull requests are welcome. This project is intended to be a safe, +welcoming space for collaboration, and contributors are expected to adhere to the +[Contributor Covenant](http://contributor-covenant.org) code of conduct. ## License From c15b0b6ff4d191edb8fc267ba3bb51ce87c285ce Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Fri, 10 Apr 2026 20:05:16 -0500 Subject: [PATCH 09/12] chore: update .gitignore and bump Ruby to 3.4.8 - Replace credentials.yml with .env in .gitignore; add .cursor/ - Bump .ruby-version from 3.3.0 to 3.4.8 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 +++-- .ruby-version | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 90f9f39..e1f68f9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,8 @@ .rspec_status -/credentials.yml +/.env /.whatsup.yml /output/ -*.gem \ No newline at end of file +*.gem +.cursor/ diff --git a/.ruby-version b/.ruby-version index 03463f3..a3808a4 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-3.3.0 +ruby-3.4.8 From 54b031c0b19220100c2d0249eb7d8ab0e344723d Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Fri, 10 Apr 2026 20:05:35 -0500 Subject: [PATCH 10/12] release: v2.0.0 Bump version to 2.0.0 (breaking changes: membership field removed, enterprise hostname moved to env var, enterprise link format changed). Update repo URLs to commerce-docs organization. Update CHANGELOG. Update all dependencies to latest compatible versions. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 27 +++++++++++ Gemfile.lock | 90 ++++++++++++++++++----------------- lib/whatsup_github/version.rb | 2 +- whatsup_github.gemspec | 7 +-- 4 files changed, 78 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ab6ab6..9ace43f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 2.0.0 + +### Breaking changes + +- Removed `membership` field from YAML output and configuration — the org-membership check has been removed entirely +- Enterprise hostname must now be set via the `WHATSUP_GITHUB_ENTERPRISE_HOSTNAME` environment variable instead of the `enterprise` key in `.whatsup.yml`, so the config file is safe to commit to git +- Enterprise PR links in generated output now use the format `enterprise:org/repo/pull/N` instead of the internal hostname URL, hiding the private hostname from committed files + +### New features + +- GitHub Enterprise Cloud (GHEC) support — `WHATSUP_GITHUB_ENTERPRISE_HOSTNAME` defaults to `github.com` (GHEC) when unset +- PR data fetching replaced N+1 REST calls with a single GraphQL `nodes(ids: [...])` batch query for a significant performance improvement +- Query logging is now opt-in: set `DEBUG=1` to print GitHub search queries to stderr +- Unauthenticated mode now warns at startup and reminds how to set credentials +- `.netrc` file permission check warns when the file is world-readable + +### Security fixes + +- YAML configuration is now loaded with `safe_load` to prevent arbitrary object deserialization +- Config file path validated against path traversal (`..` and absolute paths rejected) +- Enterprise hostname validated against an allowlist regex; private/internal IP ranges are blocked +- `.netrc` path resolved with `File.expand_path('~/.netrc')` for correctness in containerized environments + +### Maintenance + +- Updated all dependencies to their latest compatible versions + ## 1.2.0 Maintenance: diff --git a/Gemfile.lock b/Gemfile.lock index f955501..8d6c49e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,8 @@ PATH remote: . specs: - whatsup_github (1.2.0) + whatsup_github (2.0.0) + dotenv (~> 3.0) netrc (~> 0.11) octokit (~> 10.0) thor (~> 1.3) @@ -9,89 +10,90 @@ PATH GEM remote: https://rubygems.org/ specs: - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) - aruba (2.3.1) - bundler (>= 1.17, < 3.0) + 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) + rspec-expectations (>= 3.4, < 5.0) thor (~> 1.0) base64 (0.3.0) - bigdecimal (3.2.2) + bigdecimal (4.1.1) builder (3.3.0) - contracts (0.17.2) - cucumber (10.1.0) + contracts (0.17.3) + cucumber (10.2.0) base64 (~> 0.2) builder (~> 3.2) - cucumber-ci-environment (> 9, < 11) + cucumber-ci-environment (> 9, < 12) cucumber-core (> 15, < 17) - cucumber-cucumber-expressions (> 17, < 19) - cucumber-html-formatter (> 20.3, < 22) + 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 (10.0.1) - cucumber-core (15.2.1) - cucumber-gherkin (> 27, < 33) - cucumber-messages (> 26, < 30) - cucumber-tag-expressions (> 5, < 7) - cucumber-cucumber-expressions (18.0.1) + 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 (32.2.0) - cucumber-messages (> 25, < 28) - cucumber-html-formatter (21.14.0) - cucumber-messages (> 19, < 28) - cucumber-messages (27.2.0) - cucumber-tag-expressions (6.1.2) + 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) - faraday (2.13.4) + dotenv (3.2.0) + faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json logger - faraday-net_http (3.4.1) - net-http (>= 0.5.0) - faraday-retry (2.3.2) + faraday-net_http (3.4.2) + net-http (~> 0.5) + faraday-retry (2.4.0) faraday (~> 2.0) - ffi (1.17.2) - ffi (1.17.2-arm64-darwin) - fileutils (1.7.3) - json (2.13.2) + ffi (1.17.4) + ffi (1.17.4-arm64-darwin) + fileutils (1.8.0) + json (2.19.3) logger (1.7.0) memoist3 (1.0.0) mini_mime (1.1.5) multi_test (1.1.0) - net-http (0.6.0) - uri + net-http (0.9.1) + uri (>= 0.11.1) netrc (0.11.0) octokit (10.0.0) faraday (>= 1, < 3) sawyer (~> 0.9) - public_suffix (6.0.2) - rake (13.3.0) - rspec (3.13.1) + public_suffix (7.0.5) + rake (13.3.1) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.5) + rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.5) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.5) - sawyer (0.9.2) + rspec-support (3.13.7) + sawyer (0.9.3) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) - sys-uname (1.4.1) + sys-uname (1.5.1) ffi (~> 1.1) memoist3 (~> 1.0.0) - thor (1.4.0) - uri (1.0.3) + thor (1.5.0) + uri (1.1.1) PLATFORMS arm64-darwin-22 diff --git a/lib/whatsup_github/version.rb b/lib/whatsup_github/version.rb index 6b8de13..729917a 100644 --- a/lib/whatsup_github/version.rb +++ b/lib/whatsup_github/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module WhatsupGithub - VERSION = '1.2.0' + VERSION = '2.0.0' end diff --git a/whatsup_github.gemspec b/whatsup_github.gemspec index 17d697f..436d93c 100644 --- a/whatsup_github.gemspec +++ b/whatsup_github.gemspec @@ -11,15 +11,15 @@ Gem::Specification.new do |spec| spec.email = ['shevtsov@adobe.com'] spec.summary = 'Collect info from GitHub pull requests.' - spec.homepage = 'https://github.com/dshevtsov/whatsup_github' + spec.homepage = 'https://github.com/commerce-docs/whatsup_github' spec.license = 'MIT' # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' # to allow pushing to a single host or delete this section to allow pushing to any host. if spec.respond_to?(:metadata) spec.metadata['homepage_uri'] = spec.homepage - spec.metadata['source_code_uri'] = 'https://github.com/dshevtsov/whatsup_github' - spec.metadata['changelog_uri'] = 'https://github.com/dshevtsov/whatsup_github/blob/master/CHANGELOG.md' + spec.metadata['source_code_uri'] = 'https://github.com/commerce-docs/whatsup_github' + spec.metadata['changelog_uri'] = 'https://github.com/commerce-docs/whatsup_github/blob/main/CHANGELOG.md' else raise 'RubyGems 2.0 or newer is required to protect against ' \ 'public gem pushes.' @@ -36,6 +36,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = '>= 3.0' + spec.add_dependency 'dotenv', '~> 3.0' spec.add_dependency 'netrc', '~> 0.11' spec.add_dependency 'octokit', '~> 10.0' spec.add_dependency 'thor', '~> 1.3' From b6d660ec955f3ca49053a895e341662876dc64a5 Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Fri, 10 Apr 2026 20:21:00 -0500 Subject: [PATCH 11/12] style: use Ruby 3.1 shorthand hash syntax in Pulls.new call Co-Authored-By: Claude Sonnet 4.6 --- lib/whatsup_github/row_collector.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/whatsup_github/row_collector.rb b/lib/whatsup_github/row_collector.rb index 06c8898..cb94f4b 100755 --- a/lib/whatsup_github/row_collector.rb +++ b/lib/whatsup_github/row_collector.rb @@ -66,7 +66,7 @@ def label_names(labels) end def pulls(repo) - Pulls.new(repo: repo, since: since).data + Pulls.new(repo:, since:).data end def config From b7c50b7e6e0df71a2ae7aafa8ba2c44fa6ec8166 Mon Sep 17 00:00:00 2001 From: Dima Shevtsov Date: Mon, 13 Apr 2026 12:13:24 -0500 Subject: [PATCH 12/12] chore: promote faraday-retry to runtime dependency - Added `faraday-retry` as a runtime dependency in the gemspec to resolve missing-gem warnings for consumers. - Updated CHANGELOG to reflect this bug fix. --- CHANGELOG.md | 4 ++++ Gemfile.lock | 2 +- whatsup_github.gemspec | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ace43f..4edf07f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ - Enterprise hostname validated against an allowlist regex; private/internal IP ranges are blocked - `.netrc` path resolved with `File.expand_path('~/.netrc')` for correctness in containerized environments +### Bug fixes + +- `faraday-retry` promoted from development to runtime dependency — fixes missing-gem warning for consumers + ### Maintenance - Updated all dependencies to their latest compatible versions diff --git a/Gemfile.lock b/Gemfile.lock index 8d6c49e..62a8053 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: whatsup_github (2.0.0) dotenv (~> 3.0) + faraday-retry (~> 2.2) netrc (~> 0.11) octokit (~> 10.0) thor (~> 1.3) @@ -103,7 +104,6 @@ DEPENDENCIES aruba (~> 2.2) bundler (~> 2.5) cucumber (~> 10.1) - faraday-retry (~> 2.2) fileutils (~> 1.7) rake (~> 13.1) rspec (~> 3.12) diff --git a/whatsup_github.gemspec b/whatsup_github.gemspec index 436d93c..247cc7e 100644 --- a/whatsup_github.gemspec +++ b/whatsup_github.gemspec @@ -37,6 +37,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = '>= 3.0' spec.add_dependency 'dotenv', '~> 3.0' + spec.add_dependency 'faraday-retry', '~> 2.2' spec.add_dependency 'netrc', '~> 0.11' spec.add_dependency 'octokit', '~> 10.0' spec.add_dependency 'thor', '~> 1.3' @@ -47,5 +48,4 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'rake', '~> 13.1' spec.add_development_dependency 'rspec', '~> 3.12' spec.add_development_dependency 'fileutils', '~> 1.7' - spec.add_development_dependency 'faraday-retry', '~> 2.2' end