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,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemContribute
4
+ module CLI
5
+ # `gem-contribute auth <subcommand>` — Stage 2 entry point for OAuth.
6
+ #
7
+ # Subcommands:
8
+ # login — start device flow, poll for completion, persist the token
9
+ # status — show whether a token is cached for github.com (and try to
10
+ # validate it by hitting /user)
11
+ # logout — drop the cached token for github.com
12
+ class Auth
13
+ USAGE = <<~USAGE
14
+ Usage: gem-contribute auth <subcommand>
15
+
16
+ Subcommands:
17
+ login Authenticate with GitHub via OAuth device flow.
18
+ status Show whether you're authenticated.
19
+ logout Remove the cached token for github.com.
20
+ USAGE
21
+
22
+ DEFAULT_HOST = "github.com"
23
+
24
+ def initialize(stdout: $stdout, stderr: $stderr, store: TokenStore.new,
25
+ sleeper: ->(s) { Kernel.sleep(s) },
26
+ browser_opener: nil, clipper: nil)
27
+ @stdout = stdout
28
+ @stderr = stderr
29
+ @store = store
30
+ @sleeper = sleeper
31
+ @browser_opener = browser_opener || method(:default_browser_opener)
32
+ @clipper = clipper || method(:default_clipper)
33
+ end
34
+
35
+ def run(argv)
36
+ case argv.shift
37
+ when "login" then login
38
+ when "status" then status
39
+ when "logout" then logout
40
+ when nil, "help", "-h", "--help"
41
+ @stdout.puts USAGE
42
+ 0
43
+ else
44
+ @stderr.puts "gem-contribute: unknown auth subcommand"
45
+ @stderr.puts USAGE
46
+ 2
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def login
53
+ device_code = GemContribute::Auth.request_device_code(GemContribute::Auth::CLIENT_ID)
54
+ prompt_user(device_code)
55
+ result = poll_loop(device_code)
56
+ persist_or_report(result)
57
+ rescue GemContribute::Auth::AuthError => e
58
+ @stderr.puts "auth login failed: #{e.message}"
59
+ 1
60
+ end
61
+
62
+ def prompt_user(device_code)
63
+ copied = @clipper.call(device_code.user_code)
64
+ code_suffix = copied ? " (copied to clipboard)" : ""
65
+ @stdout.puts "Your one-time code#{code_suffix}: #{device_code.user_code}"
66
+
67
+ opened = @browser_opener.call(device_code.verification_uri)
68
+ url_prefix = opened ? "Browser opened to" : "Visit"
69
+ @stdout.puts "#{url_prefix}: #{device_code.verification_uri}"
70
+
71
+ @stdout.puts "Waiting for you to authorize..."
72
+ end
73
+
74
+ def poll_loop(device_code)
75
+ loop do
76
+ if device_code.expired?
77
+ return GemContribute::Auth::Result.new(status: :expired, token: nil, scope: nil, error_message: nil)
78
+ end
79
+
80
+ @sleeper.call(device_code.interval)
81
+ result = GemContribute::Auth.poll(device_code, GemContribute::Auth::CLIENT_ID)
82
+
83
+ case result.status
84
+ when :pending then next
85
+ when :slow_down then device_code = device_code.with_interval(device_code.interval + 5)
86
+ else return result
87
+ end
88
+ end
89
+ end
90
+
91
+ def persist_or_report(result)
92
+ case result.status
93
+ when :ok
94
+ @store.store(DEFAULT_HOST, access_token: result.token, scope: result.scope)
95
+ @stdout.puts "Authenticated. Token saved to #{TokenStore.default_path} (mode 0600)."
96
+ 0
97
+ when :expired
98
+ @stderr.puts "Device code expired. Run `gem-contribute auth login` again."
99
+ 1
100
+ when :denied
101
+ @stderr.puts "Authorization denied."
102
+ 1
103
+ else
104
+ @stderr.puts "auth login failed: #{result.error_message}"
105
+ 1
106
+ end
107
+ end
108
+
109
+ def status
110
+ entry = @store.entry_for(DEFAULT_HOST)
111
+ if entry.nil?
112
+ @stdout.puts "Not authenticated. Run `gem-contribute auth login`."
113
+ return 1
114
+ end
115
+
116
+ verify_and_print(entry)
117
+ end
118
+
119
+ def verify_and_print(entry)
120
+ adapter = HostAdapters::GitHubAdapter.new(token: entry["access_token"])
121
+ login_name = adapter.viewer_login
122
+ @stdout.puts "Authenticated as @#{login_name} on #{DEFAULT_HOST} (scope: #{entry["scope"] || "unknown"})"
123
+ 0
124
+ rescue GemContribute::AuthRequired, GemContribute::AdapterError => e
125
+ @stderr.puts "Token cached for #{DEFAULT_HOST} but verification failed: #{e.message}"
126
+ @stderr.puts "Run `gem-contribute auth login` to refresh."
127
+ 1
128
+ end
129
+
130
+ def logout
131
+ if @store.delete(DEFAULT_HOST)
132
+ @stdout.puts "Logged out of #{DEFAULT_HOST}."
133
+ else
134
+ @stdout.puts "No cached token for #{DEFAULT_HOST}."
135
+ end
136
+ 0
137
+ end
138
+
139
+ def default_browser_opener(uri)
140
+ cmd = case RbConfig::CONFIG["host_os"]
141
+ when /darwin/ then "open"
142
+ when /linux/ then "xdg-open"
143
+ when /mswin|mingw|cygwin/ then "start"
144
+ end
145
+ cmd && Kernel.system(cmd, uri)
146
+ rescue StandardError
147
+ false
148
+ end
149
+
150
+ def default_clipper(text)
151
+ case RbConfig::CONFIG["host_os"]
152
+ when /darwin/
153
+ IO.popen("pbcopy", "w") { |p| p.write(text) }
154
+ true
155
+ when /linux/
156
+ IO.popen(["xclip", "-selection", "clipboard"], "w") { |p| p.write(text) }
157
+ true
158
+ end
159
+ rescue StandardError
160
+ false
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemContribute
4
+ module CLI
5
+ # `gem-contribute config <subcommand>`
6
+ #
7
+ # Subcommands:
8
+ # set <key> <value> Write a value to ~/.config/gem-contribute/config.yml
9
+ # get <key> Print the current value of a key
10
+ # list Print all configured values
11
+ class Config
12
+ USAGE = <<~USAGE
13
+ Usage: gem-contribute config <subcommand>
14
+
15
+ Subcommands:
16
+ set <key> <value> Set a configuration value.
17
+ get <key> Print the current value of a key.
18
+ list Print all configured values.
19
+
20
+ Keys:
21
+ clone_root Directory where forks are cloned (default: ~/code/oss).
22
+ Example: gem-contribute config set clone_root ~/Projects/oss
23
+ USAGE
24
+
25
+ def initialize(stdout: $stdout, stderr: $stderr, config: GemContribute::Config.new)
26
+ @stdout = stdout
27
+ @stderr = stderr
28
+ @config = config
29
+ end
30
+
31
+ def run(argv)
32
+ case argv.shift
33
+ when "set" then set(argv)
34
+ when "get" then get(argv)
35
+ when "list" then list
36
+ when nil, "help", "-h", "--help"
37
+ @stdout.puts USAGE
38
+ 0
39
+ else
40
+ @stderr.puts "gem-contribute: unknown config subcommand"
41
+ @stderr.puts USAGE
42
+ 2
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def set(argv)
49
+ key = argv.shift
50
+ value = argv.shift
51
+ if key.nil? || value.nil?
52
+ @stderr.puts "Usage: gem-contribute config set <key> <value>"
53
+ return 2
54
+ end
55
+
56
+ @config.set(key, value)
57
+ @stdout.puts "#{key} = #{value}"
58
+ 0
59
+ rescue ArgumentError => e
60
+ @stderr.puts e.message
61
+ 1
62
+ end
63
+
64
+ def get(argv)
65
+ key = argv.shift
66
+ if key.nil?
67
+ @stderr.puts "Usage: gem-contribute config get <key>"
68
+ return 2
69
+ end
70
+
71
+ unless GemContribute::Config::KNOWN_KEYS.include?(key)
72
+ @stderr.puts "unknown config key #{key.inspect}"
73
+ return 1
74
+ end
75
+
76
+ @stdout.puts @config.to_h.fetch(key, "(not set — default applies)")
77
+ 0
78
+ end
79
+
80
+ def list
81
+ @stdout.puts "Configuration (#{GemContribute::Config.default_path}):"
82
+ @stdout.puts " clone_root = #{@config.clone_root}"
83
+ 0
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module GemContribute
6
+ module CLI
7
+ # `gem-contribute fork-clone-branch <gem>/<issue#>`
8
+ #
9
+ # Performs the full sequence the TUI's `f` keybinding will trigger in
10
+ # Stage 3:
11
+ #
12
+ # 1. Resolve <gem> via the RubyGems Resolver (no lockfile required;
13
+ # the lockfile is for discovery via `scan`, not gating here).
14
+ # 2. Read the cached GitHub token; raise AuthRequired with a clear
15
+ # `auth login` hint if missing.
16
+ # 3. Look up the viewer's login.
17
+ # 4. If they don't already have a fork, fork the upstream repo.
18
+ # 5. Poll until the fork is reachable (forks return 202 immediately
19
+ # but the resource may 404 for a few seconds).
20
+ # 6. `git clone` the fork to `<clone_root>/<owner>/<repo>`.
21
+ # 7. `git checkout -b gem-contribute/issue-<N>` from the default branch.
22
+ # 8. Print the local path on stdout.
23
+ #
24
+ # The shell-outs use Open3 with explicit args (not strings) to avoid any
25
+ # shell-injection surface.
26
+ class ForkCloneBranch
27
+ DEFAULT_CLONE_ROOT = File.expand_path("~/code/oss")
28
+ BRANCH_PREFIX = "gem-contribute/issue-"
29
+ FORK_READINESS_RETRIES = 12 # 12 × 5s = 60s ceiling
30
+ FORK_READINESS_INTERVAL = 5
31
+
32
+ def initialize(stdout: $stdout,
33
+ stderr: $stderr,
34
+ resolver: Resolver.new,
35
+ store: TokenStore.new,
36
+ adapter_factory: ->(token:) { HostAdapters::GitHubAdapter.new(token: token) },
37
+ git: Git.new,
38
+ clone_root: DEFAULT_CLONE_ROOT,
39
+ sleeper: ->(s) { Kernel.sleep(s) })
40
+ @stdout = stdout
41
+ @stderr = stderr
42
+ @resolver = resolver
43
+ @store = store
44
+ @adapter_factory = adapter_factory
45
+ @git = git
46
+ @clone_root = clone_root
47
+ @sleeper = sleeper
48
+ end
49
+
50
+ def run(argv)
51
+ target = argv.shift
52
+ return print_usage_error if target.nil? || !target.include?("/")
53
+
54
+ gem_name, issue = target.split("/", 2)
55
+ adapter = build_adapter
56
+ return 1 if adapter.nil?
57
+
58
+ project = resolve_or_fail(gem_name)
59
+ return 1 if project.nil?
60
+
61
+ execute(adapter, project, issue)
62
+ rescue AuthRequired
63
+ @stderr.puts "Not authenticated. Run `gem-contribute auth login` first."
64
+ 1
65
+ rescue AdapterError => e
66
+ @stderr.puts "fork-clone-branch failed: #{e.message}"
67
+ 1
68
+ end
69
+
70
+ private
71
+
72
+ def print_usage_error
73
+ @stderr.puts "Usage: gem-contribute fork-clone-branch <gem>/<issue#>"
74
+ 2
75
+ end
76
+
77
+ def build_adapter
78
+ token = @store.token_for("github.com")
79
+ if token.nil?
80
+ @stderr.puts "Not authenticated. Run `gem-contribute auth login` first."
81
+ return nil
82
+ end
83
+ @adapter_factory.call(token: token)
84
+ end
85
+
86
+ def resolve_or_fail(gem_name)
87
+ return GemContribute::SELF_PROJECT if gem_name == GemContribute::SELF_PROJECT.gem_name
88
+
89
+ gem = LockedGem.new(name: gem_name, version: "*", source_type: :rubygems, source_uri: "https://rubygems.org/")
90
+ project = @resolver.resolve(gem)
91
+
92
+ if project.host != "github.com"
93
+ @stderr.puts "Cannot fork-clone-branch: #{gem_name} resolves to #{project.host} " \
94
+ "(only github.com is supported at v0.1)"
95
+ return nil
96
+ end
97
+
98
+ project
99
+ end
100
+
101
+ def execute(adapter, project, issue)
102
+ viewer = adapter.viewer_login
103
+ clone_url = ensure_fork(adapter, project, viewer)
104
+ local_path = clone_into_root(project, clone_url)
105
+ branch_name = "#{BRANCH_PREFIX}#{issue}"
106
+ @git.checkout_branch(local_path, branch_name)
107
+ # `submit` needs to know the canonical project to point the PR at.
108
+ # Naming it `upstream` follows the standard fork workflow convention.
109
+ @git.add_remote(local_path, "upstream",
110
+ "https://github.com/#{project.owner}/#{project.repo}.git")
111
+
112
+ @stdout.puts "Forked, cloned, and branched."
113
+ @stdout.puts " path: #{local_path}"
114
+ @stdout.puts " branch: #{branch_name}"
115
+ @stdout.puts " upstream: https://github.com/#{project.owner}/#{project.repo}"
116
+ @stdout.puts " fork: https://github.com/#{viewer}/#{project.repo}"
117
+ @stdout.puts
118
+ @stdout.puts "Next: cd #{local_path} && make your changes, then `gem-contribute submit`."
119
+ 0
120
+ end
121
+
122
+ def ensure_fork(adapter, project, viewer)
123
+ if adapter.already_forked?(project)
124
+ @stdout.puts "You already have a fork at #{viewer}/#{project.repo}. Skipping fork."
125
+ return "https://github.com/#{viewer}/#{project.repo}.git"
126
+ end
127
+
128
+ @stdout.puts "Forking #{project.owner}/#{project.repo} → #{viewer}/#{project.repo}..."
129
+ body = adapter.fork(project)
130
+ wait_until_ready(adapter, viewer, project.repo)
131
+ body.fetch("clone_url")
132
+ end
133
+
134
+ def wait_until_ready(adapter, viewer, name)
135
+ ready = FORK_READINESS_RETRIES.times.any? do |i|
136
+ break true if adapter.fork_ready?(viewer, name)
137
+
138
+ @sleeper.call(FORK_READINESS_INTERVAL) unless i == FORK_READINESS_RETRIES - 1
139
+ false
140
+ end
141
+ return if ready
142
+
143
+ raise AdapterError, "fork not reachable after #{FORK_READINESS_RETRIES * FORK_READINESS_INTERVAL}s"
144
+ end
145
+
146
+ def clone_into_root(project, clone_url)
147
+ target = File.join(@clone_root, project.owner, project.repo)
148
+ if File.directory?(File.join(target, ".git"))
149
+ @stdout.puts "Reusing existing clone at #{target}."
150
+ return target
151
+ end
152
+
153
+ FileUtils.mkdir_p(File.dirname(target))
154
+ @stdout.puts "Cloning into #{target}..."
155
+ @git.clone(clone_url, target)
156
+ target
157
+ end
158
+ end
159
+
160
+ # Thin wrapper around git so the spec can swap in a fake without shelling
161
+ # out. The real implementation uses Open3 with arg-list invocation — no
162
+ # shell, so no injection surface.
163
+ class Git
164
+ def clone(url, target)
165
+ run!(["git", "clone", url, target])
166
+ end
167
+
168
+ def checkout_branch(path, branch)
169
+ run!(["git", "-C", path, "checkout", "-b", branch])
170
+ end
171
+
172
+ def add_remote(path, name, url)
173
+ # Idempotent: if the remote already exists (e.g. reusing a clone)
174
+ # we silently succeed rather than fail the whole flow.
175
+ return if remote_exists?(path, name)
176
+
177
+ run!(["git", "-C", path, "remote", "add", name, url])
178
+ end
179
+
180
+ def push(path, remote, branch)
181
+ run!(["git", "-C", path, "push", "-u", remote, branch])
182
+ end
183
+
184
+ def remote_exists?(path, name)
185
+ out, _err, status = Open3.capture3("git", "-C", path, "remote")
186
+ status.success? && out.split("\n").include?(name)
187
+ end
188
+
189
+ def run!(argv)
190
+ _stdout, stderr_str, status = Open3.capture3(*argv)
191
+ return if status.success?
192
+
193
+ raise GemContribute::AdapterError, "git #{argv[1..].join(" ")} failed: #{stderr_str.strip}"
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemContribute
4
+ module CLI
5
+ # `gem-contribute issues <gem|all>` — list open "good first issue" issues.
6
+ #
7
+ # With a gem name: lists issues for that gem.
8
+ # With "all": iterates every github.com gem in Gemfile.lock.
9
+ #
10
+ # Issue numbers appear prominently so they can be passed directly to
11
+ # `fork-clone-branch <gem>/<issue#>`.
12
+ class Issues
13
+ DEFAULT_LABEL = "good first issue"
14
+
15
+ def initialize(stdout: $stdout, stderr: $stderr,
16
+ resolver: Resolver.new,
17
+ adapter: HostAdapters::GitHubAdapter.new,
18
+ lockfile_path: "Gemfile.lock")
19
+ @stdout = stdout
20
+ @stderr = stderr
21
+ @resolver = resolver
22
+ @adapter = adapter
23
+ @lockfile_path = lockfile_path
24
+ end
25
+
26
+ def run(argv)
27
+ target = argv.shift
28
+ return print_usage if target.nil?
29
+
30
+ if target == "all"
31
+ run_all
32
+ else
33
+ project = resolve_or_fail(target)
34
+ return 1 if project.nil?
35
+
36
+ list_issues(project)
37
+ end
38
+ rescue AdapterError => e
39
+ @stderr.puts "gem-contribute: #{e.message}"
40
+ 1
41
+ end
42
+
43
+ private
44
+
45
+ def print_usage
46
+ @stderr.puts "Usage: gem-contribute issues <gem|all>"
47
+ 2
48
+ end
49
+
50
+ def run_all
51
+ gems = LockfileParser.parse(@lockfile_path)
52
+ projects = gems.filter_map do |gem|
53
+ project = @resolver.resolve(gem)
54
+ project if project.host == "github.com"
55
+ end
56
+
57
+ @stdout.puts "Scanning #{projects.size} github.com gems from #{@lockfile_path}...\n\n"
58
+
59
+ any = false
60
+ projects.each do |project|
61
+ issues = fetch_issues(project)
62
+ next if issues.empty?
63
+
64
+ any = true
65
+ print_project_issues(project, issues)
66
+ end
67
+
68
+ @stdout.puts "(no good first issues found across #{projects.size} gems)" unless any
69
+ 0
70
+ rescue LockfileNotFound => e
71
+ @stderr.puts "gem-contribute: #{e.message}"
72
+ 1
73
+ end
74
+
75
+ def fetch_issues(project)
76
+ @adapter.issues(project, labels: [DEFAULT_LABEL])
77
+ rescue AdapterError => e
78
+ @stderr.puts " warning: #{project.gem_name}: #{e.message}"
79
+ []
80
+ end
81
+
82
+ def resolve_or_fail(gem_name)
83
+ # gem-contribute isn't on RubyGems yet; short-circuit to the canonical
84
+ # self-project so the tool's own issues are reachable today.
85
+ return GemContribute::SELF_PROJECT if gem_name == GemContribute::SELF_PROJECT.gem_name
86
+
87
+ gem = LockedGem.new(name: gem_name, version: "*",
88
+ source_type: :rubygems, source_uri: "https://rubygems.org/")
89
+ project = @resolver.resolve(gem)
90
+
91
+ if project.host != "github.com"
92
+ @stderr.puts "#{gem_name}: resolves to #{project.host} (only github.com is supported)"
93
+ return nil
94
+ end
95
+
96
+ project
97
+ end
98
+
99
+ def list_issues(project)
100
+ issues = @adapter.issues(project, labels: [DEFAULT_LABEL])
101
+ print_project_issues(project, issues)
102
+ @stdout.puts "To contribute: gem-contribute fix #{project.gem_name}/<issue#>"
103
+ 0
104
+ end
105
+
106
+ def print_project_issues(project, issues)
107
+ repo_url = "https://github.com/#{project.owner}/#{project.repo}"
108
+ @stdout.puts "#{project.gem_name} — #{issues.size} open \"#{DEFAULT_LABEL}\" issues (#{repo_url})"
109
+
110
+ if issues.empty?
111
+ @stdout.puts " (none — browse #{repo_url}/issues directly)"
112
+ else
113
+ @stdout.puts
114
+ issues.each do |issue|
115
+ @stdout.puts " ##{issue["number"]} #{issue["title"]}"
116
+ @stdout.puts " #{issue["html_url"]}"
117
+ @stdout.puts
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemContribute
4
+ module CLI
5
+ # `gem-contribute scan [path]` — Stage 1's command.
6
+ #
7
+ # Reads a Gemfile.lock, resolves each rubygems-sourced gem, hits the
8
+ # GitHub adapter for `good first issue`-tagged issue counts on every
9
+ # github.com project, and prints:
10
+ #
11
+ # <N> gems · <N> on github.com · <N> on <other> · <N> unknown source
12
+ #
13
+ # Top contributable projects (by open `good first issue` count):
14
+ # <gem-name> <count> <github.com/owner/repo>
15
+ # ...
16
+ class Scan
17
+ # GitHub's `labels=foo,bar` query is an AND, not an OR, so passing the
18
+ # full set of beginner-friendly variants returns almost nothing. Stage 1
19
+ # uses the canonical `good first issue` label only — the "render labels
20
+ # verbatim" promise in ADR-0005 belongs to display, not to server-side
21
+ # filtering. Future stages can call once per label and dedupe.
22
+ DEFAULT_LABEL = "good first issue"
23
+
24
+ def initialize(stdout: $stdout, stderr: $stderr, resolver: Resolver.new, adapter: HostAdapters::GitHubAdapter.new)
25
+ @stdout = stdout
26
+ @stderr = stderr
27
+ @resolver = resolver
28
+ @adapter = adapter
29
+ end
30
+
31
+ # @param argv [Array<String>] passed-in args (no leading "scan")
32
+ # @return [Integer] exit status
33
+ def run(argv)
34
+ path = argv.first || "Gemfile.lock"
35
+ gems = LockfileParser.parse(path)
36
+ @stdout.puts "Scanning #{path} (#{gems.size} gems)..."
37
+
38
+ projects = gems.map { |gem| @resolver.resolve(gem) }
39
+ # Summary tally reflects only the lockfile contents — the
40
+ # self-injection is intentionally additive, not part of the count.
41
+ print_summary(tally_hosts(projects), gems.size)
42
+ scan_github_projects(projects)
43
+ 0
44
+ rescue LockfileNotFound => e
45
+ @stderr.puts "gem-contribute: #{e.message}"
46
+ 1
47
+ rescue Errno::ECONNREFUSED, SocketError => e
48
+ @stderr.puts "gem-contribute: network unreachable (#{e.class}: #{e.message})"
49
+ @stderr.puts "Re-run when you have connectivity, or use cached data with --refresh disabled."
50
+ 1
51
+ end
52
+
53
+ private
54
+
55
+ def inject_self(github_projects)
56
+ return github_projects if github_projects.any? { |p| p.gem_name == GemContribute::SELF_PROJECT.gem_name }
57
+
58
+ github_projects + [GemContribute::SELF_PROJECT]
59
+ end
60
+
61
+ def scan_github_projects(projects)
62
+ github_from_lockfile = projects.select { |p| p.host == "github.com" }
63
+ @stdout.puts "\nNo github.com projects in this lockfile." if github_from_lockfile.empty?
64
+
65
+ ranked = rank_by_issue_count(inject_self(github_from_lockfile))
66
+ return if ranked.empty?
67
+
68
+ print_ranked(ranked)
69
+ end
70
+
71
+ def tally_hosts(projects)
72
+ counts = Hash.new(0)
73
+ projects.each { |p| counts[p.host] += 1 }
74
+ counts
75
+ end
76
+
77
+ def print_summary(host_counts, total)
78
+ parts = ["#{total} gems"]
79
+ host_counts.each do |host, count|
80
+ label = host == :unknown ? "unknown source" : "on #{host}"
81
+ parts << "#{count} #{label}"
82
+ end
83
+ @stdout.puts parts.join(" · ")
84
+ end
85
+
86
+ def rank_by_issue_count(projects)
87
+ # We hit the API anonymously here. With a 60/hr unauthenticated rate
88
+ # limit, scanning a 50-gem lockfile is the dominant pressure on this
89
+ # CLI. The 7-day RubyGems and 5-min issues caches absorb most repeats.
90
+ results = projects.map do |project|
91
+ count = issue_count(project)
92
+ [project, count]
93
+ end.compact
94
+
95
+ results.reject { |_, count| count.zero? }.sort_by { |_, count| -count }
96
+ end
97
+
98
+ def issue_count(project)
99
+ issues = @adapter.issues(project, labels: [DEFAULT_LABEL])
100
+ issues.size
101
+ rescue AdapterError, AuthRequired => e
102
+ @stderr.puts " warning: #{project.gem_name} (#{project.host}/#{project.owner}/#{project.repo}): #{e.message}"
103
+ 0
104
+ end
105
+
106
+ def print_ranked(ranked)
107
+ @stdout.puts
108
+ @stdout.puts "Top contributable projects (by open `good first issue` count):"
109
+ col_name = ranked.map { |p, _| p.gem_name.length }.max
110
+ ranked.each do |project, count|
111
+ location = "#{project.host}/#{project.owner}/#{project.repo}"
112
+ @stdout.printf(" %-#{col_name}s %3d %s\n", project.gem_name, count, location)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end