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,425 @@
1
+ ---
2
+ marp: true
3
+ theme: default
4
+ paginate: true
5
+ size: 16:9
6
+ header: 'Blue Ridge Ruby 2026 · gem-contribute'
7
+ footer: ' '
8
+ style: |
9
+ /* Blue Ridge Ruby 2026 palette, pulled from the conference mark. */
10
+ :root {
11
+ --brr-light: #a8cce4; /* pale ridge blue */
12
+ --brr-ruby: #c2272e; /* ruby red */
13
+ --brr-blue: #2872b4; /* mid blue */
14
+ --brr-navy: #0e2854; /* deep navy */
15
+ --brr-cream: #fbf9f5; /* slide background */
16
+ --brr-ink: #16213a; /* body text */
17
+ }
18
+
19
+ section {
20
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", sans-serif;
21
+ background: var(--brr-cream);
22
+ color: var(--brr-ink);
23
+ padding: 60px 80px;
24
+ position: relative;
25
+ }
26
+
27
+ /* Header strap with the conference identifier on every content slide.
28
+ Uses Marp's `header:` directive so it survives PDF export (unlike
29
+ ::before/::after `content:` properties, which Marp drops). */
30
+ header {
31
+ position: absolute;
32
+ top: 28px; right: 80px;
33
+ left: auto;
34
+ width: auto;
35
+ margin: 0;
36
+ font-size: 0.65em;
37
+ letter-spacing: 0.08em;
38
+ text-transform: uppercase;
39
+ color: var(--brr-blue);
40
+ }
41
+
42
+ /* Footer is empty content but its element provides the gradient bar at
43
+ the bottom of every slide. The four-color stripe is the conference
44
+ palette in order. */
45
+ footer {
46
+ position: absolute;
47
+ left: 0; right: 0; bottom: 0;
48
+ margin: 0;
49
+ padding: 0;
50
+ height: 6px;
51
+ width: 100%;
52
+ color: transparent;
53
+ font-size: 0;
54
+ background: linear-gradient(
55
+ to right,
56
+ var(--brr-light) 0% 25%,
57
+ var(--brr-ruby) 25% 50%,
58
+ var(--brr-blue) 50% 75%,
59
+ var(--brr-navy) 75% 100%
60
+ );
61
+ }
62
+
63
+ h1, h2, h3 {
64
+ color: var(--brr-navy);
65
+ font-weight: 700;
66
+ letter-spacing: -0.01em;
67
+ }
68
+ h1 { font-size: 2.4em; }
69
+ h2 { font-size: 1.8em; }
70
+
71
+ a { color: var(--brr-blue); text-decoration: underline; }
72
+ strong { color: var(--brr-ruby); }
73
+
74
+ code, pre { font-family: "JetBrains Mono", "Fira Code", Menlo, monospace; }
75
+ code {
76
+ background: rgba(40, 114, 180, 0.10);
77
+ color: var(--brr-navy);
78
+ padding: 2px 6px;
79
+ border-radius: 3px;
80
+ }
81
+ pre {
82
+ font-size: 0.78em;
83
+ background: var(--brr-navy);
84
+ color: #f0f4fa;
85
+ padding: 18px 22px;
86
+ border-radius: 6px;
87
+ border-left: 4px solid var(--brr-ruby);
88
+ }
89
+ pre code {
90
+ background: transparent;
91
+ color: inherit;
92
+ padding: 0;
93
+ }
94
+
95
+ blockquote {
96
+ font-size: 1.5em;
97
+ border-left: 6px solid var(--brr-ruby);
98
+ padding: 8px 0 8px 28px;
99
+ margin-left: 0;
100
+ color: var(--brr-navy);
101
+ font-style: normal;
102
+ font-weight: 500;
103
+ }
104
+
105
+ table { border-collapse: collapse; }
106
+ th { background: var(--brr-navy); color: white; padding: 10px 16px; }
107
+ td { padding: 8px 16px; border-bottom: 1px solid var(--brr-light); }
108
+ tr:last-child td { border-bottom: none; }
109
+
110
+ .small { font-size: 0.7em; color: #5a6a82; }
111
+ .big { font-size: 1.6em; }
112
+
113
+ /* Page numbers, themed. */
114
+ section::part(pagination) { color: var(--brr-blue); }
115
+
116
+ /* Title and closing slides invert: navy background, light type.
117
+ All overrides below were chosen to clear WCAG AA (4.5:1) for normal
118
+ text and 3:1 for large text against the navy background. */
119
+ section.title {
120
+ background: var(--brr-navy);
121
+ color: white;
122
+ text-align: center;
123
+ padding-top: 18%;
124
+ }
125
+ /* On title and closing slides we hide the header strap (no `Blue Ridge
126
+ Ruby 2026 · gem-contribute` repeating over the title) but keep the
127
+ gradient bar — it visually anchors every slide identically. */
128
+ section.title header { display: none; }
129
+ section.title h1 {
130
+ color: white;
131
+ font-size: 3.2em;
132
+ margin-bottom: 0.1em;
133
+ }
134
+ section.title h2 {
135
+ color: var(--brr-light);
136
+ font-weight: 400;
137
+ font-size: 1.4em;
138
+ margin-top: 0;
139
+ }
140
+ /* Brighter ruby (#ff5b62 ≈ 5.0:1 on navy) so the URL pops without
141
+ dropping below WCAG AA. The original --brr-ruby is fine on cream
142
+ but fails on navy. */
143
+ section.title strong { color: #ff5b62; }
144
+ section.title a { color: var(--brr-light); }
145
+ /* Inline code on title slides: invert to white-on-translucent-white
146
+ (≈ 12:1 on navy) — the cream-on-cream default is invisible here. */
147
+ section.title code {
148
+ background: rgba(255, 255, 255, 0.18);
149
+ color: white;
150
+ }
151
+ section.title pre {
152
+ background: rgba(255, 255, 255, 0.08);
153
+ color: white;
154
+ border-left-color: #ff5b62;
155
+ }
156
+ section.title pre code {
157
+ background: transparent;
158
+ color: inherit;
159
+ }
160
+ /* Disclosure / fine-print on title slides — clears AA at ~6.5:1. */
161
+ section.title .small { color: #b9cde0; }
162
+ /* Blockquotes on title slides: white text, no left border (clashes
163
+ with center alignment), constrained width so lines wrap naturally. */
164
+ section.title blockquote {
165
+ color: white;
166
+ border-left: none;
167
+ text-align: center;
168
+ padding: 8px 0;
169
+ margin: 0 auto;
170
+ max-width: 70%;
171
+ font-size: 1.4em;
172
+ }
173
+ ---
174
+
175
+ <!-- _class: title -->
176
+
177
+ <!-- Speaker note (~10s): Title slide. Smile, take a breath, let
178
+ the room settle. "I'm Chris Hagmann. I want to talk about
179
+ building tools that don't exist yet — using a small one I
180
+ made this week as the example." -->
181
+
182
+ # gem-contribute
183
+
184
+ ## Building what you cannot find
185
+
186
+ Chris Hagmann · Blue Ridge Ruby 2026
187
+
188
+ ---
189
+
190
+ <!-- Speaker note (~15s): Hold the slide. Don't read it aloud —
191
+ let them read it themselves. Then, quietly: "That's been me
192
+ for years. I suspect it's been some of you." Beat. Move on.
193
+ Make it personal, not a claim about the whole room. -->
194
+
195
+ > I wanted to contribute back.
196
+ >
197
+ > I never figured out where to start.
198
+
199
+ ---
200
+
201
+ <!-- Speaker note (~25s): "I volunteered yesterday to help with
202
+ Hack Day tomorrow. When I said yes, I needed a list of good
203
+ Ruby projects with approachable issues to point people at.
204
+ That list didn't exist." Pause on "didn't exist."
205
+ Then: "So I built one. And along the way I noticed something
206
+ that I think generalizes." That's the bridge to the next
207
+ slide. -->
208
+
209
+ ## A small problem
210
+
211
+ I volunteered to help with **Hack Day** tomorrow.
212
+
213
+ <br>
214
+
215
+ I needed a list of **good Ruby projects** to point people at.
216
+
217
+ <br>
218
+
219
+ That list didn't exist.
220
+
221
+ <br>
222
+
223
+ > So I built one.
224
+
225
+ ---
226
+
227
+ <!-- Speaker note (~20s): "There ARE resources. Four of them, all
228
+ fine, all sparse." Don't read the URLs. The point is the
229
+ pattern, not the list. "They're sparse for the same reason:
230
+ they need a maintainer to opt their project in. Most
231
+ maintainers never do."
232
+ Then the turn: "The signal I needed was a different kind of
233
+ opt-in. Mine." Land on "mine." -->
234
+
235
+ ## What's out there
236
+
237
+ - **goodfirstissue.dev** · opt-in registry · sparse
238
+ - **goodfirstissues.com** · opt-in registry · sparse
239
+ - **github.com/topics/good-first-issue** · opt-in topic · sparse
240
+ - **forgoodfirstissue.github.com** · curated · narrow
241
+
242
+ <br>
243
+
244
+ These all rely on **maintainer opt-in** — and most maintainers never do.
245
+
246
+ <br>
247
+
248
+ The signal I needed was a different kind of opt-in: **mine.**
249
+
250
+ ---
251
+
252
+ <!-- Speaker note (~25s): "Bundler shipped this insight years
253
+ ago. `bundle fund` reads your Gemfile.lock to answer one
254
+ question: where should my dollars go? It's the right index
255
+ for the question." Beat. "Same index, different question:
256
+ where should my hours go?"
257
+ Land hard on the slogan. This is the meme of the talk. -->
258
+
259
+ ## `bundle fund` for time
260
+
261
+ `bundle fund` reads your `Gemfile.lock` to answer
262
+ *"where should my **dollars** go?"*
263
+
264
+ <br>
265
+
266
+ `gem-contribute` reads the same file to answer
267
+ *"where should my **hours** go?"*
268
+
269
+ ---
270
+
271
+ <!-- Speaker note (~20s): "Two hundred and sixteen gems in this
272
+ project. Two hundred and sixteen maintainers I've already
273
+ bet on. Two hundred and sixteen codebases I have at least
274
+ a little context on."
275
+ "That's already a curated list. I just had to use it." -->
276
+
277
+ ## The insight was already on disk
278
+
279
+ ```
280
+ $ bundle list | wc -l
281
+ 216
282
+ ```
283
+
284
+ <br>
285
+
286
+ Your `Gemfile.lock` is already curated.
287
+
288
+ - ~216 maintainers you've **already bet on**
289
+ - The OSS code you have **the most context on**
290
+ - A vote of confidence with versions attached
291
+
292
+ <br>
293
+
294
+ Start *there*, not on GitHub.
295
+
296
+ ---
297
+
298
+ <!-- Speaker note (~45s): Let them read the output for ~10s
299
+ before talking. Then walk down the list:
300
+ "Sorbet has fifty open good-first-issues. Fifty."
301
+ "RSpec OpenAPI, five. Packwerk, four. Rubocop, four."
302
+ Point at gem-contribute on row three. Smile.
303
+ "And the tool itself, four. It found itself. We'll come
304
+ back to that." That's the meta-joke; don't oversell it. -->
305
+
306
+ ## So I built it
307
+
308
+ ```
309
+ $ gem-contribute scan
310
+ Scanning Gemfile.lock (234 gems)...
311
+ 234 gems · 230 on github.com · 1 on gitlab.com · 3 unknown source
312
+
313
+ Top contributable projects (by open `good first issue` count):
314
+ sorbet-runtime 50 github.com/sorbet/sorbet
315
+ rspec-openapi 5 github.com/exoego/rspec-openapi
316
+ gem-contribute 4 github.com/cdhagmann/gem-contribute
317
+ packwerk 4 github.com/Shopify/packwerk
318
+ rubocop 4 github.com/rubocop/rubocop
319
+ gitlab 3 github.com/NARKOZ/gitlab
320
+ pundit 2 github.com/varvet/pundit
321
+ ...
322
+ ```
323
+
324
+ <!-- (Drill-in slide cut for time. Mention in patter:
325
+ "You can list the issues for any of these.") -->
326
+
327
+ ---
328
+
329
+ <!-- Speaker note (~40s): Pre-frame: "You pick an issue. One
330
+ command does the rest."
331
+ Read silence ~8s. Then narrate: "It forks the repo to your
332
+ account. Clones the fork locally. Adds the upstream remote.
333
+ Creates a branch named after the issue."
334
+ Pause. "All the git ceremony, gone." -->
335
+
336
+ ## Fix it
337
+
338
+ ```
339
+ $ gem-contribute fix rubocop/14102
340
+ Forking rubocop/rubocop → cdhagmann/rubocop...
341
+ Cloning into ~/code/oss/rubocop/rubocop...
342
+ Forked, cloned, and branched.
343
+ path: ~/code/oss/rubocop/rubocop
344
+ branch: gem-contribute/issue-14102
345
+ upstream: github.com/rubocop/rubocop
346
+ fork: github.com/cdhagmann/rubocop
347
+
348
+ Next: cd ~/code/oss/rubocop/rubocop && make your changes,
349
+ then `gem-contribute submit`.
350
+ ```
351
+
352
+ ---
353
+
354
+ <!-- Speaker note (~35s): "You write the fix. You commit it.
355
+ You run submit." Beat for the URL to render.
356
+ "It pushes your branch. It opens the compare URL with the
357
+ title, the body, and `Closes #14102` already filled in."
358
+ Bottom-line it: "Browser opens. PR is pre-filled. Review.
359
+ Click Create." -->
360
+
361
+ ## Submit it
362
+
363
+ ```
364
+ $ gem-contribute submit
365
+ Pushing gem-contribute/issue-14102 to origin...
366
+ Opened browser to:
367
+ https://github.com/rubocop/rubocop/compare/cdhagmann:gem-contribute/issue-14102
368
+ ?expand=1&title=Fix+%2314102%3A+Allow+Lint%2FVoid+...&body=Closes+%2314102.
369
+ ```
370
+
371
+ <br>
372
+
373
+ Browser opens. PR is pre-filled. Review. Click **Create**.
374
+
375
+ ---
376
+
377
+ <!-- Speaker note (~20s): The bridge from demo to lesson. Slow
378
+ down here. The tool is the example, not the lesson.
379
+ "I'm doing this for gems. But the pattern works wherever
380
+ you look. The things you depend on are the things you should
381
+ give back to. Once you see that, you start noticing it
382
+ everywhere." Pause. Move on. -->
383
+
384
+ ## The pattern generalizes
385
+
386
+ > The things you depend on
387
+ > are the things you should give back to.
388
+ >
389
+ > Once you see that, you start noticing it everywhere.
390
+
391
+ ---
392
+
393
+ <!-- _class: title -->
394
+
395
+ <!-- Speaker note (~15s): The takeaway slide. Read it slowly,
396
+ one beat per line. Don't editorialize. The audience should
397
+ leave the room with this in their head — not the install
398
+ command, not the gem name, this. -->
399
+
400
+ > Build for yourself first.
401
+ >
402
+ > Help where you can.
403
+ >
404
+ > The rest is bonus.
405
+
406
+ ---
407
+
408
+ <!-- _class: title -->
409
+
410
+ <!-- Speaker note (~10s): "Thanks. The gem installs today. I'll
411
+ be at Hack Day tomorrow if you want to try it on your
412
+ laptop. Find me." Don't run over. Step back from the mic. -->
413
+
414
+ ## Thanks
415
+
416
+ `gem install gem-contribute`
417
+ **cdhagmann.com/gem-contribute**
418
+
419
+ <br>
420
+
421
+ Find me at **Hack Day** tomorrow — I'll watch it work on your laptop.
422
+
423
+ <br>
424
+
425
+ <span class="small">AI-assisted; ADRs explain why.</span>
Binary file
@@ -10,6 +10,8 @@ module GemContribute
10
10
  # validate it by hitting /user)
11
11
  # logout — drop the cached token for github.com
12
12
  class Auth
13
+ include PlatformTools
14
+
13
15
  USAGE = <<~USAGE
14
16
  Usage: gem-contribute auth <subcommand>
15
17
 
@@ -21,11 +23,11 @@ module GemContribute
21
23
 
22
24
  DEFAULT_HOST = "github.com"
23
25
 
24
- def initialize(stdout: $stdout, stderr: $stderr, store: TokenStore.new,
26
+ def initialize(stdout: $stdout, stderr: $stderr, output: nil,
27
+ store: TokenStore.new,
25
28
  sleeper: ->(s) { Kernel.sleep(s) },
26
29
  browser_opener: nil, clipper: nil)
27
- @stdout = stdout
28
- @stderr = stderr
30
+ @output = output || Output::Standard.new(out: stdout, err: stderr)
29
31
  @store = store
30
32
  @sleeper = sleeper
31
33
  @browser_opener = browser_opener || method(:default_browser_opener)
@@ -38,11 +40,11 @@ module GemContribute
38
40
  when "status" then status
39
41
  when "logout" then logout
40
42
  when nil, "help", "-h", "--help"
41
- @stdout.puts USAGE
43
+ @output.info(USAGE)
42
44
  0
43
45
  else
44
- @stderr.puts "gem-contribute: unknown auth subcommand"
45
- @stderr.puts USAGE
46
+ @output.error("gem-contribute: unknown auth subcommand")
47
+ @output.error(USAGE)
46
48
  2
47
49
  end
48
50
  end
@@ -55,20 +57,20 @@ module GemContribute
55
57
  result = poll_loop(device_code)
56
58
  persist_or_report(result)
57
59
  rescue GemContribute::Auth::AuthError => e
58
- @stderr.puts "auth login failed: #{e.message}"
60
+ @output.error("auth login failed: #{e.message}")
59
61
  1
60
62
  end
61
63
 
62
64
  def prompt_user(device_code)
63
65
  copied = @clipper.call(device_code.user_code)
64
66
  code_suffix = copied ? " (copied to clipboard)" : ""
65
- @stdout.puts "Your one-time code#{code_suffix}: #{device_code.user_code}"
67
+ @output.info("Your one-time code#{code_suffix}: #{device_code.user_code}")
66
68
 
67
69
  opened = @browser_opener.call(device_code.verification_uri)
68
70
  url_prefix = opened ? "Browser opened to" : "Visit"
69
- @stdout.puts "#{url_prefix}: #{device_code.verification_uri}"
71
+ @output.info("#{url_prefix}: #{device_code.verification_uri}")
70
72
 
71
- @stdout.puts "Waiting for you to authorize..."
73
+ @output.progress("Waiting for you to authorize...")
72
74
  end
73
75
 
74
76
  def poll_loop(device_code)
@@ -92,16 +94,16 @@ module GemContribute
92
94
  case result.status
93
95
  when :ok
94
96
  @store.store(DEFAULT_HOST, access_token: result.token, scope: result.scope)
95
- @stdout.puts "Authenticated. Token saved to #{TokenStore.default_path} (mode 0600)."
97
+ @output.info("Authenticated. Token saved to #{TokenStore.default_path} (mode 0600).")
96
98
  0
97
99
  when :expired
98
- @stderr.puts "Device code expired. Run `gem-contribute auth login` again."
100
+ @output.error("Device code expired. Run `gem-contribute auth login` again.")
99
101
  1
100
102
  when :denied
101
- @stderr.puts "Authorization denied."
103
+ @output.error("Authorization denied.")
102
104
  1
103
105
  else
104
- @stderr.puts "auth login failed: #{result.error_message}"
106
+ @output.error("auth login failed: #{result.error_message}")
105
107
  1
106
108
  end
107
109
  end
@@ -109,7 +111,7 @@ module GemContribute
109
111
  def status
110
112
  entry = @store.entry_for(DEFAULT_HOST)
111
113
  if entry.nil?
112
- @stdout.puts "Not authenticated. Run `gem-contribute auth login`."
114
+ @output.info("Not authenticated. Run `gem-contribute auth login`.")
113
115
  return 1
114
116
  end
115
117
 
@@ -119,46 +121,22 @@ module GemContribute
119
121
  def verify_and_print(entry)
120
122
  adapter = HostAdapters::GitHubAdapter.new(token: entry["access_token"])
121
123
  login_name = adapter.viewer_login
122
- @stdout.puts "Authenticated as @#{login_name} on #{DEFAULT_HOST} (scope: #{entry["scope"] || "unknown"})"
124
+ @output.info("Authenticated as @#{login_name} on #{DEFAULT_HOST} (scope: #{entry["scope"] || "unknown"})")
123
125
  0
124
126
  rescue GemContribute::AuthRequired, GemContribute::AdapterError => e
125
- @stderr.puts "Token cached for #{DEFAULT_HOST} but verification failed: #{e.message}"
126
- @stderr.puts "Run `gem-contribute auth login` to refresh."
127
+ @output.error("Token cached for #{DEFAULT_HOST} but verification failed: #{e.message}")
128
+ @output.error("Run `gem-contribute auth login` to refresh.")
127
129
  1
128
130
  end
129
131
 
130
132
  def logout
131
133
  if @store.delete(DEFAULT_HOST)
132
- @stdout.puts "Logged out of #{DEFAULT_HOST}."
134
+ @output.info("Logged out of #{DEFAULT_HOST}.")
133
135
  else
134
- @stdout.puts "No cached token for #{DEFAULT_HOST}."
136
+ @output.info("No cached token for #{DEFAULT_HOST}.")
135
137
  end
136
138
  0
137
139
  end
138
-
139
- def default_browser_opener(uri)
140
- cmd = case RbConfig::CONFIG["host_os"]
141
- when /darwin/ then "open"
142
- when /linux/ then "xdg-open"
143
- when /mswin|mingw|cygwin/ then "start"
144
- end
145
- cmd && Kernel.system(cmd, uri)
146
- rescue StandardError
147
- false
148
- end
149
-
150
- def default_clipper(text)
151
- case RbConfig::CONFIG["host_os"]
152
- when /darwin/
153
- IO.popen("pbcopy", "w") { |p| p.write(text) }
154
- true
155
- when /linux/
156
- IO.popen(["xclip", "-selection", "clipboard"], "w") { |p| p.write(text) }
157
- true
158
- end
159
- rescue StandardError
160
- false
161
- end
162
140
  end
163
141
  end
164
142
  end
@@ -18,13 +18,21 @@ module GemContribute
18
18
  list Print all configured values.
19
19
 
20
20
  Keys:
21
- clone_root Directory where forks are cloned (default: ~/code/oss).
22
- Example: gem-contribute config set clone_root ~/Projects/oss
21
+ clone_root Directory where forks are cloned. Set with
22
+ `gem-contribute init` (interactive) or
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.
23
31
  USAGE
24
32
 
25
- def initialize(stdout: $stdout, stderr: $stderr, config: GemContribute::Config.new)
26
- @stdout = stdout
27
- @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)
28
36
  @config = config
29
37
  end
30
38
 
@@ -34,11 +42,11 @@ module GemContribute
34
42
  when "get" then get(argv)
35
43
  when "list" then list
36
44
  when nil, "help", "-h", "--help"
37
- @stdout.puts USAGE
45
+ @output.info(USAGE)
38
46
  0
39
47
  else
40
- @stderr.puts "gem-contribute: unknown config subcommand"
41
- @stderr.puts USAGE
48
+ @output.error("gem-contribute: unknown config subcommand")
49
+ @output.error(USAGE)
42
50
  2
43
51
  end
44
52
  end
@@ -49,37 +57,45 @@ module GemContribute
49
57
  key = argv.shift
50
58
  value = argv.shift
51
59
  if key.nil? || value.nil?
52
- @stderr.puts "Usage: gem-contribute config set <key> <value>"
60
+ @output.error("Usage: gem-contribute config set <key> <value>")
53
61
  return 2
54
62
  end
55
63
 
56
64
  @config.set(key, value)
57
- @stdout.puts "#{key} = #{value}"
65
+ @output.info("#{key} = #{value}")
58
66
  0
59
67
  rescue ArgumentError => e
60
- @stderr.puts e.message
68
+ @output.error(e.message)
61
69
  1
62
70
  end
63
71
 
64
72
  def get(argv)
65
73
  key = argv.shift
66
74
  if key.nil?
67
- @stderr.puts "Usage: gem-contribute config get <key>"
75
+ @output.error("Usage: gem-contribute config get <key>")
68
76
  return 2
69
77
  end
70
78
 
71
79
  unless GemContribute::Config::KNOWN_KEYS.include?(key)
72
- @stderr.puts "unknown config key #{key.inspect}"
80
+ @output.error("unknown config key #{key.inspect}")
73
81
  return 1
74
82
  end
75
83
 
76
- @stdout.puts @config.to_h.fetch(key, "(not set default applies)")
84
+ @output.info(@config.to_h.fetch(key, "(not set; run `gem-contribute init`)"))
77
85
  0
78
86
  end
79
87
 
80
88
  def list
81
- @stdout.puts "Configuration (#{GemContribute::Config.default_path}):"
82
- @stdout.puts " clone_root = #{@config.clone_root}"
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
83
99
  0
84
100
  end
85
101
  end