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/.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/.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/.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/.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ab6ab6..4edf07f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # 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 + +### 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 + ## 1.2.0 Maintenance: diff --git a/Gemfile.lock b/Gemfile.lock index f955501..62a8053 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,9 @@ PATH remote: . specs: - whatsup_github (1.2.0) + whatsup_github (2.0.0) + dotenv (~> 3.0) + faraday-retry (~> 2.2) netrc (~> 0.11) octokit (~> 10.0) thor (~> 1.3) @@ -9,89 +11,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 @@ -101,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/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 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/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` 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 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 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 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 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/row_collector.rb b/lib/whatsup_github/row_collector.rb index c093f8f..cb94f4b 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,37 +61,16 @@ 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 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) + Pulls.new(repo:, since:).data end def config Config.instance end - - def client - Client.instance - end end end 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/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 diff --git a/whatsup_github.gemspec b/whatsup_github.gemspec index 17d697f..247cc7e 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,8 @@ 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' @@ -46,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