obituary 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4f4cdf9f9835202c98cc63e88ad67118e118452dcbc685377300db5f931758ba
4
+ data.tar.gz: 381d5992bc0e4450700095fef0a967f951adbceee8328e6f8a3d7f504d81657d
5
+ SHA512:
6
+ metadata.gz: 865223eb644a0a2cd994d885b521d747d051b04c20c177898278f8cd80e920d22fe1ef55705617289468b3e03ac3b14ecde25d948855e8030ce909ac701afa12
7
+ data.tar.gz: 0dfe545e0847c6233217c133f563204b4bf866be060c9e04a8194ed6d6ab8c306353b58d06b8e2d9a4b2f1f6d7858e2f9ff1b01f4ea03db07c1055cc81d08c04
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # Obituary
2
+
3
+ Obituary detects archived GitHub repositories for your RubyGem dependencies. It can be used as a CLI command in CI or as an RSpec matcher to keep dependency health visible.
4
+
5
+ ## Installation
6
+
7
+ Install the gem and add to the application's Gemfile by executing:
8
+
9
+ ```bash
10
+ bundle add obituary
11
+ ```
12
+
13
+ If bundler is not being used to manage dependencies, install the gem by executing:
14
+
15
+ ```bash
16
+ gem install obituary
17
+ ```
18
+
19
+ ## Setup
20
+
21
+ Set your GitHub token in the environment so the GitHub API can be queried.
22
+
23
+ ```bash
24
+ export GITHUB_TOKEN=your_token_here
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ Obituary reads `.obituary.yml` from the project root by default.
30
+
31
+ ```yaml
32
+ include_transitive: false
33
+ github_token_env: "GITHUB_TOKEN"
34
+ ignore:
35
+ - some_archived_but_ok_gem
36
+ overrides:
37
+ some_gem: "https://github.com/owner/repo"
38
+ ```
39
+
40
+ ## Usage (CLI)
41
+
42
+ ```bash
43
+ bundle exec obituary
44
+ bundle exec obituary --include-transitive
45
+ bundle exec obituary --format json
46
+ ```
47
+
48
+ ## Usage (RSpec)
49
+
50
+ ```ruby
51
+ require "obituary/rspec"
52
+
53
+ RSpec.describe "Dependency health" do
54
+ it "does not depend on archived gems" do
55
+ expect(:bundle).not_to have_archived_gems
56
+ end
57
+ end
58
+ ```
59
+
60
+ ## CI Example
61
+
62
+ ```yaml
63
+ name: Dependency Health Check
64
+ on:
65
+ schedule:
66
+ - cron: '0 9 * * 1'
67
+ workflow_dispatch:
68
+
69
+ jobs:
70
+ check-archived:
71
+ runs-on: ubuntu-latest
72
+ steps:
73
+ - uses: actions/checkout@v4
74
+ - uses: ruby/setup-ruby@v1
75
+ with:
76
+ bundler-cache: true
77
+ - run: gem install obituary
78
+ - run: obituary check
79
+ env:
80
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
81
+ ```
82
+
83
+ ## Limitations
84
+
85
+ - Only GitHub repositories are supported.
86
+ - Private repositories require a token with access.
87
+
88
+ ## Development
89
+
90
+ After checking out the repo, run `bundle exec rspec` to run the tests.
91
+
92
+ ## License
93
+
94
+ The gem is available as open source under the terms of the MIT License.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/exe/obituary ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'obituary'
5
+
6
+ Obituary::CLI.start(ARGV)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ module Obituary
8
+ class ArchiveChecker
9
+ API_ENDPOINT = 'https://api.github.com/repos'
10
+ WARNING_THRESHOLD = 100
11
+
12
+ def initialize(github_token:)
13
+ @github_token = github_token
14
+ end
15
+
16
+ def archived?(owner_repo)
17
+ url = URI.join("#{API_ENDPOINT}/", owner_repo)
18
+ request = Net::HTTP::Get.new(url)
19
+ request['Authorization'] = "Bearer #{@github_token}"
20
+ request['Accept'] = 'application/vnd.github.v3+json'
21
+
22
+ response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
23
+ http.request(request)
24
+ end
25
+
26
+ warn_if_rate_limit_low(response)
27
+
28
+ case response
29
+ when Net::HTTPSuccess
30
+ data = JSON.parse(response.body)
31
+ data.fetch('archived', nil)
32
+ when Net::HTTPNotFound
33
+ warn("Repository not found for #{owner_repo}")
34
+ nil
35
+ when Net::HTTPUnauthorized
36
+ raise Obituary::Error, 'GitHub token unauthorized'
37
+ when Net::HTTPForbidden
38
+ raise Obituary::Error, 'GitHub API rate limit exceeded'
39
+ else
40
+ warn("GitHub API error for #{owner_repo}: #{response.code}")
41
+ nil
42
+ end
43
+ rescue JSON::ParserError
44
+ warn("Unable to parse GitHub response for #{owner_repo}")
45
+ nil
46
+ end
47
+
48
+ private
49
+
50
+ def warn_if_rate_limit_low(response)
51
+ remaining = response['X-RateLimit-Remaining']
52
+ return unless remaining
53
+
54
+ remaining_value = remaining.to_i
55
+ return if remaining_value > WARNING_THRESHOLD
56
+
57
+ warn("GitHub rate limit low: #{remaining_value} remaining")
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'optparse'
5
+
6
+ module Obituary
7
+ class CLI
8
+ DEFAULT_CONFIG = '.obituary.yml'
9
+
10
+ def self.start(argv)
11
+ new.start(argv)
12
+ end
13
+
14
+ def start(argv)
15
+ summary = run(argv)
16
+ exit(summary)
17
+ rescue SystemExit
18
+ raise
19
+ rescue StandardError => e
20
+ warn(e.message)
21
+ exit(2)
22
+ end
23
+
24
+ def run(argv)
25
+ options = {
26
+ include_transitive: nil,
27
+ config: DEFAULT_CONFIG,
28
+ format: 'text',
29
+ no_fail: false
30
+ }
31
+
32
+ parser = OptionParser.new do |opts|
33
+ opts.banner = 'Usage: obituary check [options]'
34
+ opts.on('--include-transitive', 'Include transitive dependencies') { options[:include_transitive] = true }
35
+ opts.on('--config PATH', 'Path to config file') { |value| options[:config] = value }
36
+ opts.on('--format FORMAT', 'Output format: text or json') { |value| options[:format] = value }
37
+ opts.on('--no-fail', 'Always return exit code 0') { options[:no_fail] = true }
38
+ end
39
+
40
+ args = argv.dup
41
+ if args.first == 'check'
42
+ args.shift
43
+ end
44
+
45
+ parser.parse!(args)
46
+
47
+ config = Configuration.load(path: options[:config])
48
+ config.include_transitive = options[:include_transitive] unless options[:include_transitive].nil?
49
+
50
+ summary = Runner.new(config: config).run
51
+
52
+ output(summary, options[:format])
53
+
54
+ summary.passed? || options[:no_fail] ? 0 : 1
55
+ end
56
+
57
+ private
58
+
59
+ def output(summary, format)
60
+ case format
61
+ when 'json'
62
+ payload = {
63
+ archived: format_results(summary.archived_gems),
64
+ active: format_results(summary.active_gems),
65
+ unresolved: format_results(summary.unresolved_gems),
66
+ passed: summary.passed?
67
+ }
68
+ puts JSON.pretty_generate(payload)
69
+ else
70
+ puts "obituary - Checking #{summary.results.size} gems for archived repositories..."
71
+ puts ''
72
+
73
+ if summary.archived_gems.any?
74
+ puts "\e[31m✗ ARCHIVED:\e[0m"
75
+ summary.archived_gems.each do |result|
76
+ puts " - #{result.gem_name} (https://github.com/#{result.repository})"
77
+ end
78
+ puts ''
79
+ end
80
+
81
+ if summary.unresolved_gems.any?
82
+ puts "\e[33m⚠ UNRESOLVED (repository not found):\e[0m"
83
+ summary.unresolved_gems.each do |result|
84
+ puts " - #{result.gem_name}"
85
+ end
86
+ puts ''
87
+ end
88
+
89
+ ok_count = summary.active_gems.count
90
+ archived_count = summary.archived_gems.count
91
+ unresolved_count = summary.unresolved_gems.count
92
+ puts "\e[32m✓ #{ok_count} gems OK, #{archived_count} archived, #{unresolved_count} unresolved\e[0m"
93
+
94
+ if archived_count.positive?
95
+ puts ''
96
+ puts "FAILED: #{archived_count} archived gems detected."
97
+ end
98
+ end
99
+ end
100
+
101
+ def format_results(results)
102
+ results.map do |result|
103
+ { gem: result.gem_name, repository: result.repository, archived: result.archived, error: result.error }
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Obituary
6
+ class Configuration
7
+ DEFAULT_INCLUDE_TRANSITIVE = false
8
+ DEFAULT_GITHUB_TOKEN_ENV = 'GITHUB_TOKEN'
9
+
10
+ attr_accessor :include_transitive, :github_token_env, :ignore, :overrides
11
+
12
+ def self.load(path: '.obituary.yml')
13
+ return new unless File.exist?(path)
14
+
15
+ raw = YAML.safe_load_file(path, aliases: false)
16
+ data = (raw || {}).transform_keys(&:to_sym)
17
+
18
+ new(
19
+ include_transitive: data.fetch(:include_transitive, DEFAULT_INCLUDE_TRANSITIVE),
20
+ github_token_env: data.fetch(:github_token_env, DEFAULT_GITHUB_TOKEN_ENV),
21
+ ignore: Array(data[:ignore]),
22
+ overrides: data.fetch(:overrides, {})
23
+ )
24
+ end
25
+
26
+ def initialize(
27
+ include_transitive: DEFAULT_INCLUDE_TRANSITIVE,
28
+ github_token_env: DEFAULT_GITHUB_TOKEN_ENV,
29
+ ignore: [],
30
+ overrides: {}
31
+ )
32
+ @include_transitive = include_transitive
33
+ @github_token_env = github_token_env
34
+ @ignore = ignore
35
+ @overrides = overrides
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+
5
+ module Obituary
6
+ class DependencyResolver
7
+ def resolve(include_transitive:)
8
+ gems = if include_transitive
9
+ Bundler.definition.specs.map(&:name)
10
+ else
11
+ Bundler.definition.dependencies.map(&:name)
12
+ end
13
+
14
+ gems.reject { |name| name == 'bundler' }.uniq.sort
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ module Obituary
8
+ class RepositoryFinder
9
+ RUBYGEMS_API = 'https://rubygems.org/api/v1/gems'
10
+ GITHUB_REGEX = %r{github\.com[/:]([^/]+)/([^/.]+)}
11
+
12
+ def initialize(config: Obituary.configuration)
13
+ @config = config
14
+ @cache = {}
15
+ end
16
+
17
+ def find(gem_name)
18
+ return @cache[gem_name] if @cache.key?(gem_name)
19
+
20
+ override = @config.overrides[gem_name]
21
+ if override
22
+ @cache[gem_name] = parse_github_repo(override)
23
+ return @cache[gem_name]
24
+ end
25
+
26
+ data = fetch_rubygems(gem_name)
27
+ if data
28
+ repo = parse_github_repo(data['source_code_uri']) ||
29
+ parse_github_repo(data['homepage_uri'])
30
+ return @cache[gem_name] = repo
31
+ end
32
+
33
+ @cache[gem_name] = parse_git_source_from_lock(gem_name)
34
+ end
35
+
36
+ private
37
+
38
+ def fetch_rubygems(gem_name)
39
+ url = URI.join("#{RUBYGEMS_API}/", "#{gem_name}.json")
40
+ response = Net::HTTP.get_response(url)
41
+ return nil unless response.is_a?(Net::HTTPSuccess)
42
+
43
+ JSON.parse(response.body)
44
+ rescue JSON::ParserError, URI::InvalidURIError
45
+ nil
46
+ end
47
+
48
+ def parse_git_source_from_lock(gem_name)
49
+ lock_path = Bundler.default_lockfile
50
+ return nil unless File.exist?(lock_path)
51
+
52
+ content = File.read(lock_path)
53
+ content.split(/\n\n+/).each do |section|
54
+ next unless section.start_with?("GIT\n")
55
+
56
+ remote = section.lines.find { |line| line.strip.start_with?('remote:') }
57
+ next unless remote
58
+
59
+ repo = parse_github_repo(remote.split(':', 2).last.to_s.strip)
60
+ next unless repo
61
+
62
+ gems_section = section.split("\n\n").last
63
+ next unless gems_section
64
+
65
+ return repo if gems_section.lines.any? { |line| line.strip.start_with?("#{gem_name} (") }
66
+ end
67
+
68
+ nil
69
+ end
70
+
71
+ def parse_github_repo(url)
72
+ return nil if url.nil? || url.strip.empty?
73
+
74
+ match = url.match(GITHUB_REGEX)
75
+ return nil unless match
76
+
77
+ owner = match[1]
78
+ repo = match[2]
79
+ "#{owner}/#{repo}"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Obituary
4
+ class Result
5
+ attr_reader :gem_name, :repository, :archived, :error
6
+
7
+ def initialize(gem_name:, repository:, archived:, error: nil)
8
+ @gem_name = gem_name
9
+ @repository = repository
10
+ @archived = archived
11
+ @error = error
12
+ end
13
+ end
14
+
15
+ class CheckSummary
16
+ attr_reader :results
17
+
18
+ def initialize(results)
19
+ @results = results
20
+ end
21
+
22
+ def archived_gems
23
+ results.select { |result| result.archived == true }
24
+ end
25
+
26
+ def active_gems
27
+ results.select { |result| result.archived == false }
28
+ end
29
+
30
+ def unresolved_gems
31
+ results.select { |result| result.repository.nil? }
32
+ end
33
+
34
+ def passed?
35
+ archived_gems.empty?
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/expectations'
4
+
5
+ module Obituary
6
+ module RSpec
7
+ module Matchers
8
+ ::RSpec::Matchers.define :have_archived_gems do |options = {}|
9
+ supports_block_expectations
10
+ match do |_|
11
+ config = Obituary.configuration
12
+ config = Configuration.new(
13
+ include_transitive: config.include_transitive,
14
+ github_token_env: config.github_token_env,
15
+ ignore: config.ignore,
16
+ overrides: config.overrides
17
+ )
18
+
19
+ config.include_transitive = options[:include_transitive] if options.key?(:include_transitive)
20
+
21
+ @summary = Runner.new(config: config).run
22
+ @summary.archived_gems.any?
23
+ end
24
+
25
+ failure_message_when_negated do |_|
26
+ archived = @summary.archived_gems
27
+ lines = archived.map { |result| " - #{result.gem_name} (#{result.repository})" }
28
+ "Expected no archived gems, but found #{archived.size}:\n#{lines.join("\n")}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rspec/matchers'
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Obituary
4
+ class Runner
5
+ def initialize(config: Obituary.configuration)
6
+ @config = config
7
+ end
8
+
9
+ def run
10
+ token = ENV.fetch(@config.github_token_env) do
11
+ raise Obituary::Error, "Missing GitHub token in #{@config.github_token_env}"
12
+ end
13
+
14
+ resolver = DependencyResolver.new
15
+ finder = RepositoryFinder.new(config: @config)
16
+ checker = ArchiveChecker.new(github_token: token)
17
+
18
+ gems = resolver.resolve(include_transitive: @config.include_transitive)
19
+ gems = gems.reject { |name| @config.ignore.include?(name) }
20
+
21
+ results = gems.map do |gem_name|
22
+ repository = finder.find(gem_name)
23
+ archived = repository ? checker.archived?(repository) : nil
24
+ Result.new(gem_name: gem_name, repository: repository, archived: archived)
25
+ rescue StandardError => e
26
+ Result.new(gem_name: gem_name, repository: nil, archived: nil, error: e.message)
27
+ end
28
+
29
+ CheckSummary.new(results)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Obituary
4
+ VERSION = '0.1.0'
5
+ end
data/lib/obituary.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'obituary/version'
4
+ require_relative 'obituary/configuration'
5
+ require_relative 'obituary/dependency_resolver'
6
+ require_relative 'obituary/repository_finder'
7
+ require_relative 'obituary/archive_checker'
8
+ require_relative 'obituary/result'
9
+ require_relative 'obituary/runner'
10
+ require_relative 'obituary/cli'
11
+ require_relative 'obituary/rspec'
12
+
13
+ module Obituary
14
+ class Error < StandardError; end
15
+
16
+ class << self
17
+ def configure
18
+ yield(configuration)
19
+ end
20
+
21
+ def configuration
22
+ @configuration ||= Configuration.load
23
+ end
24
+ end
25
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: obituary
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bundler
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ description: Checks Gem dependencies against GitHub and reports archived repositories.
27
+ email:
28
+ - t.yudai92@gmail.com
29
+ executables:
30
+ - obituary
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE.txt
35
+ - README.md
36
+ - Rakefile
37
+ - exe/obituary
38
+ - lib/obituary.rb
39
+ - lib/obituary/archive_checker.rb
40
+ - lib/obituary/cli.rb
41
+ - lib/obituary/configuration.rb
42
+ - lib/obituary/dependency_resolver.rb
43
+ - lib/obituary/repository_finder.rb
44
+ - lib/obituary/result.rb
45
+ - lib/obituary/rspec.rb
46
+ - lib/obituary/rspec/matchers.rb
47
+ - lib/obituary/runner.rb
48
+ - lib/obituary/version.rb
49
+ homepage: https://github.com/ydah/obituary
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ allowed_push_host: https://rubygems.org
54
+ homepage_uri: https://github.com/ydah/obituary
55
+ source_code_uri: https://github.com/ydah/obituary
56
+ rubygems_mfa_required: 'true'
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.1.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 4.0.6
72
+ specification_version: 4
73
+ summary: Detect archived GitHub repositories for RubyGem dependencies.
74
+ test_files: []