gem-contribute 0.2.0 → 0.3.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 +4 -4
  2. data/.github/PULL_REQUEST_TEMPLATE.md +14 -8
  3. data/.github/workflows/ci.yml +26 -0
  4. data/.github/workflows/pr-template-check.yml +100 -0
  5. data/CHANGELOG.md +26 -0
  6. data/CLAUDE.md +1 -1
  7. data/CONTRIBUTING.md +10 -4
  8. data/README.md +13 -1
  9. data/docs/OPEN_QUESTIONS.md +167 -0
  10. data/docs/ROADMAP.md +266 -0
  11. data/docs/adr/0006-standalone-gem-not-plugin.md +1 -1
  12. data/docs/adr/0008-rooibos-tui-framework.md +3 -3
  13. data/docs/adr/0010-charm-ruby-tui-framework.md +2 -2
  14. data/docs/adr/0011-host-adapter-owns-host-verbs.md +58 -0
  15. data/docs/adr/0012-output-free-service-objects-three-interface-architecture.md +79 -0
  16. data/docs/adr/0013-revert-to-rooibos.md +71 -0
  17. data/docs/adr/0014-ship-bundler-and-rubygems-plugins.md +75 -0
  18. data/docs/adr/README.md +7 -3
  19. data/docs/design-interface-layer.md +295 -0
  20. data/docs/design.md +31 -8
  21. data/docs/index.md +1 -1
  22. data/docs/prep-plan.md +6 -6
  23. data/lib/gem_contribute/cli/auth.rb +22 -44
  24. data/lib/gem_contribute/cli/config.rb +29 -15
  25. data/lib/gem_contribute/cli/fix.rb +122 -0
  26. data/lib/gem_contribute/cli/fork.rb +145 -0
  27. data/lib/gem_contribute/cli/init.rb +19 -24
  28. data/lib/gem_contribute/cli/issue_announcer.rb +42 -0
  29. data/lib/gem_contribute/cli/issues.rb +36 -47
  30. data/lib/gem_contribute/cli/platform_tools.rb +33 -0
  31. data/lib/gem_contribute/cli/post_clone_hooks.rb +50 -0
  32. data/lib/gem_contribute/cli/rate_limit_footer.rb +5 -3
  33. data/lib/gem_contribute/cli/scan.rb +20 -16
  34. data/lib/gem_contribute/cli/submit.rb +60 -64
  35. data/lib/gem_contribute/cli/workflow.rb +63 -0
  36. data/lib/gem_contribute/cli.rb +9 -16
  37. data/lib/gem_contribute/config.rb +27 -1
  38. data/lib/gem_contribute/git.rb +49 -0
  39. data/lib/gem_contribute/host_adapter.rb +52 -5
  40. data/lib/gem_contribute/host_adapters/github_adapter.rb +126 -37
  41. data/lib/gem_contribute/operations/announce.rb +52 -0
  42. data/lib/gem_contribute/operations/branch.rb +35 -0
  43. data/lib/gem_contribute/operations/clone.rb +41 -0
  44. data/lib/gem_contribute/operations/fix_pipeline.rb +70 -0
  45. data/lib/gem_contribute/operations/fork.rb +35 -0
  46. data/lib/gem_contribute/output/null.rb +20 -0
  47. data/lib/gem_contribute/output/standard.rb +71 -0
  48. data/lib/gem_contribute/version.rb +1 -1
  49. data/lib/gem_contribute.rb +10 -18
  50. metadata +109 -3
  51. data/lib/gem_contribute/cli/fork_clone_branch.rb +0 -204
@@ -21,11 +21,18 @@ module GemContribute
21
21
  clone_root Directory where forks are cloned. Set with
22
22
  `gem-contribute init` (interactive) or
23
23
  `gem-contribute config set clone_root <path>`.
24
+ editor Editor command for `fix -e`. Falls back to $EDITOR.
25
+ Example: gem-contribute config set editor code
26
+ ai_tool Shell command for `fix -a` (run in clone dir).
27
+ Example: gem-contribute config set ai_tool "claude ."
28
+ comment_on_fix Whether `fix` posts a "working on this" comment.
29
+ Default: true. Per-repo overrides via
30
+ `comment_on_fix_overrides` in the YAML.
24
31
  USAGE
25
32
 
26
- def initialize(stdout: $stdout, stderr: $stderr, config: GemContribute::Config.new)
27
- @stdout = stdout
28
- @stderr = stderr
33
+ def initialize(stdout: $stdout, stderr: $stderr, output: nil,
34
+ config: GemContribute::Config.new)
35
+ @output = output || Output::Standard.new(out: stdout, err: stderr)
29
36
  @config = config
30
37
  end
31
38
 
@@ -35,11 +42,11 @@ module GemContribute
35
42
  when "get" then get(argv)
36
43
  when "list" then list
37
44
  when nil, "help", "-h", "--help"
38
- @stdout.puts USAGE
45
+ @output.info(USAGE)
39
46
  0
40
47
  else
41
- @stderr.puts "gem-contribute: unknown config subcommand"
42
- @stderr.puts USAGE
48
+ @output.error("gem-contribute: unknown config subcommand")
49
+ @output.error(USAGE)
43
50
  2
44
51
  end
45
52
  end
@@ -50,38 +57,45 @@ module GemContribute
50
57
  key = argv.shift
51
58
  value = argv.shift
52
59
  if key.nil? || value.nil?
53
- @stderr.puts "Usage: gem-contribute config set <key> <value>"
60
+ @output.error("Usage: gem-contribute config set <key> <value>")
54
61
  return 2
55
62
  end
56
63
 
57
64
  @config.set(key, value)
58
- @stdout.puts "#{key} = #{value}"
65
+ @output.info("#{key} = #{value}")
59
66
  0
60
67
  rescue ArgumentError => e
61
- @stderr.puts e.message
68
+ @output.error(e.message)
62
69
  1
63
70
  end
64
71
 
65
72
  def get(argv)
66
73
  key = argv.shift
67
74
  if key.nil?
68
- @stderr.puts "Usage: gem-contribute config get <key>"
75
+ @output.error("Usage: gem-contribute config get <key>")
69
76
  return 2
70
77
  end
71
78
 
72
79
  unless GemContribute::Config::KNOWN_KEYS.include?(key)
73
- @stderr.puts "unknown config key #{key.inspect}"
80
+ @output.error("unknown config key #{key.inspect}")
74
81
  return 1
75
82
  end
76
83
 
77
- @stdout.puts @config.to_h.fetch(key, "(not set; run `gem-contribute init`)")
84
+ @output.info(@config.to_h.fetch(key, "(not set; run `gem-contribute init`)"))
78
85
  0
79
86
  end
80
87
 
81
88
  def list
82
- @stdout.puts "Configuration (#{GemContribute::Config.default_path}):"
83
- display = @config.clone_root || "(not set; run `gem-contribute init`)"
84
- @stdout.puts " clone_root = #{display}"
89
+ @output.info("Configuration (#{GemContribute::Config.default_path}):")
90
+ @output.info(" clone_root = #{@config.clone_root || "(not set; run `gem-contribute init`)"}")
91
+ @output.info(" editor = #{@config.editor || "(not set)"}")
92
+ @output.info(" ai_tool = #{@config.ai_tool || "(not set)"}")
93
+ @output.info(" comment_on_fix = #{@config.comment_on_fix?}")
94
+ overrides = @config.to_h["comment_on_fix_overrides"]
95
+ if overrides.is_a?(Hash) && !overrides.empty?
96
+ @output.info(" comment_on_fix_overrides:")
97
+ overrides.each { |repo, val| @output.info(" #{repo}: #{val}") }
98
+ end
85
99
  0
86
100
  end
87
101
  end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/initializer"
4
+ require "dry/monads"
5
+
6
+ module GemContribute
7
+ module CLI
8
+ # `gem-contribute fix <gem>/<issue#> [-e] [-a] [--no-comment]`
9
+ #
10
+ # The issue-tied path: run `Operations::FixPipeline` (Fork → Clone →
11
+ # Branch → Announce), then optionally open the user's editor or AI
12
+ # tool. The verb is a thin Result-pattern-matching shell around the
13
+ # pipeline (per ADR-0012).
14
+ class Fix
15
+ extend Dry::Initializer
16
+ include Workflow
17
+ include Dry::Monads[:result]
18
+
19
+ DEFAULT_CLONE_ROOT = File.expand_path("~/code/oss")
20
+
21
+ option :stdout, default: -> { $stdout }
22
+ option :stderr, default: -> { $stderr }
23
+ option :output, default: -> { Output::Standard.new(out: stdout, err: stderr) }
24
+ option :resolver, default: -> { Resolver.new }
25
+ option :store, default: -> { TokenStore.new }
26
+ option :adapter_factory,
27
+ default: -> { ->(token:) { HostAdapters::GitHubAdapter.new(token: token) } }
28
+ option :git, default: -> { GemContribute::Git.new }
29
+ option :clone_root, default: -> { DEFAULT_CLONE_ROOT }
30
+ option :post_clone_hooks, default: -> { PostCloneHooks.new(output: output) }
31
+ option :config, default: -> { GemContribute::Config.new }
32
+ option :pipeline, default: -> { Operations::FixPipeline.new(git: git) }
33
+
34
+ def run(argv)
35
+ return missing_clone_root if @clone_root.nil?
36
+
37
+ target, flags = parse_argv(argv)
38
+ return print_usage_error if target.nil? || !target.include?("/")
39
+
40
+ gem_name, issue = target.split("/", 2)
41
+
42
+ case build_adapter
43
+ in Success(adapter)
44
+ project = resolve_target(gem_name, verb: "fix")
45
+ return 1 if project.nil?
46
+
47
+ execute(adapter, project, issue, flags)
48
+ in Failure(:unauthenticated)
49
+ @output.error("Not authenticated. Run `gem-contribute auth login` first.")
50
+ 1
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def parse_argv(argv)
57
+ flags = { editor: false, ai_tool: false, no_comment: false }
58
+ positional = []
59
+ argv.each do |arg|
60
+ case arg
61
+ when "-e", "--editor" then flags[:editor] = true
62
+ when "-a", "--ai" then flags[:ai_tool] = true
63
+ when "--no-comment" then flags[:no_comment] = true
64
+ else positional << arg
65
+ end
66
+ end
67
+ [positional.first, flags]
68
+ end
69
+
70
+ def print_usage_error
71
+ @output.error("Usage: gem-contribute fix <gem>/<issue#> [-e] [-a]")
72
+ 2
73
+ end
74
+
75
+ def execute(adapter, project, issue, flags)
76
+ allow_announce = !flags[:no_comment] &&
77
+ @config.comment_on_fix?("#{project.owner}/#{project.repo}")
78
+
79
+ result = @output.progress("Forking #{project.owner}/#{project.repo}...") do
80
+ @pipeline.call(adapter: adapter, project: project, issue: issue,
81
+ root: @clone_root, allow_announce: allow_announce)
82
+ end
83
+
84
+ case result
85
+ in Success(fork: fork_data, clone: clone_data, branch: branch_data, announce: announce_data)
86
+ print_summary(clone_data.path, branch_data.name, fork_data)
87
+ print_announce_outcome(announce_data, issue)
88
+ @post_clone_hooks.call(clone_data.path, editor: flags[:editor], ai_tool: flags[:ai_tool])
89
+ 0
90
+ in Failure(:unauthenticated)
91
+ @output.error("Not authenticated. Run `gem-contribute auth login` first.")
92
+ 1
93
+ in Failure(:adapter_error, message)
94
+ @output.error("fix failed: #{message}")
95
+ 1
96
+ end
97
+ end
98
+
99
+ def print_summary(local_path, branch_name, fork_info)
100
+ @output.info("Forked, cloned, and branched.")
101
+ @output.info(" path: #{local_path}")
102
+ @output.info(" branch: #{branch_name}")
103
+ @output.info(" upstream: #{fork_info.upstream_url}")
104
+ @output.info(" fork: #{fork_info.fork_url}")
105
+ @output.info("")
106
+ @output.info("Next: cd #{local_path} && make your changes, then `gem-contribute submit`.")
107
+ end
108
+
109
+ def print_announce_outcome(announce_result, issue)
110
+ case announce_result
111
+ in Success(:posted)
112
+ @output.info("Posted 'working on this' comment to issue ##{issue}.")
113
+ in Success(:skipped)
114
+ # no output for skipped — quiet success
115
+ in Failure(:announce_failed, message)
116
+ @output.warn("Note: couldn't post 'working on this' comment to issue ##{issue}: " \
117
+ "#{message}. Continuing.")
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/initializer"
4
+ require "dry/monads"
5
+
6
+ module GemContribute
7
+ module CLI
8
+ # `gem-contribute fork <gem|owner/repo> [-e] [-a]`. Resolve the target,
9
+ # bootstrap a fork+clone via `Operations::Fork` + `Operations::Clone`,
10
+ # print a summary, run post-clone hooks. The CLI verb is a thin
11
+ # composition; the host-API ceremony lives in the adapter and the
12
+ # filesystem policy lives in Operations (ADR-0011). Operations are
13
+ # output-free per ADR-0012; this verb does the printing.
14
+ class Fork
15
+ extend Dry::Initializer
16
+ include Workflow
17
+ include Dry::Monads[:result]
18
+
19
+ DEFAULT_CLONE_ROOT = File.expand_path("~/code/oss")
20
+
21
+ option :stdout, default: -> { $stdout }
22
+ option :stderr, default: -> { $stderr }
23
+ option :output, default: -> { Output::Standard.new(out: stdout, err: stderr) }
24
+ option :resolver, default: -> { Resolver.new }
25
+ option :store, default: -> { TokenStore.new }
26
+ option :adapter_factory,
27
+ default: -> { ->(token:) { HostAdapters::GitHubAdapter.new(token: token) } }
28
+ option :git, default: -> { GemContribute::Git.new }
29
+ option :clone_root, default: -> { DEFAULT_CLONE_ROOT }
30
+ option :post_clone_hooks, default: -> { PostCloneHooks.new(output: output) }
31
+ option :fork_op, default: -> { Operations::Fork.new }
32
+ option :clone_op, default: -> { Operations::Clone.new(git: git) }
33
+
34
+ def run(argv)
35
+ return missing_clone_root if @clone_root.nil?
36
+
37
+ target, flags = parse_argv(argv)
38
+ return print_usage_error if target.nil?
39
+
40
+ case build_adapter
41
+ in Success(adapter)
42
+ project = resolve_target(target, verb: "fork", allow_owner_repo: true)
43
+ return 1 if project.nil?
44
+
45
+ execute(adapter, project, flags)
46
+ in Failure(:unauthenticated)
47
+ @output.error("Not authenticated. Run `gem-contribute auth login` first.")
48
+ 1
49
+ end
50
+ end
51
+
52
+ # The bootstrap primitive Fix shares: fork (or reuse) → clone (or
53
+ # reuse) → upstream remote. Returns `Success([local_path, fork_info])`
54
+ # on the happy path; `Failure(reason)` propagated from Operations
55
+ # otherwise.
56
+ def bootstrap(adapter, project)
57
+ @output.progress("Forking #{project.owner}/#{project.repo}...")
58
+ fork_result = @fork_op.call(adapter: adapter, project: project)
59
+ return fork_result if fork_result.failure?
60
+
61
+ fork_info = fork_result.value!
62
+ @output.info(fork_status_line(fork_info, project))
63
+
64
+ clone_result = @clone_op.call(adapter: adapter, project: project,
65
+ fork_clone_url: fork_info.clone_url, root: @clone_root)
66
+ return clone_result if clone_result.failure?
67
+
68
+ clone_info = clone_result.value!
69
+ @output.info(clone_status_line(clone_info))
70
+
71
+ Success([clone_info.path, fork_info])
72
+ end
73
+
74
+ private
75
+
76
+ def parse_argv(argv)
77
+ flags = { editor: false, ai_tool: false }
78
+ positional = []
79
+ argv.each do |arg|
80
+ case arg
81
+ when "-e", "--editor" then flags[:editor] = true
82
+ when "-a", "--ai" then flags[:ai_tool] = true
83
+ else positional << arg
84
+ end
85
+ end
86
+ [positional.first, flags]
87
+ end
88
+
89
+ def print_usage_error
90
+ @output.error("Usage: gem-contribute fork <gem|owner/repo> [-e] [-a]")
91
+ 2
92
+ end
93
+
94
+ def execute(adapter, project, flags)
95
+ case bootstrap(adapter, project)
96
+ in Success(local_path, fork_info)
97
+ print_summary(local_path, project, fork_info, flags)
98
+ @post_clone_hooks.call(local_path, editor: flags[:editor], ai_tool: flags[:ai_tool])
99
+ 0
100
+ in Failure(:unauthenticated)
101
+ @output.error("Not authenticated. Run `gem-contribute auth login` first.")
102
+ 1
103
+ in Failure(:adapter_error, message)
104
+ @output.error("fork failed: #{message}")
105
+ 1
106
+ end
107
+ end
108
+
109
+ def fork_status_line(info, project)
110
+ if info.reused
111
+ " Reusing existing fork at #{info.viewer}/#{project.repo}."
112
+ else
113
+ " Forked → #{info.viewer}/#{project.repo}."
114
+ end
115
+ end
116
+
117
+ def clone_status_line(info)
118
+ if info.reused
119
+ "Reusing existing clone at #{info.path}."
120
+ else
121
+ "Cloned into #{info.path}."
122
+ end
123
+ end
124
+
125
+ def print_summary(local_path, project, fork_info, flags)
126
+ @output.info("Forked and cloned. You're on the default branch.")
127
+ @output.info(" path: #{local_path}")
128
+ @output.info(" upstream: #{fork_info.upstream_url}")
129
+ @output.info(" fork: #{fork_info.fork_url}")
130
+ @output.info("")
131
+ @output.info(next_hint(local_path, project, flags))
132
+ end
133
+
134
+ def next_hint(local_path, project, flags)
135
+ fix_command = "`gem-contribute fix #{project.gem_name}/<issue#>`"
136
+ if flags[:editor] || flags[:ai_tool]
137
+ "Next: pick an issue and run #{fix_command} to branch off the default."
138
+ else
139
+ "Next: cd #{local_path} && $EDITOR . " \
140
+ "When you pick an issue, #{fix_command} branches off the default."
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tty-prompt"
4
+
3
5
  module GemContribute
4
6
  module CLI
5
7
  # `gem-contribute init` — interactive one-time setup. Writes the user's
@@ -8,6 +10,10 @@ module GemContribute
8
10
  #
9
11
  # Without init, `fix` errors with a hint to run init. The point is to
10
12
  # avoid creating directories or assuming auth without explicit consent.
13
+ #
14
+ # Prompt input/output goes through `TTY::Prompt` (per ADR-0012 Phase 2,
15
+ # commit #31). The injected `prompt:` keyword lets tests pass a
16
+ # `TTY::Prompt.new(input:, output:)` with StringIO streams.
11
17
  class Init
12
18
  USAGE = <<~USAGE
13
19
  Usage: gem-contribute init
@@ -20,17 +26,16 @@ module GemContribute
20
26
  DEFAULT_SUGGESTION = "~/code/oss"
21
27
  AUTH_HOST = "github.com"
22
28
 
23
- def initialize(stdout: $stdout, stderr: $stderr,
29
+ def initialize(stdout: $stdout, stderr: $stderr, output: nil,
24
30
  config: GemContribute::Config.new,
25
31
  store: GemContribute::TokenStore.new,
26
32
  auth: nil,
27
- gets: -> { $stdin.gets })
28
- @stdout = stdout
29
- @stderr = stderr
33
+ prompt: nil)
34
+ @output = output || Output::Standard.new(out: stdout, err: stderr)
30
35
  @config = config
31
36
  @store = store
32
- @auth = auth || GemContribute::CLI::Auth.new(stdout: stdout, stderr: stderr, store: store)
33
- @gets = gets
37
+ @auth = auth || GemContribute::CLI::Auth.new(output: @output, store: store)
38
+ @prompt = prompt || TTY::Prompt.new
34
39
  end
35
40
 
36
41
  def run(argv)
@@ -46,36 +51,26 @@ module GemContribute
46
51
  def prompt_clone_root
47
52
  current = @config.to_h["clone_root"]
48
53
  default = current || DEFAULT_SUGGESTION
49
-
50
- @stdout.print "Where should I clone repos? [#{default}]: "
51
- @stdout.flush
52
- input = @gets.call.to_s.chomp.strip
53
- chosen = input.empty? ? default : input
54
-
54
+ chosen = @prompt.ask("Where should I clone repos?", default: default)
55
55
  @config.set("clone_root", chosen)
56
- @stdout.puts "Clone root set to #{File.expand_path(chosen)}"
56
+ @output.info("Clone root set to #{File.expand_path(chosen)}")
57
57
  end
58
58
 
59
59
  def maybe_authenticate
60
60
  if @store.token_for(AUTH_HOST)
61
- @stdout.puts "GitHub: already authenticated."
61
+ @output.info("GitHub: already authenticated.")
62
62
  return
63
63
  end
64
64
 
65
- @stdout.print "Authenticate with GitHub now? [Y/n]: "
66
- @stdout.flush
67
- answer = @gets.call.to_s.chomp.strip.downcase
68
-
69
- if %w[n no].include?(answer)
70
- @stdout.puts "Skipping auth. Run `gem-contribute auth login` when you're ready."
71
- return
65
+ if @prompt.yes?("Authenticate with GitHub now?", default: true)
66
+ @auth.run(["login"])
67
+ else
68
+ @output.info("Skipping auth. Run `gem-contribute auth login` when you're ready.")
72
69
  end
73
-
74
- @auth.run(["login"])
75
70
  end
76
71
 
77
72
  def print_usage
78
- @stdout.puts USAGE
73
+ @output.info(USAGE)
79
74
  0
80
75
  end
81
76
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemContribute
4
+ module CLI
5
+ # CLI-side helpers for the "I'm working on this" claim machinery.
6
+ #
7
+ # The actual posting (and gating) lives in `Operations::Announce`
8
+ # (output-free, Result-returning) — see ADR-0012. What remains here
9
+ # is the index-fetching used by `scan` and `issues` to flag claimed
10
+ # issues in their output. Both halves share `Operations::Announce::WORKING_MARKER`.
11
+ module IssueAnnouncer
12
+ MARKER = Operations::Announce::WORKING_MARKER
13
+
14
+ module_function
15
+
16
+ # Builds a lookup hash of {"owner/repo" => Set<issue_number>} from
17
+ # GitHub's issue search for our marker. Used by `scan` and `issues`
18
+ # to flag claimed issues. One search call per process (the adapter
19
+ # caches the result for the issues TTL). Degrades to an empty hash
20
+ # if the search fails (anonymous rate limits, network, etc.).
21
+ def fetch_claim_index(adapter)
22
+ items = adapter.search_issues("\"#{MARKER}\" is:issue is:open")
23
+ items.each_with_object(Hash.new { |h, k| h[k] = [] }) do |item, index|
24
+ parsed = parse_issue_url(item["html_url"])
25
+ next unless parsed
26
+
27
+ owner, repo, number = parsed
28
+ index["#{owner}/#{repo}"] << number
29
+ end
30
+ rescue GemContribute::AdapterError, GemContribute::AuthRequired
31
+ {}
32
+ end
33
+
34
+ def parse_issue_url(url)
35
+ match = url.to_s.match(%r{github\.com/([^/]+)/([^/]+)/issues/(\d+)})
36
+ return nil unless match
37
+
38
+ [match[1], match[2], match[3].to_i]
39
+ end
40
+ end
41
+ end
42
+ end
@@ -8,16 +8,17 @@ module GemContribute
8
8
  # With "all": iterates every github.com gem in Gemfile.lock.
9
9
  #
10
10
  # Issue numbers appear prominently so they can be passed directly to
11
- # `fork-clone-branch <gem>/<issue#>`.
11
+ # `fix <gem>/<issue#>`.
12
12
  class Issues
13
+ include Workflow
14
+
13
15
  DEFAULT_LABEL = "good first issue"
14
16
 
15
- def initialize(stdout: $stdout, stderr: $stderr,
17
+ def initialize(stdout: $stdout, stderr: $stderr, output: nil,
16
18
  resolver: Resolver.new,
17
19
  adapter: HostAdapters::GitHubAdapter.new,
18
20
  lockfile_path: "Gemfile.lock")
19
- @stdout = stdout
20
- @stderr = stderr
21
+ @output = output || Output::Standard.new(out: stdout, err: stderr)
21
22
  @resolver = resolver
22
23
  @adapter = adapter
23
24
  @lockfile_path = lockfile_path
@@ -27,30 +28,29 @@ module GemContribute
27
28
  target = argv.shift
28
29
  return print_usage if target.nil?
29
30
 
30
- status = if target == "all"
31
- run_all
32
- else
33
- project = resolve_or_fail(target)
34
- if project.nil?
35
- 1
36
- else
37
- list_issues(project)
38
- end
39
- end
40
- RateLimitFooter.print(adapter: @adapter, stdout: @stdout)
31
+ @claim_index = IssueAnnouncer.fetch_claim_index(@adapter)
32
+ status = target == "all" ? run_all : run_single(target)
33
+ RateLimitFooter.print(adapter: @adapter, output: @output)
41
34
  status
42
35
  rescue AdapterError => e
43
- @stderr.puts "gem-contribute: #{e.message}"
36
+ @output.error("gem-contribute: #{e.message}")
44
37
  1
45
38
  end
46
39
 
47
40
  private
48
41
 
49
42
  def print_usage
50
- @stderr.puts "Usage: gem-contribute issues <gem|all>"
43
+ @output.error("Usage: gem-contribute issues <gem|all>")
51
44
  2
52
45
  end
53
46
 
47
+ def run_single(target)
48
+ project = resolve_target(target, verb: "issues")
49
+ return 1 if project.nil?
50
+
51
+ list_issues(project)
52
+ end
53
+
54
54
  def run_all
55
55
  gems = LockfileParser.parse(@lockfile_path)
56
56
  projects = gems.filter_map do |gem|
@@ -58,7 +58,7 @@ module GemContribute
58
58
  project if project.host == "github.com"
59
59
  end
60
60
 
61
- @stdout.puts "Scanning #{projects.size} github.com gems from #{@lockfile_path}...\n\n"
61
+ @output.info("Scanning #{projects.size} github.com gems from #{@lockfile_path}...\n")
62
62
 
63
63
  any = false
64
64
  projects.each do |project|
@@ -69,57 +69,46 @@ module GemContribute
69
69
  print_project_issues(project, issues)
70
70
  end
71
71
 
72
- @stdout.puts "(no good first issues found across #{projects.size} gems)" unless any
72
+ @output.info("(no good first issues found across #{projects.size} gems)") unless any
73
73
  0
74
74
  rescue LockfileNotFound => e
75
- @stderr.puts "gem-contribute: #{e.message}"
75
+ @output.error("gem-contribute: #{e.message}")
76
76
  1
77
77
  end
78
78
 
79
79
  def fetch_issues(project)
80
80
  @adapter.issues(project, labels: [DEFAULT_LABEL])
81
81
  rescue AdapterError => e
82
- @stderr.puts " warning: #{project.gem_name}: #{e.message}"
82
+ @output.warn(" warning: #{project.gem_name}: #{e.message}")
83
83
  []
84
84
  end
85
85
 
86
- def resolve_or_fail(gem_name)
87
- # gem-contribute isn't on RubyGems yet; short-circuit to the canonical
88
- # self-project so the tool's own issues are reachable today.
89
- return GemContribute::SELF_PROJECT if gem_name == GemContribute::SELF_PROJECT.gem_name
90
-
91
- gem = LockedGem.new(name: gem_name, version: "*",
92
- source_type: :rubygems, source_uri: "https://rubygems.org/")
93
- project = @resolver.resolve(gem)
94
-
95
- if project.host != "github.com"
96
- @stderr.puts "#{gem_name}: resolves to #{project.host} (only github.com is supported)"
97
- return nil
98
- end
99
-
100
- project
101
- end
102
-
103
86
  def list_issues(project)
104
87
  issues = @adapter.issues(project, labels: [DEFAULT_LABEL])
105
88
  print_project_issues(project, issues)
106
- @stdout.puts "To contribute: gem-contribute fix #{project.gem_name}/<issue#>"
89
+ @output.info("To contribute: gem-contribute fix #{project.gem_name}/<issue#>")
107
90
  0
108
91
  end
109
92
 
110
93
  def print_project_issues(project, issues)
111
94
  repo_url = "https://github.com/#{project.owner}/#{project.repo}"
112
- @stdout.puts "#{project.gem_name} — #{issues.size} open \"#{DEFAULT_LABEL}\" issues (#{repo_url})"
95
+ @output.info("#{project.gem_name} — #{issues.size} open \"#{DEFAULT_LABEL}\" issues (#{repo_url})")
113
96
 
114
97
  if issues.empty?
115
- @stdout.puts " (none — browse #{repo_url}/issues directly)"
98
+ @output.info(" (none — browse #{repo_url}/issues directly)")
116
99
  else
117
- @stdout.puts
118
- issues.each do |issue|
119
- @stdout.puts " ##{issue["number"]} #{issue["title"]}"
120
- @stdout.puts " #{issue["html_url"]}"
121
- @stdout.puts
122
- end
100
+ @output.info("")
101
+ print_issue_list(project, issues)
102
+ end
103
+ end
104
+
105
+ def print_issue_list(project, issues)
106
+ claimed = @claim_index["#{project.owner}/#{project.repo}"] || []
107
+ issues.each do |issue|
108
+ label = claimed.include?(issue["number"]) ? "[claimed] " : ""
109
+ @output.info(" ##{issue["number"]} #{label}#{issue["title"]}")
110
+ @output.info(" #{issue["html_url"]}")
111
+ @output.info("")
123
112
  end
124
113
  end
125
114
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemContribute
4
+ module CLI
5
+ module PlatformTools
6
+ private
7
+
8
+ def default_browser_opener(uri)
9
+ cmd = case RbConfig::CONFIG["host_os"]
10
+ when /darwin/ then "open"
11
+ when /linux/ then "xdg-open"
12
+ when /mswin|mingw|cygwin/ then "start"
13
+ end
14
+ cmd && Kernel.system(cmd, uri)
15
+ rescue StandardError
16
+ false
17
+ end
18
+
19
+ def default_clipper(text)
20
+ cmd = case RbConfig::CONFIG["host_os"]
21
+ when /darwin/ then "pbcopy"
22
+ when /linux/ then ["xclip", "-selection", "clipboard"]
23
+ end
24
+ return false unless cmd
25
+
26
+ IO.popen(cmd, "w") { |p| p.write(text) }
27
+ true
28
+ rescue StandardError
29
+ false
30
+ end
31
+ end
32
+ end
33
+ end