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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "uri"
5
+
6
+ module GemContribute
7
+ module CLI
8
+ # `gem-contribute submit` — push the current branch to the user's fork and
9
+ # open a pre-filled PR compare page in the browser.
10
+ #
11
+ # Run from inside a clone created by `gem-contribute fix`. Reads:
12
+ # - origin remote → fork owner/repo (where the branch is pushed)
13
+ # - upstream remote → canonical owner/repo (where the PR is filed)
14
+ # - current branch → must match `gem-contribute/issue-<N>`
15
+ #
16
+ # The PR itself is NOT opened via API. We push, then open GitHub's compare
17
+ # page in the browser with title and body pre-filled. This mirrors the
18
+ # `auth login` UX (browser handles the human step) and means the user
19
+ # always reviews the PR text before submitting.
20
+ class Submit
21
+ BRANCH_REGEX = %r{\Agem-contribute/issue-(\d+)\z}
22
+
23
+ def initialize(stdout: $stdout, stderr: $stderr,
24
+ git: Git.new,
25
+ adapter_factory: ->(token:) { HostAdapters::GitHubAdapter.new(token: token) },
26
+ store: TokenStore.new,
27
+ browser_opener: nil,
28
+ working_dir: Dir.pwd)
29
+ @stdout = stdout
30
+ @stderr = stderr
31
+ @git = git
32
+ @adapter_factory = adapter_factory
33
+ @store = store
34
+ @browser_opener = browser_opener || method(:default_browser_opener)
35
+ @working_dir = working_dir
36
+ end
37
+
38
+ def run(_argv)
39
+ branch = current_branch
40
+ issue_number = parse_issue_number(branch)
41
+ return 1 if issue_number.nil?
42
+
43
+ origin = parse_remote("origin", required: true)
44
+ return 1 if origin.nil?
45
+
46
+ # When the user owns the upstream (e.g. self-dogfooding) there's no
47
+ # separate fork and no `upstream` remote — fall back to origin and
48
+ # build a same-repo PR.
49
+ upstream = parse_remote("upstream", required: false) || origin
50
+
51
+ title = fetch_issue_title(upstream, issue_number)
52
+ push_branch(branch)
53
+ url = compare_url(upstream, origin, branch, issue_number, title)
54
+ open_and_print(url)
55
+ 0
56
+ rescue AdapterError => e
57
+ @stderr.puts "submit failed: #{e.message}"
58
+ 1
59
+ end
60
+
61
+ private
62
+
63
+ def current_branch
64
+ # symbolic-ref works even on a fresh branch with no commits;
65
+ # rev-parse --abbrev-ref doesn't.
66
+ out, _err, status = Open3.capture3("git", "-C", @working_dir, "symbolic-ref", "--short", "HEAD")
67
+ raise AdapterError, "not inside a git repository (or HEAD is detached)" unless status.success?
68
+
69
+ out.strip
70
+ end
71
+
72
+ def parse_issue_number(branch)
73
+ match = BRANCH_REGEX.match(branch)
74
+ return match[1].to_i if match
75
+
76
+ @stderr.puts "submit: branch #{branch.inspect} doesn't match #{BRANCH_REGEX.source}."
77
+ @stderr.puts "Run `gem-contribute fix <gem>/<issue#>` first to set up the branch."
78
+ nil
79
+ end
80
+
81
+ def parse_remote(name, required:)
82
+ out, _err, status = Open3.capture3("git", "-C", @working_dir, "remote", "get-url", name)
83
+ unless status.success?
84
+ if required
85
+ @stderr.puts "submit: no `#{name}` remote configured. " \
86
+ "Are you inside a git clone?"
87
+ end
88
+ return nil
89
+ end
90
+
91
+ owner_repo_from_url(out.strip)
92
+ end
93
+
94
+ # Accepts both https://github.com/owner/repo(.git) and git@github.com:owner/repo.git
95
+ def owner_repo_from_url(url)
96
+ if (m = url.match(%r{github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?\z}))
97
+ { owner: m[1], repo: m[2] }
98
+ else
99
+ @stderr.puts "submit: can't parse GitHub owner/repo from #{url.inspect}"
100
+ nil
101
+ end
102
+ end
103
+
104
+ def fetch_issue_title(upstream, number)
105
+ token = @store.token_for("github.com")
106
+ adapter = @adapter_factory.call(token: token)
107
+ adapter.issue(upstream[:owner], upstream[:repo], number).fetch("title", nil)
108
+ rescue AdapterError => e
109
+ @stderr.puts "submit: couldn't fetch issue title (#{e.message}). Continuing without it."
110
+ nil
111
+ end
112
+
113
+ def push_branch(branch)
114
+ @stdout.puts "Pushing #{branch} to origin..."
115
+ @git.push(@working_dir, "origin", branch)
116
+ end
117
+
118
+ def compare_url(upstream, origin, branch, issue_number, title)
119
+ # GitHub compare URL forms:
120
+ # Cross-fork: /<upstream>/compare/<fork-owner>:<branch>
121
+ # Same-repo: /<upstream>/compare/<branch>
122
+ # We omit the explicit base so GitHub auto-resolves to default.
123
+ # `expand=1` opens the PR creation form pre-filled.
124
+ same_repo = origin[:owner] == upstream[:owner] && origin[:repo] == upstream[:repo]
125
+ head = same_repo ? branch : "#{origin[:owner]}:#{branch}"
126
+ full_title = title ? "Fix ##{issue_number}: #{title}" : "Fix ##{issue_number}"
127
+ params = {
128
+ "expand" => "1",
129
+ "title" => full_title,
130
+ "body" => "Closes ##{issue_number}.\n\n_Opened via `gem-contribute submit`._"
131
+ }
132
+
133
+ "https://github.com/#{upstream[:owner]}/#{upstream[:repo]}/compare/#{head}?" \
134
+ "#{URI.encode_www_form(params)}"
135
+ end
136
+
137
+ def open_and_print(url)
138
+ opened = @browser_opener.call(url)
139
+ @stdout.puts opened ? "Opened browser to:" : "Open this URL to file the PR:"
140
+ @stdout.puts " #{url}"
141
+ end
142
+
143
+ def default_browser_opener(uri)
144
+ cmd = case RbConfig::CONFIG["host_os"]
145
+ when /darwin/ then "open"
146
+ when /linux/ then "xdg-open"
147
+ when /mswin|mingw|cygwin/ then "start"
148
+ end
149
+ cmd && Kernel.system(cmd, uri)
150
+ rescue StandardError
151
+ false
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module GemContribute
6
+ module CLI
7
+ autoload :Scan, "gem_contribute/cli/scan"
8
+ autoload :Auth, "gem_contribute/cli/auth"
9
+ autoload :Config, "gem_contribute/cli/config"
10
+ autoload :Issues, "gem_contribute/cli/issues"
11
+ autoload :ForkCloneBranch, "gem_contribute/cli/fork_clone_branch"
12
+ autoload :Git, "gem_contribute/cli/fork_clone_branch"
13
+ autoload :Submit, "gem_contribute/cli/submit"
14
+ USAGE = <<~USAGE
15
+ Usage: gem-contribute <command> [options]
16
+
17
+ Commands:
18
+ scan [path] Summarize the contributable surface of a Gemfile.lock.
19
+ Path defaults to ./Gemfile.lock.
20
+ issues <gem|all> List open "good first issue" issues for a gem (or all gems).
21
+ config set <key> <val> Persist a configuration value.
22
+ config get <key> Print a configuration value.
23
+ config list Print all configuration values.
24
+ auth login Authenticate with GitHub via OAuth device flow.
25
+ auth status Show whether you're authenticated.
26
+ auth logout Remove the cached token for github.com.
27
+ fix <gem>/<issue#> Fork the gem's repo, clone the fork, branch from main.
28
+ (alias: fork-clone-branch)
29
+ submit Push the current branch and open a pre-filled
30
+ PR compare page in the browser. Run from inside
31
+ a clone created by `fix`.
32
+
33
+ Global options:
34
+ --refresh Invalidate caches before running.
35
+ -h, --help Show this help.
36
+ --version Print the version and exit.
37
+ USAGE
38
+
39
+ module_function
40
+
41
+ # Entry point for exe/gem-contribute. Returns an integer exit status so the
42
+ # caller can `exit GemContribute::CLI.run(ARGV)`.
43
+ def run(argv, stdout: $stdout, stderr: $stderr)
44
+ argv = argv.dup
45
+ handle_global_flags!(argv, stdout: stdout)
46
+ dispatch(argv.shift, argv, stdout: stdout, stderr: stderr)
47
+ end
48
+
49
+ def dispatch(command, argv, stdout:, stderr:)
50
+ builder = COMMANDS[command]
51
+ if builder.nil?
52
+ return print_help(stdout) if [nil, "help", "-h", "--help"].include?(command)
53
+
54
+ return unknown_command(command, stderr)
55
+ end
56
+
57
+ builder.call(stdout, stderr).run(argv)
58
+ end
59
+
60
+ COMMANDS = {
61
+ "scan" => ->(o, e) { Scan.new(stdout: o, stderr: e, adapter: github_adapter) },
62
+ "issues" => ->(o, e) { Issues.new(stdout: o, stderr: e, adapter: github_adapter) },
63
+ "config" => ->(o, e) { Config.new(stdout: o, stderr: e) },
64
+ "auth" => ->(o, e) { Auth.new(stdout: o, stderr: e) },
65
+ "fix" => lambda { |o, e|
66
+ ForkCloneBranch.new(stdout: o, stderr: e,
67
+ clone_root: GemContribute::Config.new.clone_root)
68
+ },
69
+ "fork-clone-branch" => lambda { |o, e|
70
+ ForkCloneBranch.new(stdout: o, stderr: e,
71
+ clone_root: GemContribute::Config.new.clone_root)
72
+ },
73
+ "submit" => ->(o, e) { Submit.new(stdout: o, stderr: e) }
74
+ }.freeze
75
+
76
+ def print_help(stdout)
77
+ stdout.puts USAGE
78
+ 0
79
+ end
80
+
81
+ def unknown_command(command, stderr)
82
+ stderr.puts "gem-contribute: unknown command #{command.inspect}"
83
+ stderr.puts USAGE
84
+ 2
85
+ end
86
+
87
+ def github_adapter
88
+ token = TokenStore.new.token_for("github.com")
89
+ HostAdapters::GitHubAdapter.new(token: token)
90
+ end
91
+
92
+ def handle_global_flags!(argv, stdout:)
93
+ if argv.include?("--version")
94
+ stdout.puts "gem-contribute #{GemContribute::VERSION}"
95
+ exit 0
96
+ end
97
+
98
+ return unless argv.delete("--refresh")
99
+
100
+ Cache.new.clear!
101
+ stdout.puts "Cache cleared at #{Cache.default_root}"
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module GemContribute
7
+ # Reads and writes ~/.config/gem-contribute/config.yml.
8
+ # Honors XDG_CONFIG_HOME so tests stay hermetic and unusual layouts work.
9
+ # Missing or corrupt files are treated as an empty config (no crash).
10
+ class Config
11
+ DEFAULT_CLONE_ROOT = File.expand_path("~/code/oss")
12
+
13
+ KNOWN_KEYS = %w[clone_root].freeze
14
+
15
+ def initialize(path: self.class.default_path)
16
+ @path = path
17
+ @data = load_file
18
+ end
19
+
20
+ def clone_root
21
+ raw = @data["clone_root"]
22
+ raw ? File.expand_path(raw) : DEFAULT_CLONE_ROOT
23
+ end
24
+
25
+ def set(key, value)
26
+ raise ArgumentError, "unknown config key #{key.inspect}. Known keys: #{KNOWN_KEYS.join(", ")}" \
27
+ unless KNOWN_KEYS.include?(key)
28
+
29
+ @data[key] = value
30
+ write_file
31
+ end
32
+
33
+ def to_h
34
+ @data.dup
35
+ end
36
+
37
+ def self.default_path
38
+ base = ENV.fetch("XDG_CONFIG_HOME", File.expand_path("~/.config"))
39
+ File.join(base, "gem-contribute", "config.yml")
40
+ end
41
+
42
+ private
43
+
44
+ def load_file
45
+ return {} unless File.exist?(@path)
46
+
47
+ parsed = YAML.safe_load(File.read(@path, encoding: "UTF-8"))
48
+ parsed.is_a?(Hash) ? parsed : {}
49
+ rescue Psych::Exception, Errno::EACCES
50
+ {}
51
+ end
52
+
53
+ def write_file
54
+ FileUtils.mkdir_p(File.dirname(@path))
55
+ tmp = "#{@path}.tmp"
56
+ File.write(tmp, YAML.dump(@data), encoding: "UTF-8")
57
+ File.rename(tmp, @path)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemContribute
4
+ class Error < StandardError; end
5
+
6
+ class LockfileNotFound < Error; end
7
+
8
+ class LockfileParseError < Error; end
9
+
10
+ class ResolveError < Error
11
+ attr_reader :gem_name
12
+
13
+ def initialize(gem_name, message)
14
+ @gem_name = gem_name
15
+ super("#{gem_name}: #{message}")
16
+ end
17
+ end
18
+
19
+ class AdapterError < Error; end
20
+
21
+ # Raised by host adapters when an authenticated call is attempted without a
22
+ # cached token for that host. The TUI catches this and triggers device flow;
23
+ # CLI callers print a "run auth login" hint. See ADR-0001.
24
+ class AuthRequired < Error
25
+ attr_reader :host
26
+
27
+ def initialize(host)
28
+ @host = host
29
+ super("authentication required for #{host}")
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemContribute
4
+ # Abstract host adapter. Concrete implementations (GitHubAdapter, future
5
+ # GitLabAdapter, future CodebergAdapter) conform to this interface so the
6
+ # TUI doesn't have to special-case anything beyond looking up the right
7
+ # adapter for a project's host.
8
+ #
9
+ # Public-API methods (no auth needed):
10
+ # issues(project, labels:)
11
+ # community_profile(project)
12
+ # file_contents(project, path)
13
+ #
14
+ # Auth-required methods (raise AuthRequired without a cached token):
15
+ # fork(project)
16
+ # already_forked?(project)
17
+ #
18
+ # See ADR-0001 for the JIT auth contract.
19
+ class HostAdapter
20
+ def issues(_project, labels: nil)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def community_profile(_project)
25
+ raise NotImplementedError
26
+ end
27
+
28
+ def file_contents(_project, _path)
29
+ raise NotImplementedError
30
+ end
31
+
32
+ def fork(_project)
33
+ raise NotImplementedError
34
+ end
35
+
36
+ def already_forked?(_project)
37
+ raise NotImplementedError
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module GemContribute
8
+ module HostAdapters
9
+ # GitHub adapter. v0.1 implements the unauthenticated read methods
10
+ # (issues, community_profile, file_contents). The auth-required methods
11
+ # raise AuthRequired so the calling layer (CLI in Stage 2, TUI in Stage 3)
12
+ # can trigger device flow. See ADR-0001 and ADR-0004.
13
+ #
14
+ # `token` is optional and reserved for Stage 2; when present it's sent as
15
+ # `Authorization: Bearer …` to lift the rate limit and unlock fork/etc.
16
+ class GitHubAdapter < HostAdapter
17
+ API_BASE = "https://api.github.com"
18
+ ACCEPT = "application/vnd.github+json"
19
+ API_VERSION = "2022-11-28"
20
+ MAX_REDIRECTS = 3
21
+
22
+ RateLimit = Data.define(:limit, :remaining, :reset_at)
23
+
24
+ attr_reader :rate_limit
25
+
26
+ def initialize(cache: Cache.new, http: Net::HTTP, token: nil)
27
+ super()
28
+ @cache = cache
29
+ @http = http
30
+ @token = token
31
+ @rate_limit = nil
32
+ end
33
+
34
+ # @return [Hash] a single issue's full payload (uncached — submit only).
35
+ def issue(owner, repo, number)
36
+ ensure_known_host!(Project.new(gem_name: repo, host: "github.com",
37
+ owner: owner, repo: repo, metadata: {}))
38
+ get_json("/repos/#{owner}/#{repo}/issues/#{number}")
39
+ end
40
+
41
+ # @return [Array<Hash>] open issues filtered to the given labels (if any)
42
+ def issues(project, labels: nil)
43
+ ensure_known_host!(project)
44
+
45
+ cache_key = issue_cache_key(project, labels)
46
+ cached = @cache.fetch("issues", cache_key)
47
+ return cached if cached
48
+
49
+ params = { state: "open", per_page: 50 }
50
+ params[:labels] = Array(labels).join(",") if labels && !Array(labels).empty?
51
+ body = get_json("/repos/#{project.owner}/#{project.repo}/issues", params)
52
+
53
+ # GitHub's /issues endpoint mixes pull requests in. PRs have a
54
+ # `pull_request` key; filter those out so callers see issues only.
55
+ only_issues = body.reject { |i| i.key?("pull_request") }
56
+ @cache.write("issues", cache_key, only_issues)
57
+ end
58
+
59
+ def community_profile(project)
60
+ ensure_known_host!(project)
61
+ cache_key = "#{project.owner}/#{project.repo}"
62
+ cached = @cache.fetch("repos", cache_key)
63
+ return cached if cached
64
+
65
+ body = get_json("/repos/#{project.owner}/#{project.repo}/community/profile")
66
+ @cache.write("repos", cache_key, body)
67
+ end
68
+
69
+ def file_contents(project, path)
70
+ ensure_known_host!(project)
71
+ cache_key = "#{project.owner}/#{project.repo}:#{path}"
72
+ cached = @cache.fetch("files", cache_key)
73
+ return cached if cached
74
+
75
+ body = get_json("/repos/#{project.owner}/#{project.repo}/contents/#{path}")
76
+ @cache.write("files", cache_key, body)
77
+ end
78
+
79
+ # POST /repos/:owner/:repo/forks. Returns the fork's parsed body
80
+ # (clone_url, owner.login, name, etc.). GitHub responds 202 (accepted)
81
+ # immediately even if the fork is still propagating; callers that need
82
+ # to clone right after may want to poll readiness — see
83
+ # `fork_ready?` below.
84
+ def fork(project)
85
+ raise AuthRequired, "github.com" unless @token
86
+
87
+ ensure_known_host!(project)
88
+ post_json("/repos/#{project.owner}/#{project.repo}/forks")
89
+ end
90
+
91
+ # GET /repos/:viewer/:repo. True iff the viewer already owns a fork of
92
+ # the upstream repo at the same name.
93
+ def already_forked?(project)
94
+ raise AuthRequired, "github.com" unless @token
95
+
96
+ ensure_known_host!(project)
97
+ viewer = viewer_login
98
+ get_json("/repos/#{viewer}/#{project.repo}")
99
+ true
100
+ rescue AdapterError => e
101
+ return false if e.message.include?("404")
102
+
103
+ raise
104
+ end
105
+
106
+ # GET /user. Used by `auth status` and `already_forked?`. Returns the
107
+ # authenticated user's login string (e.g. "cdhagmann").
108
+ def viewer_login
109
+ raise AuthRequired, "github.com" unless @token
110
+
111
+ body = get_json("/user")
112
+ body.fetch("login")
113
+ end
114
+
115
+ # GET /repos/:viewer/:repo, returning true once GitHub has finished
116
+ # provisioning the fork. The fork endpoint returns 202 immediately;
117
+ # the resource may 404 for a few seconds before becoming live.
118
+ def fork_ready?(viewer, repo_name)
119
+ raise AuthRequired, "github.com" unless @token
120
+
121
+ get_json("/repos/#{viewer}/#{repo_name}")
122
+ true
123
+ rescue AdapterError => e
124
+ return false if e.message.include?("404")
125
+
126
+ raise
127
+ end
128
+
129
+ private
130
+
131
+ def issue_cache_key(project, labels)
132
+ label_segment = Array(labels).sort.join(",")
133
+ "#{project.owner}/#{project.repo}?labels=#{label_segment}"
134
+ end
135
+
136
+ def ensure_known_host!(project)
137
+ return if project.host == "github.com"
138
+
139
+ raise AdapterError, "GitHubAdapter cannot serve project on host #{project.host.inspect}"
140
+ end
141
+
142
+ def get_json(path, params = {})
143
+ response = http_get(path, params)
144
+ record_rate_limit(response)
145
+ decode_response(response, path)
146
+ end
147
+
148
+ def post_json(path, body = nil)
149
+ response = http_post(path, body)
150
+ record_rate_limit(response)
151
+ decode_response(response, path)
152
+ end
153
+
154
+ def http_get(path, params, redirects_remaining: MAX_REDIRECTS)
155
+ url = URI("#{API_BASE}#{path}")
156
+ url.query = URI.encode_www_form(params) unless params.empty?
157
+ response = @http.start(url.host, url.port, use_ssl: true) do |conn|
158
+ conn.get(url.request_uri, request_headers)
159
+ end
160
+
161
+ if response.is_a?(Net::HTTPMovedPermanently) && redirects_remaining.positive?
162
+ new_path = URI(response["Location"]).path
163
+ return http_get(new_path, params, redirects_remaining: redirects_remaining - 1)
164
+ end
165
+
166
+ response
167
+ end
168
+
169
+ def http_post(path, body)
170
+ url = URI("#{API_BASE}#{path}")
171
+ @http.start(url.host, url.port, use_ssl: true) do |conn|
172
+ request = Net::HTTP::Post.new(url.request_uri, request_headers.merge("Content-Type" => "application/json"))
173
+ request.body = JSON.dump(body) if body
174
+ conn.request(request)
175
+ end
176
+ end
177
+
178
+ def decode_response(response, path)
179
+ case response
180
+ when Net::HTTPNoContent then nil
181
+ when Net::HTTPSuccess then JSON.parse(response.body)
182
+ when Net::HTTPUnauthorized, Net::HTTPForbidden
183
+ raise AuthRequired, "github.com" if @token.nil?
184
+
185
+ raise AdapterError, "GitHub returned #{response.code}: #{response.body}"
186
+ else
187
+ raise AdapterError, "GitHub returned #{response.code} for #{path}"
188
+ end
189
+ end
190
+
191
+ def request_headers
192
+ headers = {
193
+ "Accept" => ACCEPT,
194
+ "User-Agent" => "gem-contribute/#{GemContribute::VERSION}",
195
+ "X-GitHub-Api-Version" => API_VERSION
196
+ }
197
+ headers["Authorization"] = "Bearer #{@token}" if @token
198
+ headers
199
+ end
200
+
201
+ def record_rate_limit(response)
202
+ limit = response["X-RateLimit-Limit"]
203
+ remaining = response["X-RateLimit-Remaining"]
204
+ reset = response["X-RateLimit-Reset"]
205
+ return if [limit, remaining, reset].any?(&:nil?)
206
+
207
+ @rate_limit = RateLimit.new(
208
+ limit: limit.to_i,
209
+ remaining: remaining.to_i,
210
+ reset_at: Time.at(reset.to_i)
211
+ )
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemContribute
4
+ # A single dependency parsed from Gemfile.lock.
5
+ #
6
+ # The design doc calls this "a Gem"; the in-code name is LockedGem to avoid
7
+ # shadowing Ruby's stdlib ::Gem inside the GemContribute namespace.
8
+ # See ADR-0009.
9
+ #
10
+ # `source_type` is one of:
11
+ # :rubygems — published to a RubyGems-compatible index
12
+ # :git — `gem 'foo', git: '…'`
13
+ # :path — `gem 'foo', path: '…'`
14
+ # :bundler — Bundler itself (only present in lockfiles via DEPENDENCIES)
15
+ LockedGem = Data.define(:name, :version, :source_type, :source_uri) do
16
+ def rubygems?
17
+ source_type == :rubygems
18
+ end
19
+
20
+ def resolvable?
21
+ # We can only ask the RubyGems API about things we got from RubyGems.
22
+ # See ADR-0003 for what we do with the answer.
23
+ rubygems?
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+
5
+ module GemContribute
6
+ # Wraps Bundler::LockfileParser. See ADR-0002.
7
+ #
8
+ # Input: a path to a Gemfile.lock.
9
+ # Output: an Array of LockedGem.
10
+ module LockfileParser
11
+ module_function
12
+
13
+ # @param path [String, Pathname] path to a Gemfile.lock
14
+ # @return [Array<LockedGem>]
15
+ def parse(path)
16
+ contents = read_lockfile(path)
17
+ parser = Bundler::LockfileParser.new(contents)
18
+
19
+ parser.specs.map { |spec| build_locked_gem(spec) }
20
+ rescue Bundler::LockfileError => e
21
+ raise LockfileParseError, "could not parse #{path}: #{e.message}"
22
+ end
23
+
24
+ def read_lockfile(path)
25
+ File.read(path)
26
+ rescue Errno::ENOENT
27
+ raise LockfileNotFound, "no Gemfile.lock at #{path}"
28
+ end
29
+
30
+ def build_locked_gem(spec)
31
+ LockedGem.new(
32
+ name: spec.name,
33
+ version: spec.version.to_s,
34
+ source_type: classify_source(spec.source),
35
+ source_uri: source_uri(spec.source)
36
+ )
37
+ end
38
+
39
+ def classify_source(source)
40
+ case source
41
+ when Bundler::Source::Rubygems then :rubygems
42
+ when Bundler::Source::Git then :git
43
+ when Bundler::Source::Path then :path
44
+ else :unknown
45
+ end
46
+ end
47
+
48
+ def source_uri(source)
49
+ case source
50
+ when Bundler::Source::Rubygems
51
+ # Bundler::Source::Rubygems can have multiple remotes; pick the first.
52
+ # In practice this is rubygems.org for almost every gem.
53
+ source.remotes.first&.to_s
54
+ when Bundler::Source::Git
55
+ source.uri
56
+ when Bundler::Source::Path
57
+ source.path.to_s
58
+ end
59
+ end
60
+ end
61
+ end