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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gem-contribute
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Hagmann
@@ -23,10 +23,94 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '2.4'
26
+ - !ruby/object:Gem::Dependency
27
+ name: dry-initializer
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: dry-monads
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.10'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.10'
54
+ - !ruby/object:Gem::Dependency
55
+ name: dry-operation
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.1'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.1'
68
+ - !ruby/object:Gem::Dependency
69
+ name: tty-prompt
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.23'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.23'
82
+ - !ruby/object:Gem::Dependency
83
+ name: tty-spinner
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.9'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.9'
96
+ - !ruby/object:Gem::Dependency
97
+ name: zeitwerk
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '2.6'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '2.6'
26
110
  description: |
27
111
  gem-contribute reads a project's Gemfile.lock, resolves each gem's source
28
112
  repository via the RubyGems API, surfaces open contributable issues from
29
- those repositories, and offers a one-keystroke fork-clone-branch flow so a
113
+ those repositories, and offers a one-keystroke fix flow so a
30
114
  developer can go from "I noticed an issue" to "I have a working branch" in
31
115
  seconds. v0.1 supports GitHub-hosted gems with OAuth device-flow auth.
32
116
  email:
@@ -40,6 +124,8 @@ files:
40
124
  - ".github/ISSUE_TEMPLATE/workshop-issue.md"
41
125
  - ".github/PULL_REQUEST_TEMPLATE.md"
42
126
  - ".github/workflows/auto-merge-kicked-tires.yml"
127
+ - ".github/workflows/ci.yml"
128
+ - ".github/workflows/pr-template-check.yml"
43
129
  - CHANGELOG.md
44
130
  - CLAUDE.md
45
131
  - CODE_OF_CONDUCT.md
@@ -49,6 +135,8 @@ files:
49
135
  - MAINTAINER.md
50
136
  - README.md
51
137
  - Rakefile
138
+ - docs/OPEN_QUESTIONS.md
139
+ - docs/ROADMAP.md
52
140
  - docs/_config.yml
53
141
  - docs/adr/0001-just-in-time-auth.md
54
142
  - docs/adr/0002-bundler-lockfile-parser.md
@@ -60,8 +148,13 @@ files:
60
148
  - docs/adr/0008-rooibos-tui-framework.md
61
149
  - docs/adr/0009-top-level-namespace.md
62
150
  - docs/adr/0010-charm-ruby-tui-framework.md
151
+ - docs/adr/0011-host-adapter-owns-host-verbs.md
152
+ - docs/adr/0012-output-free-service-objects-three-interface-architecture.md
153
+ - docs/adr/0013-revert-to-rooibos.md
154
+ - docs/adr/0014-ship-bundler-and-rubygems-plugins.md
63
155
  - docs/adr/README.md
64
156
  - docs/claude-code-prompt.md
157
+ - docs/design-interface-layer.md
65
158
  - docs/design.md
66
159
  - docs/ideas.md
67
160
  - docs/index.md
@@ -78,18 +171,31 @@ files:
78
171
  - lib/gem_contribute/cli.rb
79
172
  - lib/gem_contribute/cli/auth.rb
80
173
  - lib/gem_contribute/cli/config.rb
81
- - lib/gem_contribute/cli/fork_clone_branch.rb
174
+ - lib/gem_contribute/cli/fix.rb
175
+ - lib/gem_contribute/cli/fork.rb
82
176
  - lib/gem_contribute/cli/init.rb
177
+ - lib/gem_contribute/cli/issue_announcer.rb
83
178
  - lib/gem_contribute/cli/issues.rb
179
+ - lib/gem_contribute/cli/platform_tools.rb
180
+ - lib/gem_contribute/cli/post_clone_hooks.rb
84
181
  - lib/gem_contribute/cli/rate_limit_footer.rb
85
182
  - lib/gem_contribute/cli/scan.rb
86
183
  - lib/gem_contribute/cli/submit.rb
184
+ - lib/gem_contribute/cli/workflow.rb
87
185
  - lib/gem_contribute/config.rb
88
186
  - lib/gem_contribute/errors.rb
187
+ - lib/gem_contribute/git.rb
89
188
  - lib/gem_contribute/host_adapter.rb
90
189
  - lib/gem_contribute/host_adapters/github_adapter.rb
91
190
  - lib/gem_contribute/locked_gem.rb
92
191
  - lib/gem_contribute/lockfile_parser.rb
192
+ - lib/gem_contribute/operations/announce.rb
193
+ - lib/gem_contribute/operations/branch.rb
194
+ - lib/gem_contribute/operations/clone.rb
195
+ - lib/gem_contribute/operations/fix_pipeline.rb
196
+ - lib/gem_contribute/operations/fork.rb
197
+ - lib/gem_contribute/output/null.rb
198
+ - lib/gem_contribute/output/standard.rb
93
199
  - lib/gem_contribute/project.rb
94
200
  - lib/gem_contribute/resolver.rb
95
201
  - lib/gem_contribute/token_store.rb
@@ -1,204 +0,0 @@
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
- return missing_clone_root if @clone_root.nil?
52
-
53
- target = argv.shift
54
- return print_usage_error if target.nil? || !target.include?("/")
55
-
56
- gem_name, issue = target.split("/", 2)
57
- adapter = build_adapter
58
- return 1 if adapter.nil?
59
-
60
- project = resolve_or_fail(gem_name)
61
- return 1 if project.nil?
62
-
63
- execute(adapter, project, issue)
64
- rescue AuthRequired
65
- @stderr.puts "Not authenticated. Run `gem-contribute auth login` first."
66
- 1
67
- rescue AdapterError => e
68
- @stderr.puts "fork-clone-branch failed: #{e.message}"
69
- 1
70
- end
71
-
72
- private
73
-
74
- def missing_clone_root
75
- @stderr.puts "clone_root is not configured. Run `gem-contribute init` first."
76
- 1
77
- end
78
-
79
- def print_usage_error
80
- @stderr.puts "Usage: gem-contribute fork-clone-branch <gem>/<issue#>"
81
- 2
82
- end
83
-
84
- def build_adapter
85
- token = @store.token_for("github.com")
86
- if token.nil?
87
- @stderr.puts "Not authenticated. Run `gem-contribute auth login` first."
88
- return nil
89
- end
90
- @adapter_factory.call(token: token)
91
- end
92
-
93
- def resolve_or_fail(gem_name)
94
- return GemContribute::SELF_PROJECT if gem_name == GemContribute::SELF_PROJECT.gem_name
95
-
96
- gem = LockedGem.new(name: gem_name, version: "*", source_type: :rubygems, source_uri: "https://rubygems.org/")
97
- project = @resolver.resolve(gem)
98
-
99
- if project.host != "github.com"
100
- @stderr.puts "Cannot fork-clone-branch: #{gem_name} resolves to #{project.host} " \
101
- "(only github.com is supported at v0.1)"
102
- return nil
103
- end
104
-
105
- project
106
- end
107
-
108
- def execute(adapter, project, issue)
109
- viewer = adapter.viewer_login
110
- clone_url = ensure_fork(adapter, project, viewer)
111
- local_path = clone_into_root(project, clone_url)
112
- branch_name = "#{BRANCH_PREFIX}#{issue}"
113
- @git.checkout_branch(local_path, branch_name)
114
- # `submit` needs to know the canonical project to point the PR at.
115
- # Naming it `upstream` follows the standard fork workflow convention.
116
- @git.add_remote(local_path, "upstream",
117
- "https://github.com/#{project.owner}/#{project.repo}.git")
118
-
119
- @stdout.puts "Forked, cloned, and branched."
120
- @stdout.puts " path: #{local_path}"
121
- @stdout.puts " branch: #{branch_name}"
122
- @stdout.puts " upstream: https://github.com/#{project.owner}/#{project.repo}"
123
- @stdout.puts " fork: https://github.com/#{viewer}/#{project.repo}"
124
- @stdout.puts
125
- @stdout.puts "Next: cd #{local_path} && make your changes, then `gem-contribute submit`."
126
- 0
127
- end
128
-
129
- def ensure_fork(adapter, project, viewer)
130
- if adapter.already_forked?(project)
131
- @stdout.puts "You already have a fork at #{viewer}/#{project.repo}. Skipping fork."
132
- return "https://github.com/#{viewer}/#{project.repo}.git"
133
- end
134
-
135
- @stdout.puts "Forking #{project.owner}/#{project.repo} → #{viewer}/#{project.repo}..."
136
- body = adapter.fork(project)
137
- wait_until_ready(adapter, viewer, project.repo)
138
- body.fetch("clone_url")
139
- end
140
-
141
- def wait_until_ready(adapter, viewer, name)
142
- ready = FORK_READINESS_RETRIES.times.any? do |i|
143
- break true if adapter.fork_ready?(viewer, name)
144
-
145
- @sleeper.call(FORK_READINESS_INTERVAL) unless i == FORK_READINESS_RETRIES - 1
146
- false
147
- end
148
- return if ready
149
-
150
- raise AdapterError, "fork not reachable after #{FORK_READINESS_RETRIES * FORK_READINESS_INTERVAL}s"
151
- end
152
-
153
- def clone_into_root(project, clone_url)
154
- target = File.join(@clone_root, project.owner, project.repo)
155
- if File.directory?(File.join(target, ".git"))
156
- @stdout.puts "Reusing existing clone at #{target}."
157
- return target
158
- end
159
-
160
- FileUtils.mkdir_p(File.dirname(target))
161
- @stdout.puts "Cloning into #{target}..."
162
- @git.clone(clone_url, target)
163
- target
164
- end
165
- end
166
-
167
- # Thin wrapper around git so the spec can swap in a fake without shelling
168
- # out. The real implementation uses Open3 with arg-list invocation — no
169
- # shell, so no injection surface.
170
- class Git
171
- def clone(url, target)
172
- run!(["git", "clone", url, target])
173
- end
174
-
175
- def checkout_branch(path, branch)
176
- run!(["git", "-C", path, "checkout", "-b", branch])
177
- end
178
-
179
- def add_remote(path, name, url)
180
- # Idempotent: if the remote already exists (e.g. reusing a clone)
181
- # we silently succeed rather than fail the whole flow.
182
- return if remote_exists?(path, name)
183
-
184
- run!(["git", "-C", path, "remote", "add", name, url])
185
- end
186
-
187
- def push(path, remote, branch)
188
- run!(["git", "-C", path, "push", "-u", remote, branch])
189
- end
190
-
191
- def remote_exists?(path, name)
192
- out, _err, status = Open3.capture3("git", "-C", path, "remote")
193
- status.success? && out.split("\n").include?(name)
194
- end
195
-
196
- def run!(argv)
197
- _stdout, stderr_str, status = Open3.capture3(*argv)
198
- return if status.success?
199
-
200
- raise GemContribute::AdapterError, "git #{argv[1..].join(" ")} failed: #{stderr_str.strip}"
201
- end
202
- end
203
- end
204
- end