gem-contribute 0.1.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.gem_release.yml +1 -0
  3. data/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. data/.github/workflows/ci.yml +26 -0
  5. data/.github/workflows/pr-template-check.yml +100 -0
  6. data/CHANGELOG.md +41 -0
  7. data/CLAUDE.md +1 -1
  8. data/CODE_OF_CONDUCT.md +86 -0
  9. data/CONTRIBUTING.md +12 -13
  10. data/README.md +21 -8
  11. data/docs/OPEN_QUESTIONS.md +167 -0
  12. data/docs/ROADMAP.md +266 -0
  13. data/docs/adr/0006-standalone-gem-not-plugin.md +1 -1
  14. data/docs/adr/0008-rooibos-tui-framework.md +3 -3
  15. data/docs/adr/0010-charm-ruby-tui-framework.md +84 -0
  16. data/docs/adr/0011-host-adapter-owns-host-verbs.md +58 -0
  17. data/docs/adr/0012-output-free-service-objects-three-interface-architecture.md +79 -0
  18. data/docs/adr/0013-revert-to-rooibos.md +71 -0
  19. data/docs/adr/0014-ship-bundler-and-rubygems-plugins.md +75 -0
  20. data/docs/adr/README.md +7 -2
  21. data/docs/design-interface-layer.md +295 -0
  22. data/docs/design.md +31 -8
  23. data/docs/ideas.md +1 -0
  24. data/docs/index.md +2 -2
  25. data/docs/prep-plan.md +6 -6
  26. data/docs/talk/README.md +45 -0
  27. data/docs/talk/index.html +4165 -0
  28. data/docs/talk/lightning.md +425 -0
  29. data/docs/talk/lightning.pdf +0 -0
  30. data/lib/gem_contribute/cli/auth.rb +22 -44
  31. data/lib/gem_contribute/cli/config.rb +32 -16
  32. data/lib/gem_contribute/cli/fix.rb +122 -0
  33. data/lib/gem_contribute/cli/fork.rb +145 -0
  34. data/lib/gem_contribute/cli/init.rb +78 -0
  35. data/lib/gem_contribute/cli/issue_announcer.rb +42 -0
  36. data/lib/gem_contribute/cli/issues.rb +37 -44
  37. data/lib/gem_contribute/cli/platform_tools.rb +33 -0
  38. data/lib/gem_contribute/cli/post_clone_hooks.rb +50 -0
  39. data/lib/gem_contribute/cli/rate_limit_footer.rb +34 -0
  40. data/lib/gem_contribute/cli/scan.rb +20 -15
  41. data/lib/gem_contribute/cli/submit.rb +60 -64
  42. data/lib/gem_contribute/cli/workflow.rb +63 -0
  43. data/lib/gem_contribute/cli.rb +11 -14
  44. data/lib/gem_contribute/config.rb +28 -4
  45. data/lib/gem_contribute/git.rb +49 -0
  46. data/lib/gem_contribute/host_adapter.rb +52 -5
  47. data/lib/gem_contribute/host_adapters/github_adapter.rb +126 -37
  48. data/lib/gem_contribute/operations/announce.rb +52 -0
  49. data/lib/gem_contribute/operations/branch.rb +35 -0
  50. data/lib/gem_contribute/operations/clone.rb +41 -0
  51. data/lib/gem_contribute/operations/fix_pipeline.rb +70 -0
  52. data/lib/gem_contribute/operations/fork.rb +35 -0
  53. data/lib/gem_contribute/output/null.rb +20 -0
  54. data/lib/gem_contribute/output/standard.rb +71 -0
  55. data/lib/gem_contribute/version.rb +1 -1
  56. data/lib/gem_contribute.rb +10 -18
  57. metadata +120 -3
  58. data/lib/gem_contribute/cli/fork_clone_branch.rb +0 -197
@@ -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
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+
5
+ module GemContribute
6
+ module CLI
7
+ # `gem-contribute init` — interactive one-time setup. Writes the user's
8
+ # `clone_root` to ~/.config/gem-contribute/config.yml and, if no GitHub
9
+ # token is cached, offers to run `auth login`.
10
+ #
11
+ # Without init, `fix` errors with a hint to run init. The point is to
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.
17
+ class Init
18
+ USAGE = <<~USAGE
19
+ Usage: gem-contribute init
20
+
21
+ Interactively set the directory where forks are cloned (clone_root),
22
+ then offer to authenticate with GitHub if you haven't already.
23
+ Re-run any time to change.
24
+ USAGE
25
+
26
+ DEFAULT_SUGGESTION = "~/code/oss"
27
+ AUTH_HOST = "github.com"
28
+
29
+ def initialize(stdout: $stdout, stderr: $stderr, output: nil,
30
+ config: GemContribute::Config.new,
31
+ store: GemContribute::TokenStore.new,
32
+ auth: nil,
33
+ prompt: nil)
34
+ @output = output || Output::Standard.new(out: stdout, err: stderr)
35
+ @config = config
36
+ @store = store
37
+ @auth = auth || GemContribute::CLI::Auth.new(output: @output, store: store)
38
+ @prompt = prompt || TTY::Prompt.new
39
+ end
40
+
41
+ def run(argv)
42
+ return print_usage if %w[help -h --help].include?(argv.first)
43
+
44
+ prompt_clone_root
45
+ maybe_authenticate
46
+ 0
47
+ end
48
+
49
+ private
50
+
51
+ def prompt_clone_root
52
+ current = @config.to_h["clone_root"]
53
+ default = current || DEFAULT_SUGGESTION
54
+ chosen = @prompt.ask("Where should I clone repos?", default: default)
55
+ @config.set("clone_root", chosen)
56
+ @output.info("Clone root set to #{File.expand_path(chosen)}")
57
+ end
58
+
59
+ def maybe_authenticate
60
+ if @store.token_for(AUTH_HOST)
61
+ @output.info("GitHub: already authenticated.")
62
+ return
63
+ end
64
+
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.")
69
+ end
70
+ end
71
+
72
+ def print_usage
73
+ @output.info(USAGE)
74
+ 0
75
+ end
76
+ end
77
+ end
78
+ 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,26 +28,29 @@ module GemContribute
27
28
  target = argv.shift
28
29
  return print_usage if target.nil?
29
30
 
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
31
+ @claim_index = IssueAnnouncer.fetch_claim_index(@adapter)
32
+ status = target == "all" ? run_all : run_single(target)
33
+ RateLimitFooter.print(adapter: @adapter, output: @output)
34
+ status
38
35
  rescue AdapterError => e
39
- @stderr.puts "gem-contribute: #{e.message}"
36
+ @output.error("gem-contribute: #{e.message}")
40
37
  1
41
38
  end
42
39
 
43
40
  private
44
41
 
45
42
  def print_usage
46
- @stderr.puts "Usage: gem-contribute issues <gem|all>"
43
+ @output.error("Usage: gem-contribute issues <gem|all>")
47
44
  2
48
45
  end
49
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
+
50
54
  def run_all
51
55
  gems = LockfileParser.parse(@lockfile_path)
52
56
  projects = gems.filter_map do |gem|
@@ -54,7 +58,7 @@ module GemContribute
54
58
  project if project.host == "github.com"
55
59
  end
56
60
 
57
- @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")
58
62
 
59
63
  any = false
60
64
  projects.each do |project|
@@ -65,57 +69,46 @@ module GemContribute
65
69
  print_project_issues(project, issues)
66
70
  end
67
71
 
68
- @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
69
73
  0
70
74
  rescue LockfileNotFound => e
71
- @stderr.puts "gem-contribute: #{e.message}"
75
+ @output.error("gem-contribute: #{e.message}")
72
76
  1
73
77
  end
74
78
 
75
79
  def fetch_issues(project)
76
80
  @adapter.issues(project, labels: [DEFAULT_LABEL])
77
81
  rescue AdapterError => e
78
- @stderr.puts " warning: #{project.gem_name}: #{e.message}"
82
+ @output.warn(" warning: #{project.gem_name}: #{e.message}")
79
83
  []
80
84
  end
81
85
 
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
86
  def list_issues(project)
100
87
  issues = @adapter.issues(project, labels: [DEFAULT_LABEL])
101
88
  print_project_issues(project, issues)
102
- @stdout.puts "To contribute: gem-contribute fix #{project.gem_name}/<issue#>"
89
+ @output.info("To contribute: gem-contribute fix #{project.gem_name}/<issue#>")
103
90
  0
104
91
  end
105
92
 
106
93
  def print_project_issues(project, issues)
107
94
  repo_url = "https://github.com/#{project.owner}/#{project.repo}"
108
- @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})")
109
96
 
110
97
  if issues.empty?
111
- @stdout.puts " (none — browse #{repo_url}/issues directly)"
98
+ @output.info(" (none — browse #{repo_url}/issues directly)")
112
99
  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
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("")
119
112
  end
120
113
  end
121
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
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module GemContribute
6
+ module CLI
7
+ # Optional post-clone hooks invoked by `fix` when the user passes
8
+ # `-e` (open editor) and/or `-a` (launch AI tool). Extracted so the
9
+ # `fix` state machine stays focused on the fork/clone/branch
10
+ # sequence.
11
+ class PostCloneHooks
12
+ def initialize(stdout: $stdout, stderr: $stderr, output: nil,
13
+ config: GemContribute::Config.new,
14
+ editor_runner: ->(cmd, path) { Kernel.system("#{cmd} #{path.shellescape}") },
15
+ ai_runner: ->(cmd, path) { Kernel.system(cmd, chdir: path) })
16
+ @output = output || Output::Standard.new(out: stdout, err: stderr)
17
+ @config = config
18
+ @editor_runner = editor_runner
19
+ @ai_runner = ai_runner
20
+ end
21
+
22
+ def call(local_path, editor:, ai_tool:)
23
+ open_editor(local_path) if editor
24
+ launch_ai(local_path) if ai_tool
25
+ end
26
+
27
+ private
28
+
29
+ def open_editor(local_path)
30
+ editor = @config.editor || ENV.fetch("EDITOR", nil)
31
+ if editor.nil? || editor.empty?
32
+ @output.warn("-e: no editor configured. " \
33
+ "Set with `gem-contribute config set editor <cmd>` or set $EDITOR.")
34
+ return
35
+ end
36
+ @editor_runner.call(editor, local_path)
37
+ end
38
+
39
+ def launch_ai(local_path)
40
+ ai_tool = @config.ai_tool
41
+ if ai_tool.nil? || ai_tool.empty?
42
+ @output.warn("-a: no ai_tool configured. " \
43
+ "Set with `gem-contribute config set ai_tool \"<cmd>\"`.")
44
+ return
45
+ end
46
+ @ai_runner.call(ai_tool, local_path)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemContribute
4
+ module CLI
5
+ # Prints a one-line GitHub rate-limit footer after `scan` or `issues`
6
+ # finishes its main output, when the adapter has rate-limit data.
7
+ #
8
+ # Format: "GitHub rate limit: 4,587 / 5,000 remaining · resets at 14:32 UTC"
9
+ #
10
+ # When `adapter.rate_limit` is nil (e.g. every call was served from cache),
11
+ # nothing is printed — see #4 acceptance criteria.
12
+ module RateLimitFooter
13
+ module_function
14
+
15
+ # @param adapter [GemContribute::HostAdapters::GitHubAdapter]
16
+ # @param output [GemContribute::Output::Standard, GemContribute::Output::Null]
17
+ # @param stdout [IO] backward-compat for callers that haven't migrated to `output:`
18
+ def print(adapter:, output: nil, stdout: $stdout)
19
+ output ||= Output::Standard.new(out: stdout)
20
+ rate_limit = adapter.respond_to?(:rate_limit) ? adapter.rate_limit : nil
21
+ return if rate_limit.nil?
22
+
23
+ remaining = format_with_separators(rate_limit.remaining)
24
+ limit = format_with_separators(rate_limit.limit)
25
+ reset = rate_limit.reset_at.utc.strftime("%H:%M")
26
+ output.info("GitHub rate limit: #{remaining} / #{limit} remaining · resets at #{reset} UTC")
27
+ end
28
+
29
+ def format_with_separators(integer)
30
+ integer.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
31
+ end
32
+ end
33
+ end
34
+ end