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
@@ -21,9 +21,10 @@ module GemContribute
21
21
  # filtering. Future stages can call once per label and dedupe.
22
22
  DEFAULT_LABEL = "good first issue"
23
23
 
24
- def initialize(stdout: $stdout, stderr: $stderr, resolver: Resolver.new, adapter: HostAdapters::GitHubAdapter.new)
25
- @stdout = stdout
26
- @stderr = stderr
24
+ def initialize(stdout: $stdout, stderr: $stderr, output: nil,
25
+ resolver: Resolver.new,
26
+ adapter: HostAdapters::GitHubAdapter.new)
27
+ @output = output || Output::Standard.new(out: stdout, err: stderr)
27
28
  @resolver = resolver
28
29
  @adapter = adapter
29
30
  end
@@ -33,20 +34,21 @@ module GemContribute
33
34
  def run(argv)
34
35
  path = argv.first || "Gemfile.lock"
35
36
  gems = LockfileParser.parse(path)
36
- @stdout.puts "Scanning #{path} (#{gems.size} gems)..."
37
+ @output.progress("Scanning #{path} (#{gems.size} gems)...")
37
38
 
38
39
  projects = gems.map { |gem| @resolver.resolve(gem) }
39
40
  # Summary tally reflects only the lockfile contents — the
40
41
  # self-injection is intentionally additive, not part of the count.
41
42
  print_summary(tally_hosts(projects), gems.size)
42
43
  scan_github_projects(projects)
44
+ RateLimitFooter.print(adapter: @adapter, output: @output)
43
45
  0
44
46
  rescue LockfileNotFound => e
45
- @stderr.puts "gem-contribute: #{e.message}"
47
+ @output.error("gem-contribute: #{e.message}")
46
48
  1
47
49
  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
+ @output.error("gem-contribute: network unreachable (#{e.class}: #{e.message})")
51
+ @output.error("Re-run when you have connectivity, or use cached data with --refresh disabled.")
50
52
  1
51
53
  end
52
54
 
@@ -60,12 +62,13 @@ module GemContribute
60
62
 
61
63
  def scan_github_projects(projects)
62
64
  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?
65
+ @output.info("\nNo github.com projects in this lockfile.") if github_from_lockfile.empty?
64
66
 
65
67
  ranked = rank_by_issue_count(inject_self(github_from_lockfile))
66
68
  return if ranked.empty?
67
69
 
68
- print_ranked(ranked)
70
+ claim_index = IssueAnnouncer.fetch_claim_index(@adapter)
71
+ print_ranked(ranked, claim_index)
69
72
  end
70
73
 
71
74
  def tally_hosts(projects)
@@ -80,7 +83,7 @@ module GemContribute
80
83
  label = host == :unknown ? "unknown source" : "on #{host}"
81
84
  parts << "#{count} #{label}"
82
85
  end
83
- @stdout.puts parts.join(" · ")
86
+ @output.info(parts.join(" · "))
84
87
  end
85
88
 
86
89
  def rank_by_issue_count(projects)
@@ -99,17 +102,19 @@ module GemContribute
99
102
  issues = @adapter.issues(project, labels: [DEFAULT_LABEL])
100
103
  issues.size
101
104
  rescue AdapterError, AuthRequired => e
102
- @stderr.puts " warning: #{project.gem_name} (#{project.host}/#{project.owner}/#{project.repo}): #{e.message}"
105
+ @output.warn(" warning: #{project.gem_name} (#{project.host}/#{project.owner}/#{project.repo}): #{e.message}")
103
106
  0
104
107
  end
105
108
 
106
- def print_ranked(ranked)
107
- @stdout.puts
108
- @stdout.puts "Top contributable projects (by open `good first issue` count):"
109
+ def print_ranked(ranked, claim_index)
110
+ @output.info("")
111
+ @output.info("Top contributable projects (by open `good first issue` count):")
109
112
  col_name = ranked.map { |p, _| p.gem_name.length }.max
110
113
  ranked.each do |project, count|
111
114
  location = "#{project.host}/#{project.owner}/#{project.repo}"
112
- @stdout.printf(" %-#{col_name}s %3d %s\n", project.gem_name, count, location)
115
+ claimed = claim_index["#{project.owner}/#{project.repo}"] || []
116
+ suffix = claimed.empty? ? "" : " · #{claimed.size} claimed"
117
+ @output.info(format(" %-#{col_name}s %3d %s%s", project.gem_name, count, location, suffix))
113
118
  end
114
119
  end
115
120
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
- require "uri"
5
4
 
6
5
  module GemContribute
7
6
  module CLI
@@ -13,21 +12,23 @@ module GemContribute
13
12
  # - upstream remote → canonical owner/repo (where the PR is filed)
14
13
  # - current branch → must match `gem-contribute/issue-<N>`
15
14
  #
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.
15
+ # The PR itself is NOT opened via API. We push, then open the host's
16
+ # compare/MR page in the browser with title and body pre-filled. This
17
+ # mirrors the `auth login` UX (browser handles the human step) and means
18
+ # the user always reviews the PR text before submitting. The host-specific
19
+ # URL is built by the adapter (ADR-0011).
20
20
  class Submit
21
+ include PlatformTools
22
+
21
23
  BRANCH_REGEX = %r{\Agem-contribute/issue-(\d+)\z}
22
24
 
23
- def initialize(stdout: $stdout, stderr: $stderr,
24
- git: Git.new,
25
+ def initialize(stdout: $stdout, stderr: $stderr, output: nil,
26
+ git: GemContribute::Git.new,
25
27
  adapter_factory: ->(token:) { HostAdapters::GitHubAdapter.new(token: token) },
26
28
  store: TokenStore.new,
27
29
  browser_opener: nil,
28
30
  working_dir: Dir.pwd)
29
- @stdout = stdout
30
- @stderr = stderr
31
+ @output = output || Output::Standard.new(out: stdout, err: stderr)
31
32
  @git = git
32
33
  @adapter_factory = adapter_factory
33
34
  @store = store
@@ -47,19 +48,30 @@ module GemContribute
47
48
  # separate fork and no `upstream` remote — fall back to origin and
48
49
  # build a same-repo PR.
49
50
  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
51
+ execute(branch, issue_number, origin, upstream)
56
52
  rescue AdapterError => e
57
- @stderr.puts "submit failed: #{e.message}"
53
+ @output.error("submit failed: #{e.message}")
58
54
  1
59
55
  end
60
56
 
61
57
  private
62
58
 
59
+ def execute(branch, issue_number, origin, upstream)
60
+ adapter = @adapter_factory.call(token: @store.token_for("github.com"))
61
+ upstream_project = project_for(upstream)
62
+ title = fetch_issue_title(adapter, upstream_project, issue_number)
63
+ push_branch(branch)
64
+ url = adapter.pull_request_url(
65
+ upstream_project,
66
+ head_owner: origin[:owner],
67
+ head_branch: branch,
68
+ title: pr_title(issue_number, title),
69
+ body: pr_body(issue_number)
70
+ )
71
+ open_and_print(url)
72
+ 0
73
+ end
74
+
63
75
  def current_branch
64
76
  # symbolic-ref works even on a fresh branch with no commits;
65
77
  # rev-parse --abbrev-ref doesn't.
@@ -73,82 +85,66 @@ module GemContribute
73
85
  match = BRANCH_REGEX.match(branch)
74
86
  return match[1].to_i if match
75
87
 
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."
88
+ @output.error("submit: branch #{branch.inspect} doesn't match #{BRANCH_REGEX.source}.")
89
+ @output.error("Run `gem-contribute fix <gem>/<issue#>` first to set up the branch.")
78
90
  nil
79
91
  end
80
92
 
81
93
  def parse_remote(name, required:)
82
94
  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
95
+ return missing_remote_error(name) if !status.success? && required
96
+ return nil unless status.success?
90
97
 
91
98
  owner_repo_from_url(out.strip)
92
99
  end
93
100
 
101
+ def missing_remote_error(name)
102
+ @output.error("submit: no `#{name}` remote configured. Are you inside a git clone?")
103
+ nil
104
+ end
105
+
94
106
  # Accepts both https://github.com/owner/repo(.git) and git@github.com:owner/repo.git
95
107
  def owner_repo_from_url(url)
96
108
  if (m = url.match(%r{github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?\z}))
97
109
  { owner: m[1], repo: m[2] }
98
110
  else
99
- @stderr.puts "submit: can't parse GitHub owner/repo from #{url.inspect}"
111
+ @output.error("submit: can't parse GitHub owner/repo from #{url.inspect}")
100
112
  nil
101
113
  end
102
114
  end
103
115
 
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)
116
+ def project_for(owner_repo)
117
+ Project.new(
118
+ gem_name: owner_repo[:repo], host: "github.com",
119
+ owner: owner_repo[:owner], repo: owner_repo[:repo], metadata: {}
120
+ )
121
+ end
122
+
123
+ def fetch_issue_title(adapter, upstream_project, number)
124
+ adapter.issue(upstream_project, number).fetch("title", nil)
108
125
  rescue AdapterError => e
109
- @stderr.puts "submit: couldn't fetch issue title (#{e.message}). Continuing without it."
126
+ @output.warn("submit: couldn't fetch issue title (#{e.message}). Continuing without it.")
110
127
  nil
111
128
  end
112
129
 
113
- def push_branch(branch)
114
- @stdout.puts "Pushing #{branch} to origin..."
115
- @git.push(@working_dir, "origin", branch)
130
+ def pr_title(issue_number, title)
131
+ title ? "Fix ##{issue_number}: #{title}" : "Fix ##{issue_number}"
116
132
  end
117
133
 
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)}"
134
+ def pr_body(issue_number)
135
+ "Closes ##{issue_number}.\n\n_Opened via `gem-contribute submit`._"
135
136
  end
136
137
 
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}"
138
+ def push_branch(branch)
139
+ @output.progress("Pushing #{branch} to origin...") do
140
+ @git.push(@working_dir, "origin", branch)
141
+ end
141
142
  end
142
143
 
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
144
+ def open_and_print(url)
145
+ opened = @browser_opener.call(url)
146
+ @output.info(opened ? "Opened browser to:" : "Open this URL to file the PR:")
147
+ @output.info(" #{url}")
152
148
  end
153
149
  end
154
150
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+
5
+ module GemContribute
6
+ module CLI
7
+ # Shared scaffolding for action-style CLI verbs (Fix, Fork, future
8
+ # Abandon). Each verb owns its own `parse_argv`, `execute`, and
9
+ # `print_usage_error`; this module captures the common pieces around
10
+ # them: the missing-clone_root error, the auth-token check (as a
11
+ # Result-returning service per ADR-0012), and the project resolver.
12
+ #
13
+ # Including classes are expected to hold:
14
+ # @output, @resolver, @store, @adapter_factory, @clone_root
15
+ module Workflow
16
+ include Dry::Monads[:result]
17
+
18
+ private
19
+
20
+ def missing_clone_root
21
+ @output.error("clone_root is not configured. Run `gem-contribute init` first.")
22
+ 1
23
+ end
24
+
25
+ # Per ADR-0012: returns Success(adapter) | Failure(:unauthenticated).
26
+ # Callers pattern-match — no nil + stderr side effect, no exceptions.
27
+ def build_adapter
28
+ token = @store.token_for("github.com")
29
+ return Failure(:unauthenticated) if token.nil?
30
+
31
+ Success(@adapter_factory.call(token: token))
32
+ end
33
+
34
+ # Resolves a CLI target to a `github.com` Project, or prints an
35
+ # error and returns nil. With `allow_owner_repo: true` the slash
36
+ # form (`owner/repo`) bypasses RubyGems and constructs the Project
37
+ # directly — useful for verbs that don't require a published gem.
38
+ def resolve_target(target, verb:, allow_owner_repo: false)
39
+ if allow_owner_repo && target.include?("/")
40
+ owner, repo = target.split("/", 2)
41
+ return GemContribute::Project.new(
42
+ gem_name: repo, host: "github.com",
43
+ owner: owner, repo: repo, metadata: {}
44
+ )
45
+ end
46
+
47
+ return GemContribute::SELF_PROJECT if target == GemContribute::SELF_PROJECT.gem_name
48
+
49
+ gem = LockedGem.new(name: target, version: "*",
50
+ source_type: :rubygems, source_uri: "https://rubygems.org/")
51
+ project = @resolver.resolve(gem)
52
+
53
+ if project.host != "github.com"
54
+ @output.error("Cannot run `#{verb}`: #{target} resolves to #{project.host} " \
55
+ "(only github.com is supported at v0.1)")
56
+ return nil
57
+ end
58
+
59
+ project
60
+ end
61
+ end
62
+ end
63
+ end
@@ -4,17 +4,11 @@ require "optparse"
4
4
 
5
5
  module GemContribute
6
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
7
  USAGE = <<~USAGE
15
8
  Usage: gem-contribute <command> [options]
16
9
 
17
10
  Commands:
11
+ init One-time interactive setup (sets clone_root).
18
12
  scan [path] Summarize the contributable surface of a Gemfile.lock.
19
13
  Path defaults to ./Gemfile.lock.
20
14
  issues <gem|all> List open "good first issue" issues for a gem (or all gems).
@@ -24,8 +18,13 @@ module GemContribute
24
18
  auth login Authenticate with GitHub via OAuth device flow.
25
19
  auth status Show whether you're authenticated.
26
20
  auth logout Remove the cached token for github.com.
21
+ fork <gem|owner/repo> Fork (and clone) any GitHub repo. Pass a gem name
22
+ to look it up on RubyGems, or `owner/repo` for any
23
+ GitHub project (e.g. `rubyevents/rubyevents`).
24
+ Lands on the default branch.
25
+ Flags: -e (editor), -a (AI tool).
27
26
  fix <gem>/<issue#> Fork the gem's repo, clone the fork, branch from main.
28
- (alias: fork-clone-branch)
27
+ Flags: -e (editor), -a (AI tool), --no-comment.
29
28
  submit Push the current branch and open a pre-filled
30
29
  PR compare page in the browser. Run from inside
31
30
  a clone created by `fix`.
@@ -58,17 +57,15 @@ module GemContribute
58
57
  end
59
58
 
60
59
  COMMANDS = {
60
+ "init" => ->(o, e) { Init.new(stdout: o, stderr: e) },
61
61
  "scan" => ->(o, e) { Scan.new(stdout: o, stderr: e, adapter: github_adapter) },
62
62
  "issues" => ->(o, e) { Issues.new(stdout: o, stderr: e, adapter: github_adapter) },
63
63
  "config" => ->(o, e) { Config.new(stdout: o, stderr: e) },
64
64
  "auth" => ->(o, e) { Auth.new(stdout: o, stderr: e) },
65
+ "fork" => ->(o, e) { Fork.new(stdout: o, stderr: e, clone_root: GemContribute::Config.new.clone_root) },
65
66
  "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)
67
+ config = GemContribute::Config.new
68
+ Fix.new(stdout: o, stderr: e, clone_root: config.clone_root, config: config)
72
69
  },
73
70
  "submit" => ->(o, e) { Submit.new(stdout: o, stderr: e) }
74
71
  }.freeze
@@ -8,9 +8,7 @@ module GemContribute
8
8
  # Honors XDG_CONFIG_HOME so tests stay hermetic and unusual layouts work.
9
9
  # Missing or corrupt files are treated as an empty config (no crash).
10
10
  class Config
11
- DEFAULT_CLONE_ROOT = File.expand_path("~/code/oss")
12
-
13
- KNOWN_KEYS = %w[clone_root].freeze
11
+ KNOWN_KEYS = %w[clone_root editor ai_tool comment_on_fix].freeze
14
12
 
15
13
  def initialize(path: self.class.default_path)
16
14
  @path = path
@@ -19,7 +17,26 @@ module GemContribute
19
17
 
20
18
  def clone_root
21
19
  raw = @data["clone_root"]
22
- raw ? File.expand_path(raw) : DEFAULT_CLONE_ROOT
20
+ raw ? File.expand_path(raw) : nil
21
+ end
22
+
23
+ def editor
24
+ @data["editor"]
25
+ end
26
+
27
+ def ai_tool
28
+ @data["ai_tool"]
29
+ end
30
+
31
+ # Returns whether `fix` should post a "working on this" comment.
32
+ # Pass a repo (`"owner/repo"`) to check the per-repo override; without
33
+ # a repo, returns the global default. Default is true when unset.
34
+ def comment_on_fix?(repo = nil)
35
+ overrides = @data["comment_on_fix_overrides"]
36
+ return truthy?(overrides[repo]) if repo && overrides.is_a?(Hash) && overrides.key?(repo)
37
+
38
+ raw = @data["comment_on_fix"]
39
+ raw.nil? || truthy?(raw)
23
40
  end
24
41
 
25
42
  def set(key, value)
@@ -41,6 +58,13 @@ module GemContribute
41
58
 
42
59
  private
43
60
 
61
+ def truthy?(value)
62
+ case value
63
+ when true, false then value
64
+ else value.to_s.downcase != "false"
65
+ end
66
+ end
67
+
44
68
  def load_file
45
69
  return {} unless File.exist?(@path)
46
70
 
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module GemContribute
6
+ # Thin wrapper around the `git` CLI so callers can substitute a fake in
7
+ # tests without shelling out. Uses Open3 with arg-list invocation (no shell)
8
+ # so there's no injection surface.
9
+ class Git
10
+ def clone(url, target)
11
+ run!(["git", "clone", url, target])
12
+ end
13
+
14
+ def checkout_branch(path, branch)
15
+ run!(["git", "-C", path, "checkout", "-b", branch])
16
+ end
17
+
18
+ def add_remote(path, name, url)
19
+ # Idempotent: if the remote already exists (e.g. reusing a clone)
20
+ # we silently succeed rather than fail the whole flow.
21
+ return if remote_exists?(path, name)
22
+
23
+ run!(["git", "-C", path, "remote", "add", name, url])
24
+ end
25
+
26
+ def push(path, remote, branch)
27
+ run!(["git", "-C", path, "push", "-u", remote, branch])
28
+ end
29
+
30
+ def remote_exists?(path, name)
31
+ out, _err, status = Open3.capture3("git", "-C", path, "remote")
32
+ status.success? && out.split("\n").include?(name)
33
+ end
34
+
35
+ def branch_exists?(path, branch)
36
+ _out, _err, status = Open3.capture3("git", "-C", path,
37
+ "rev-parse", "--verify", "--quiet",
38
+ "refs/heads/#{branch}")
39
+ status.success?
40
+ end
41
+
42
+ def run!(argv)
43
+ _stdout, stderr_str, status = Open3.capture3(*argv)
44
+ return if status.success?
45
+
46
+ raise GemContribute::AdapterError, "git #{argv[1..].join(" ")} failed: #{stderr_str.strip}"
47
+ end
48
+ end
49
+ end
@@ -3,24 +3,51 @@
3
3
  module GemContribute
4
4
  # Abstract host adapter. Concrete implementations (GitHubAdapter, future
5
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.
6
+ # rest of the app — Operations, CLI verbs, TUI doesn't have to special-case
7
+ # anything beyond looking up the right adapter for a project's host.
8
+ #
9
+ # See ADR-0011: HostAdapter owns the host-API verbs (fork, comment,
10
+ # pull_request_url) plus the host-specific URL templating (clone_url,
11
+ # repo_url). Higher layers compose those primitives; they don't construct
12
+ # host URLs themselves.
8
13
  #
9
14
  # Public-API methods (no auth needed):
10
15
  # issues(project, labels:)
16
+ # issue(project, number)
17
+ # issue_comments(project, number)
11
18
  # community_profile(project)
12
19
  # file_contents(project, path)
20
+ # search_issues(query)
21
+ # clone_url(owner, repo)
22
+ # repo_url(owner, repo)
13
23
  #
14
24
  # Auth-required methods (raise AuthRequired without a cached token):
15
- # fork(project)
16
- # already_forked?(project)
25
+ # fork(project) — idempotent, blocks until the fork is reachable
26
+ # comment(project, issue:, body:)
27
+ # pull_request_url(upstream, head_owner:, head_branch:, title:, body:)
28
+ # viewer_login
17
29
  #
18
30
  # See ADR-0001 for the JIT auth contract.
19
31
  class HostAdapter
32
+ # Result of a successful `fork(project)`.
33
+ # - clone_url: HTTPS URL suitable for `git clone`.
34
+ # - fork_url: human-readable web URL of the fork (used in summaries).
35
+ # - viewer: the authenticated user's login (and the fork's owner).
36
+ # - reused: true if the fork already existed; false if just created.
37
+ ForkResult = Data.define(:clone_url, :fork_url, :viewer, :reused)
38
+
20
39
  def issues(_project, labels: nil)
21
40
  raise NotImplementedError
22
41
  end
23
42
 
43
+ def issue(_project, _number)
44
+ raise NotImplementedError
45
+ end
46
+
47
+ def issue_comments(_project, _number)
48
+ raise NotImplementedError
49
+ end
50
+
24
51
  def community_profile(_project)
25
52
  raise NotImplementedError
26
53
  end
@@ -29,11 +56,31 @@ module GemContribute
29
56
  raise NotImplementedError
30
57
  end
31
58
 
59
+ def search_issues(_query)
60
+ raise NotImplementedError
61
+ end
62
+
32
63
  def fork(_project)
33
64
  raise NotImplementedError
34
65
  end
35
66
 
36
- def already_forked?(_project)
67
+ def comment(_project, issue:, body:)
68
+ raise NotImplementedError
69
+ end
70
+
71
+ def pull_request_url(_upstream, head_owner:, head_branch:, title:, body:)
72
+ raise NotImplementedError
73
+ end
74
+
75
+ def viewer_login
76
+ raise NotImplementedError
77
+ end
78
+
79
+ def clone_url(_owner, _repo)
80
+ raise NotImplementedError
81
+ end
82
+
83
+ def repo_url(_owner, _repo)
37
84
  raise NotImplementedError
38
85
  end
39
86
  end