gem-contribute 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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.github/ISSUE_TEMPLATE/workshop-issue.md +29 -0
  3. data/.github/workflows/auto-merge-kicked-tires.yml +88 -0
  4. data/CHANGELOG.md +24 -0
  5. data/CLAUDE.md +47 -0
  6. data/CONTRIBUTING.md +46 -0
  7. data/KICKED_THE_TIRES.yml +22 -0
  8. data/LICENSE +21 -0
  9. data/MAINTAINER.md +92 -0
  10. data/README.md +89 -0
  11. data/Rakefile +10 -0
  12. data/docs/_config.yml +30 -0
  13. data/docs/adr/0001-just-in-time-auth.md +44 -0
  14. data/docs/adr/0002-bundler-lockfile-parser.md +35 -0
  15. data/docs/adr/0003-issue-tracker-preference.md +33 -0
  16. data/docs/adr/0004-device-flow-auth.md +36 -0
  17. data/docs/adr/0005-render-labels-verbatim.md +46 -0
  18. data/docs/adr/0006-standalone-gem-not-plugin.md +31 -0
  19. data/docs/adr/0007-display-contributing-verbatim.md +39 -0
  20. data/docs/adr/0008-rooibos-tui-framework.md +62 -0
  21. data/docs/adr/0009-top-level-namespace.md +37 -0
  22. data/docs/adr/README.md +21 -0
  23. data/docs/claude-code-prompt.md +40 -0
  24. data/docs/design.md +234 -0
  25. data/docs/index.md +102 -0
  26. data/docs/prep-plan.md +165 -0
  27. data/docs/workshop.md +60 -0
  28. data/exe/gem-contribute +7 -0
  29. data/lib/gem_contribute/auth.rb +161 -0
  30. data/lib/gem_contribute/cache.rb +98 -0
  31. data/lib/gem_contribute/cli/auth.rb +164 -0
  32. data/lib/gem_contribute/cli/config.rb +87 -0
  33. data/lib/gem_contribute/cli/fork_clone_branch.rb +197 -0
  34. data/lib/gem_contribute/cli/issues.rb +123 -0
  35. data/lib/gem_contribute/cli/scan.rb +117 -0
  36. data/lib/gem_contribute/cli/submit.rb +155 -0
  37. data/lib/gem_contribute/cli.rb +104 -0
  38. data/lib/gem_contribute/config.rb +60 -0
  39. data/lib/gem_contribute/errors.rb +32 -0
  40. data/lib/gem_contribute/host_adapter.rb +40 -0
  41. data/lib/gem_contribute/host_adapters/github_adapter.rb +215 -0
  42. data/lib/gem_contribute/locked_gem.rb +26 -0
  43. data/lib/gem_contribute/lockfile_parser.rb +61 -0
  44. data/lib/gem_contribute/project.rb +21 -0
  45. data/lib/gem_contribute/resolver.rb +131 -0
  46. data/lib/gem_contribute/token_store.rb +86 -0
  47. data/lib/gem_contribute/version.rb +5 -0
  48. data/lib/gem_contribute.rb +32 -0
  49. data/script/lint-kicked-tires.rb +76 -0
  50. data/sig/gem_contribute.rbs +3 -0
  51. metadata +114 -0
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemContribute
4
+ # A gem resolved to a host repository.
5
+ #
6
+ # `host` is one of: "github.com", "gitlab.com", "codeberg.org", or :unknown.
7
+ # When :unknown, owner/repo are nil and `metadata` may carry whatever URI we
8
+ # found so the user can at least see it.
9
+ Project = Data.define(:gem_name, :host, :owner, :repo, :metadata) do
10
+ def known_host?
11
+ host.is_a?(String)
12
+ end
13
+
14
+ def url
15
+ return metadata[:source_url] if metadata && !known_host?
16
+ return nil unless owner && repo
17
+
18
+ "https://#{host}/#{owner}/#{repo}"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module GemContribute
8
+ # Resolves a LockedGem to a Project (host + owner + repo) by querying the
9
+ # RubyGems v1 API and walking the metadata URIs in preference order.
10
+ #
11
+ # Preference order (ADR-0003):
12
+ # bug_tracker_uri → source_code_uri → homepage_uri
13
+ #
14
+ # Recognized hosts: github.com, gitlab.com, codeberg.org. Anything else
15
+ # (mailing-list bug tracker, internal bugzilla, etc.) is stored as the
16
+ # `:source_url` in metadata so the user can at least see it.
17
+ class Resolver
18
+ API_BASE = "https://rubygems.org/api/v1/gems"
19
+ KNOWN_HOSTS = %w[github.com gitlab.com codeberg.org].freeze
20
+
21
+ # Reasons a resolve might come back without a host coordinate. Surfaced as
22
+ # `metadata[:reason]` on the returned Project so the CLI/TUI can show the
23
+ # user *why* a gem wasn't actionable.
24
+ REASON_NON_RUBYGEMS_SOURCE = :non_rubygems_source
25
+ REASON_API_NOT_FOUND = :api_not_found
26
+ REASON_NO_USABLE_URI = :no_usable_uri
27
+ REASON_UNKNOWN_HOST = :unknown_host
28
+
29
+ def initialize(cache: Cache.new, http: Net::HTTP, clock: -> { Time.now.to_i })
30
+ @cache = cache
31
+ @http = http
32
+ @clock = clock
33
+ end
34
+
35
+ # @param gem [LockedGem]
36
+ # @return [Project]
37
+ def resolve(gem)
38
+ return unresolved(gem, REASON_NON_RUBYGEMS_SOURCE) unless gem.resolvable?
39
+
40
+ metadata = fetch_metadata(gem)
41
+ return unresolved(gem, REASON_API_NOT_FOUND) if metadata.nil?
42
+
43
+ uri = preferred_uri(metadata)
44
+ return unresolved(gem, REASON_NO_USABLE_URI) if uri.nil?
45
+
46
+ coords = parse_host_coordinates(uri)
47
+ return unresolved(gem, REASON_UNKNOWN_HOST, source_url: uri) if coords.nil?
48
+
49
+ Project.new(
50
+ gem_name: gem.name,
51
+ host: coords[:host],
52
+ owner: coords[:owner],
53
+ repo: coords[:repo],
54
+ metadata: { source_url: uri, picked_from: coords[:picked_from] }
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ def unresolved(gem, reason, **extras)
61
+ Project.new(
62
+ gem_name: gem.name,
63
+ host: :unknown,
64
+ owner: nil,
65
+ repo: nil,
66
+ metadata: { reason: reason, **extras }
67
+ )
68
+ end
69
+
70
+ def fetch_metadata(gem)
71
+ cached = @cache.fetch("gems", gem.name)
72
+ return cached if cached
73
+
74
+ response = http_get("#{API_BASE}/#{gem.name}.json")
75
+ case response
76
+ when Net::HTTPSuccess
77
+ @cache.write("gems", gem.name, JSON.parse(response.body))
78
+ when Net::HTTPNotFound
79
+ nil
80
+ else
81
+ raise ResolveError.new(gem.name, "RubyGems API returned #{response.code}")
82
+ end
83
+ end
84
+
85
+ def http_get(url)
86
+ uri = URI(url)
87
+ @http.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |conn|
88
+ conn.get(uri.request_uri, "Accept" => "application/json", "User-Agent" => user_agent)
89
+ end
90
+ end
91
+
92
+ def user_agent
93
+ "gem-contribute/#{GemContribute::VERSION}"
94
+ end
95
+
96
+ def preferred_uri(metadata)
97
+ # Order matters. ADR-0003.
98
+ candidates = [
99
+ ["bug_tracker_uri", metadata["bug_tracker_uri"]],
100
+ ["source_code_uri", metadata["source_code_uri"]],
101
+ ["homepage_uri", metadata["homepage_uri"]]
102
+ ]
103
+ candidates.each do |label, value|
104
+ next if value.nil? || value.empty?
105
+
106
+ @last_picked = label
107
+ return value
108
+ end
109
+ nil
110
+ end
111
+
112
+ def parse_host_coordinates(url)
113
+ uri = safe_uri(url)
114
+ return nil if uri.nil? || uri.host.nil? || uri.path.nil?
115
+
116
+ host = uri.host.sub(/\Awww\./, "")
117
+ return nil unless KNOWN_HOSTS.include?(host)
118
+
119
+ owner, repo = uri.path.delete_prefix("/").split("/", 3)
120
+ return nil if owner.to_s.empty? || repo.to_s.empty?
121
+
122
+ { host: host, owner: owner, repo: repo.sub(/\.git\z/, ""), picked_from: @last_picked }
123
+ end
124
+
125
+ def safe_uri(url)
126
+ URI.parse(url)
127
+ rescue URI::InvalidURIError
128
+ nil
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module GemContribute
7
+ # Reads / writes ~/.config/gem-contribute/auth.json (mode 0600), keyed by
8
+ # host. The per-host structure means GitLab / Codeberg adapters drop in
9
+ # without rearranging storage. See ADR-0001 and ADR-0004.
10
+ #
11
+ # File schema:
12
+ # {
13
+ # "github.com": {
14
+ # "access_token": "gho_...",
15
+ # "scope": "public_repo",
16
+ # "stored_at": 1730000000
17
+ # }
18
+ # }
19
+ #
20
+ # Honors XDG_CONFIG_HOME so tests stay hermetic and unusual layouts work.
21
+ class TokenStore
22
+ def initialize(path: TokenStore.default_path, clock: -> { Time.now.to_i })
23
+ @path = path
24
+ @clock = clock
25
+ end
26
+
27
+ # @return [String, nil] the cached access token for the host, or nil
28
+ def token_for(host)
29
+ data = read
30
+ data.dig(host, "access_token")
31
+ end
32
+
33
+ # @return [Hash, nil] {access_token, scope, stored_at} or nil
34
+ def entry_for(host)
35
+ read[host]
36
+ end
37
+
38
+ def store(host, access_token:, scope: nil)
39
+ data = read
40
+ data[host] = {
41
+ "access_token" => access_token,
42
+ "scope" => scope,
43
+ "stored_at" => @clock.call
44
+ }.compact
45
+ write(data)
46
+ end
47
+
48
+ def delete(host)
49
+ data = read
50
+ removed = data.delete(host)
51
+ write(data) if removed
52
+ removed
53
+ end
54
+
55
+ def hosts
56
+ read.keys
57
+ end
58
+
59
+ def self.default_path
60
+ base = ENV["XDG_CONFIG_HOME"] || File.expand_path("~/.config")
61
+ File.join(base, "gem-contribute", "auth.json")
62
+ end
63
+
64
+ private
65
+
66
+ def read
67
+ return {} unless File.file?(@path)
68
+
69
+ JSON.parse(File.read(@path, encoding: "UTF-8"))
70
+ rescue JSON::ParserError, Encoding::InvalidByteSequenceError
71
+ # Corrupt store: don't lose the user's data, but don't crash either.
72
+ # The token is recoverable by re-running `auth login`; the worst case
73
+ # is one extra device-flow round trip.
74
+ {}
75
+ end
76
+
77
+ def write(data)
78
+ FileUtils.mkdir_p(File.dirname(@path))
79
+ tmp = "#{@path}.tmp"
80
+ File.write(tmp, JSON.pretty_generate(data), encoding: "UTF-8")
81
+ File.chmod(0o600, tmp)
82
+ File.rename(tmp, @path)
83
+ File.chmod(0o600, @path)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemContribute
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gem_contribute/version"
4
+ require_relative "gem_contribute/errors"
5
+
6
+ module GemContribute
7
+ autoload :LockedGem, "gem_contribute/locked_gem"
8
+ autoload :Project, "gem_contribute/project"
9
+
10
+ # The canonical Project for gem-contribute itself. Used by the CLI to
11
+ # short-circuit resolution (gem-contribute isn't on RubyGems yet) and
12
+ # to auto-inject the tool into its own scan results.
13
+ SELF_PROJECT = Project.new(
14
+ gem_name: "gem-contribute",
15
+ host: "github.com",
16
+ owner: "cdhagmann",
17
+ repo: "gem-contribute",
18
+ metadata: { self_injected: true }
19
+ ).freeze
20
+ autoload :LockfileParser, "gem_contribute/lockfile_parser"
21
+ autoload :Cache, "gem_contribute/cache"
22
+ autoload :Resolver, "gem_contribute/resolver"
23
+ autoload :HostAdapter, "gem_contribute/host_adapter"
24
+ autoload :Auth, "gem_contribute/auth"
25
+ autoload :Config, "gem_contribute/config"
26
+ autoload :TokenStore, "gem_contribute/token_store"
27
+ autoload :CLI, "gem_contribute/cli"
28
+
29
+ module HostAdapters
30
+ autoload :GitHubAdapter, "gem_contribute/host_adapters/github_adapter"
31
+ end
32
+ end
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Validates KICKED_THE_TIRES.yml against the schema documented in its header.
5
+ # Used by the auto-merge workflow (.github/workflows/auto-merge-kicked-tires.yml)
6
+ # and runnable locally:
7
+ #
8
+ # ruby script/lint-kicked-tires.rb # lint the canonical file
9
+ # ruby script/lint-kicked-tires.rb path.yml # lint a specific file
10
+ #
11
+ # Exits 0 on success, 1 on any schema violation, with a clear message.
12
+
13
+ require "yaml"
14
+ require "date"
15
+
16
+ PATH = ARGV[0] || "KICKED_THE_TIRES.yml"
17
+ ALLOWED_KEYS = %w[handle date note location].freeze
18
+ REQUIRED_KEYS = %w[handle date].freeze
19
+ HANDLE_PATTERN = /\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}[a-zA-Z0-9])?\z/ # GitHub handle rules
20
+ DATE_PATTERN = /\A\d{4}-\d{2}-\d{2}\z/
21
+ MAX_BYTES = 100_000 # 100KB — guards against runaway PRs
22
+
23
+ def fail!(msg)
24
+ warn "✗ #{msg}"
25
+ exit 1
26
+ end
27
+
28
+ fail! "file not found: #{PATH}" unless File.exist?(PATH)
29
+ fail! "file is suspiciously large (#{File.size(PATH)} bytes)" if File.size(PATH) > MAX_BYTES
30
+
31
+ begin
32
+ data = YAML.safe_load_file(PATH, permitted_classes: [Date])
33
+ rescue Psych::SyntaxError => e
34
+ fail! "YAML syntax error: #{e.message}"
35
+ end
36
+
37
+ fail! "top-level must be an array" unless data.is_a?(Array)
38
+ fail! "must have at least one entry" if data.empty?
39
+
40
+ handles = []
41
+ data.each_with_index do |entry, i|
42
+ num = i + 1
43
+ fail! "entry #{num}: must be a hash, got #{entry.class}" unless entry.is_a?(Hash)
44
+
45
+ keys = entry.keys.map(&:to_s)
46
+ unknown = keys - ALLOWED_KEYS
47
+ fail! "entry #{num}: unknown keys: #{unknown.inspect} (allowed: #{ALLOWED_KEYS.inspect})" \
48
+ unless unknown.empty?
49
+
50
+ missing = REQUIRED_KEYS - keys
51
+ fail! "entry #{num}: missing required keys: #{missing.inspect}" unless missing.empty?
52
+
53
+ handle = entry["handle"]
54
+ fail! "entry #{num}: handle must be a string" unless handle.is_a?(String)
55
+ fail! "entry #{num}: handle must not start with '@' (use just the username)" \
56
+ if handle.start_with?("@")
57
+ fail! "entry #{num}: handle #{handle.inspect} doesn't look like a GitHub username" \
58
+ unless handle.match?(HANDLE_PATTERN)
59
+
60
+ date = entry["date"]
61
+ date_ok = date.is_a?(Date) || (date.is_a?(String) && date.match?(DATE_PATTERN))
62
+ fail! "entry #{num}: date must be YYYY-MM-DD" unless date_ok
63
+
64
+ fail! "entry #{num}: note must be a string" if entry.key?("note") && !entry["note"].is_a?(String)
65
+
66
+ if entry.key?("location") && !entry["location"].is_a?(String)
67
+ fail! "entry #{num}: location must be a string (not a nested object)"
68
+ end
69
+
70
+ handles << handle
71
+ end
72
+
73
+ duplicates = handles.group_by(&:itself).select { |_, v| v.size > 1 }.keys
74
+ fail! "duplicate handles: #{duplicates.inspect}" unless duplicates.empty?
75
+
76
+ puts "✓ #{data.size} entries, all valid"
@@ -0,0 +1,3 @@
1
+ module GemContribute
2
+ VERSION: String
3
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gem-contribute
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Chris Hagmann
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.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.4'
26
+ description: |
27
+ gem-contribute reads a project's Gemfile.lock, resolves each gem's source
28
+ repository via the RubyGems API, surfaces open contributable issues from
29
+ those repositories, and offers a one-keystroke fork-clone-branch flow so a
30
+ developer can go from "I noticed an issue" to "I have a working branch" in
31
+ seconds. v0.1 supports GitHub-hosted gems with OAuth device-flow auth.
32
+ email:
33
+ - cdhagmann@gmail.com
34
+ executables:
35
+ - gem-contribute
36
+ extensions: []
37
+ extra_rdoc_files: []
38
+ files:
39
+ - ".github/ISSUE_TEMPLATE/workshop-issue.md"
40
+ - ".github/workflows/auto-merge-kicked-tires.yml"
41
+ - CHANGELOG.md
42
+ - CLAUDE.md
43
+ - CONTRIBUTING.md
44
+ - KICKED_THE_TIRES.yml
45
+ - LICENSE
46
+ - MAINTAINER.md
47
+ - README.md
48
+ - Rakefile
49
+ - docs/_config.yml
50
+ - docs/adr/0001-just-in-time-auth.md
51
+ - docs/adr/0002-bundler-lockfile-parser.md
52
+ - docs/adr/0003-issue-tracker-preference.md
53
+ - docs/adr/0004-device-flow-auth.md
54
+ - docs/adr/0005-render-labels-verbatim.md
55
+ - docs/adr/0006-standalone-gem-not-plugin.md
56
+ - docs/adr/0007-display-contributing-verbatim.md
57
+ - docs/adr/0008-rooibos-tui-framework.md
58
+ - docs/adr/0009-top-level-namespace.md
59
+ - docs/adr/README.md
60
+ - docs/claude-code-prompt.md
61
+ - docs/design.md
62
+ - docs/index.md
63
+ - docs/prep-plan.md
64
+ - docs/workshop.md
65
+ - exe/gem-contribute
66
+ - lib/gem_contribute.rb
67
+ - lib/gem_contribute/auth.rb
68
+ - lib/gem_contribute/cache.rb
69
+ - lib/gem_contribute/cli.rb
70
+ - lib/gem_contribute/cli/auth.rb
71
+ - lib/gem_contribute/cli/config.rb
72
+ - lib/gem_contribute/cli/fork_clone_branch.rb
73
+ - lib/gem_contribute/cli/issues.rb
74
+ - lib/gem_contribute/cli/scan.rb
75
+ - lib/gem_contribute/cli/submit.rb
76
+ - lib/gem_contribute/config.rb
77
+ - lib/gem_contribute/errors.rb
78
+ - lib/gem_contribute/host_adapter.rb
79
+ - lib/gem_contribute/host_adapters/github_adapter.rb
80
+ - lib/gem_contribute/locked_gem.rb
81
+ - lib/gem_contribute/lockfile_parser.rb
82
+ - lib/gem_contribute/project.rb
83
+ - lib/gem_contribute/resolver.rb
84
+ - lib/gem_contribute/token_store.rb
85
+ - lib/gem_contribute/version.rb
86
+ - script/lint-kicked-tires.rb
87
+ - sig/gem_contribute.rbs
88
+ homepage: https://cdhagmann.com/gem-contribute/
89
+ licenses:
90
+ - MIT
91
+ metadata:
92
+ source_code_uri: https://github.com/cdhagmann/gem-contribute
93
+ bug_tracker_uri: https://github.com/cdhagmann/gem-contribute/issues
94
+ changelog_uri: https://github.com/cdhagmann/gem-contribute/blob/main/CHANGELOG.md
95
+ documentation_uri: https://cdhagmann.com/gem-contribute/
96
+ rubygems_mfa_required: 'true'
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: 3.2.0
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 4.0.10
112
+ specification_version: 4
113
+ summary: Find and contribute to the open-source Ruby gems your project depends on.
114
+ test_files: []