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,75 @@
1
+ # ADR 0014: Ship Bundler and RubyGems plugins as v1 interfaces
2
+
3
+ **Status:** Accepted
4
+ **Date:** 2026-05-03
5
+ **Amends:** [ADR-0006](0006-standalone-gem-not-plugin.md), [ADR-0012](0012-output-free-service-objects-three-interface-architecture.md)
6
+
7
+ ## Context
8
+
9
+ [ADR-0006](0006-standalone-gem-not-plugin.md) (2026-04-27) decided to ship as a standalone gem rather than a Bundler plugin. The primary reason was workshop scope — Bundler plugin authoring would distract attendees from the actual learning objectives (Ratatui, OAuth, GitHub's API). It explicitly left the door open: *"A future ADR can revisit this if the tool sees real adoption and the plugin UX becomes the bottleneck."*
10
+
11
+ [ADR-0012](0012-output-free-service-objects-three-interface-architecture.md) (2026-05-03 morning) added a RubyGems plugin (`gem contribute`) as a third interface, alongside the standalone CLI and the (then-bubbletea) TUI. It noted that the Bundler plugin decision in ADR-0006 was unchanged.
12
+
13
+ The Blue Ridge Ruby workshop concluded 2026-05-02. ADR-0006's workshop-scope concern is no longer load-bearing.
14
+
15
+ Separately: the v1 release goal now includes both `bundle contribute` *and* `gem contribute` as discoverable entry points, alongside `gem-contribute` itself. This makes the tool reachable from whatever invocation surface a user is already in.
16
+
17
+ ## Decision
18
+
19
+ Ship three entry points for v1:
20
+
21
+ 1. **`gem-contribute`** — standalone CLI binary. Bare invocation (no subcommand) launches the Rooibos TUI. Subcommands run as CLI verbs.
22
+ 2. **`bundle contribute`** — Bundler plugin. CLI-only. Bare invocation runs a default summary verb (TBD: `scan` vs `list all`). Subcommands run as CLI verbs.
23
+ 3. **`gem contribute`** — RubyGems plugin. CLI-only. Same shape as `bundle contribute`.
24
+
25
+ All three entry points ship in **a single gem** (`gem-contribute`). One `gem install gem-contribute` registers the standalone binary, the Bundler plugin, and the RubyGems plugin.
26
+
27
+ ## Reasoning
28
+
29
+ **The workshop scope-creep argument has expired.** ADR-0006's central rejection was "plugin authoring would distract workshop attendees." Workshop is done; the v1 audience is end users and contributors, not workshop attendees. The remaining ADR-0006 reasoning (UX nicety of `bundle X`) actually *supports* shipping plugins.
30
+
31
+ **Plugin entry points are CLI-only, by design.** Bundler and RubyGems plugin ecosystems are built around CLI subcommands, not interactive TUIs. Users running `bundle contribute` expect the same kind of behavior as `bundle exec`, `bundle install`, etc. — text in, text out. The TUI is a property of the standalone binary; the plugins delegate into the same service-layer entry points the CLI uses.
32
+
33
+ This has a useful architectural consequence: the plugin entry points never need to load Rooibos or `ratatui_ruby`. Plugin install stays lightweight, and plugin invocations don't pay the TUI startup cost.
34
+
35
+ **One gem rather than three.** ADR-0012 sketched a future `rubygems-contribute` gem. Three gems would follow Ruby ecosystem convention (`bundler-X`, `rubygems-X`) but tripples release ceremony, version coordination, and CHANGELOG maintenance. For a project this size, that cost is not earned. One gem with three entry points: one `gem install`, one CHANGELOG, one version, all three interfaces work.
36
+
37
+ **ADR-0012's three-interface framing carries over unchanged.** The service layer (output-free, returns `Result`) is what enables three interfaces to share code. ADR-0014 doesn't add a fourth interface; it confirms the third (Bundler plugin) and locks the packaging to a single gem.
38
+
39
+ ## Alternatives considered
40
+
41
+ - **Stay standalone-only (ADR-0006 unmodified).** Rejected: the original justification (workshop scope) no longer applies, and one of v1's product goals is to make the tool reachable from `bundle X` and `gem X` invocations.
42
+ - **Three separate gems.** Rejected: triples release ceremony for marginal architectural cleanliness. The plugin shims would be tiny — a Bundler `Plugin::API` registration and a `Gem::Command` subclass — not worth their own gemspec, version, and changelog.
43
+ - **Ship `bundle contribute` only, defer `gem contribute` to v1.x.** Rejected as worse-of-both-worlds: same release work, half the surface area covered. They're symmetric pieces of work.
44
+ - **TUI in plugins too.** Rejected: not idiomatic for the Bundler/RubyGems plugin ecosystems, doubles the per-invocation startup cost, and the standalone binary already serves the "I want the TUI" use case.
45
+
46
+ ## Consequences
47
+
48
+ **On the gemspec:**
49
+ - Add `plugins.rb` (Bundler plugin entry point) per Bundler plugin convention.
50
+ - Add `rubygems_plugin.rb` (RubyGems plugin entry point) per RubyGems plugin convention.
51
+ - The two entry points register their respective subcommand classes (or delegate to a shared dispatch table).
52
+
53
+ **On `lib/gem_contribute/cli.rb`:**
54
+ - The dispatch table becomes the single source of truth for verb registration.
55
+ - Bare-arg behavior diverges per entry point: `gem-contribute` launches TUI; `bundle contribute` and `gem contribute` run a default CLI verb.
56
+ - Consider `dry-cli` to formalize multi-entry-point command registration (deferred decision; see OPEN_QUESTIONS Q6 sub-question).
57
+
58
+ **On the Bundler plugin entry point:** must not require Rooibos or `ratatui_ruby` at load time. TUI loading is gated to the standalone-binary entry point only.
59
+
60
+ **On `ADR-0006`:** status updated to note ADR-0014 amends it. The standalone-gem decision stands; the no-Bundler-plugin decision is reversed.
61
+
62
+ **On `ADR-0012`:** status updated to note ADR-0014 amends it. The three-interface architecture is preserved; the planned separate `rubygems-contribute` gem is replaced by a single `gem-contribute` gem with three entry points.
63
+
64
+ **On `docs/design-interface-layer.md`:** "gem plugin pipeline" section needs updating — the plugin shim is internal to the `gem-contribute` gem, not a separate `rubygems-contribute` gem. Same change for the Bundler plugin section (which doesn't yet exist; needs adding).
65
+
66
+ **On testing:** add at least one smoke test per plugin entry point that proves the plugin registers and dispatches a verb without booting the TUI. Implementation tests live in the existing CLI verb specs (verbs aren't aware of which entry point invoked them).
67
+
68
+ **On release:** the `bundle plugin install gem-contribute` and `gem install gem-contribute` paths both need verification before v1 ships. Add to Phase 6 acceptance.
69
+
70
+ ## What this *doesn't* change
71
+
72
+ - ADR-0001 through ADR-0005, ADR-0007 (data layer, display rules). Plugin entry points reuse the same service layer.
73
+ - ADR-0009 (top-level namespace). Plugins live under `GemContribute::` like everything else.
74
+ - ADR-0011 (HostAdapter owns host verbs). Service layer; orthogonal.
75
+ - ADR-0013 (Rooibos as TUI framework). The plugins are CLI-only; framework choice doesn't reach them.
data/docs/adr/README.md CHANGED
@@ -11,10 +11,15 @@ Format: [Michael Nygard's template](https://github.com/joelparkerhenderson/archi
11
11
  - [0003 — Prefer `bug_tracker_uri` over `source_code_uri`](0003-issue-tracker-preference.md)
12
12
  - [0004 — Use OAuth Device Flow, not PATs](0004-device-flow-auth.md)
13
13
  - [0005 — Render labels verbatim](0005-render-labels-verbatim.md)
14
- - [0006 — Ship as a standalone gem, not a Bundler plugin](0006-standalone-gem-not-plugin.md)
14
+ - [0006 — Ship as a standalone gem, not a Bundler plugin](0006-standalone-gem-not-plugin.md) — Bundler-plugin decision reversed by 0014; standalone-gem decision stands
15
15
  - [0007 — Show CONTRIBUTING; don't parse it](0007-display-contributing-verbatim.md)
16
- - [0008 — Use Rooibos for the TUI layer](0008-rooibos-tui-framework.md)
16
+ - [0008 — Use Rooibos for the TUI layer](0008-rooibos-tui-framework.md) — superseded by 0010, substance restored by 0013
17
17
  - [0009 — Top-level namespace is `GemContribute`](0009-top-level-namespace.md)
18
+ - [0010 — Use Charm-Ruby (bubbletea + lipgloss) for the TUI layer](0010-charm-ruby-tui-framework.md) — superseded by 0013
19
+ - [0011 — HostAdapter owns host verbs; Operations compose them; CLI verbs compose Operations](0011-host-adapter-owns-host-verbs.md)
20
+ - [0012 — Output-free service objects, dry-monads Result contract, three-interface architecture](0012-output-free-service-objects-three-interface-architecture.md) — packaging amended by 0014; service-layer contract stands
21
+ - [0013 — Revert TUI framework to Rooibos](0013-revert-to-rooibos.md) — supersedes 0010, restores 0008's substance
22
+ - [0014 — Ship Bundler and RubyGems plugins as v1 interfaces](0014-ship-bundler-and-rubygems-plugins.md) — amends 0006 and 0012
18
23
 
19
24
  ## When to add an ADR
20
25
 
@@ -0,0 +1,295 @@
1
+ # Interface Layer Design
2
+
3
+ This document describes the multi-interface architecture introduced by [ADR-0012](adr/0012-output-free-service-objects-three-interface-architecture.md). It is the reference for all work that touches the boundary between service objects and the three output vectors: CLI, TUI, and gem plugin. Read it before changing anything in `lib/gem_contribute/cli/` or `lib/gem_contribute/operations/`.
4
+
5
+ For the broader system architecture — parsers, resolvers, the host adapter interface, caching — see [`design.md`](design.md).
6
+
7
+ ## The problem with the current boundary
8
+
9
+ `Operations::Fork` and `Operations::Clone` accept `stdout:` and print progress during `call`:
10
+
11
+ ```ruby
12
+ def call(adapter:, project:)
13
+ @stdout.puts "Forking #{project.owner}/#{project.repo}..."
14
+ fork = adapter.fork(project)
15
+ @stdout.puts fork.reused ? " Reusing existing fork." : " Forked → ..."
16
+ Result.new(...)
17
+ end
18
+ ```
19
+
20
+ This works for a single CLI, but breaks across three interfaces:
21
+
22
+ - The **TUI** has no output stream. Progress is model state rendered by `View`, not a string printed to stdout. There is no clean way to route `@stdout.puts` into a Rooibos model update.
23
+ - The **gem plugin** needs to own its own output formatting and cannot share stdout injection with the CLI.
24
+ - **Tests** must assert on stdout side effects instead of return values, coupling test setup to I/O concerns.
25
+
26
+ The same problem appears in `Workflow#build_adapter`, which returns `nil` and prints to stderr on failure — two concerns collapsed into one method.
27
+
28
+ ## Target architecture
29
+
30
+ Three interface layers share one service layer. The service layer produces no output. Each interface layer translates `Result` values into the output appropriate for its medium.
31
+
32
+ ```
33
+ Service Layer
34
+ ┌────────────────────────────────────────────────────┐
35
+ │ Operations::Fork Operations::Clone │
36
+ │ Operations::FixPipeline Resolver │
37
+ │ HostAdapter Auth Git │
38
+ │ │
39
+ │ Returns Success(Result) | Failure(reason) │
40
+ │ No stdout. No stderr. No exceptions across layers.│
41
+ └───────────────┬───────────────────┬────────────────┘
42
+ │ │ │
43
+ ▼ ▼ ▼
44
+ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐
45
+ │ CLI Pipeline │ │ TUI Pipeline │ │ gem plugin │
46
+ │ │ │ │ │ Pipeline │
47
+ │ Output:: │ │ Commands + │ │ │
48
+ │ Standard │ │ Update │ │ reuses CLI │
49
+ │ tty-spinner │ │ │ │ pipeline │
50
+ │ tty-prompt │ │ │ │ │
51
+ └───────────────┘ └───────────────┘ └──────────────┘
52
+ ```
53
+
54
+ The three interface pipelines are entirely separate. A change to CLI output formatting has zero effect on the TUI. A new service object needs no knowledge of any interface.
55
+
56
+ ## Service layer
57
+
58
+ ### Result types
59
+
60
+ Every operation returns a `dry-monads` `Result`:
61
+
62
+ - `Success(value)` on the happy path — `value` is a `Data` struct.
63
+ - `Failure(reason)` for expected failure conditions — `reason` is a symbol or tagged tuple.
64
+
65
+ ```ruby
66
+ Success(Operations::Fork::Result.new(clone_url:, fork_url:, upstream_url:, viewer:, reused:))
67
+
68
+ Failure(:unauthenticated)
69
+ Failure(:adapter_error, "rate limit exceeded")
70
+ ```
71
+
72
+ Typed exceptions (`AuthRequired`, `AdapterError`) are not used as cross-layer signals. They may be raised and rescued within a single operation but do not propagate to callers.
73
+
74
+ ### No I/O
75
+
76
+ Operations accept no `stdout:` or `stderr:` parameter. The `# rubocop:disable Metrics/ParameterLists` suppressions in the current `CLI::Fork` and `CLI::Fix` initializers are a symptom of this pattern — the extra `stdout:` and `stderr:` parameters inflate every constructor. `dry-initializer` replaces the verbose initializer pattern and the suppressions go away:
77
+
78
+ ```ruby
79
+ class Operations::Fork
80
+ extend Dry::Initializer
81
+ # No stdout:. No stderr:. Pure computation.
82
+ end
83
+ ```
84
+
85
+ ### Multi-step pipelines
86
+
87
+ The `fix` flow (fork → clone → branch → announce) is a `dry-operation` pipeline. Each step receives a shared context, enriches it, and returns `Success(enriched)` or `Failure(reason)`. Failure at any step short-circuits the chain — no early returns, no nil checks.
88
+
89
+ ```ruby
90
+ class Operations::FixPipeline
91
+ include Dry::Operation
92
+
93
+ def call(adapter:, project:, issue:, viewer:)
94
+ fork_result = step Operations::Fork.new.call(adapter:, project:)
95
+ clone_result = step Operations::Clone.new.call(adapter:, project:, fork_result:)
96
+ branch_name = step Operations::Branch.new.call(path: clone_result.path, issue:)
97
+ step Operations::Announce.new.call(adapter:, project:, issue:, viewer:, was_resuming: clone_result.reused)
98
+ Success({ fork: fork_result, clone: clone_result, branch: branch_name })
99
+ end
100
+ end
101
+ ```
102
+
103
+ Both the CLI and TUI call `FixPipeline` rather than wiring the steps themselves.
104
+
105
+ ### `Workflow#build_adapter`
106
+
107
+ Currently returns `nil` and prints to stderr. Under the new contract:
108
+
109
+ ```ruby
110
+ def build_adapter
111
+ token = @store.token_for("github.com")
112
+ return Failure(:unauthenticated) if token.nil?
113
+
114
+ Success(@adapter_factory.call(token: token))
115
+ end
116
+ ```
117
+
118
+ Callers pattern-match on the result; no output side effect in the mixin.
119
+
120
+ ## CLI pipeline
121
+
122
+ ### `Output::Standard`
123
+
124
+ All CLI verbs use `Output::Standard` instead of raw `@stdout`/`@stderr`. It wraps both streams and exposes a semantic interface:
125
+
126
+ ```ruby
127
+ module GemContribute
128
+ module Output
129
+ class Standard
130
+ def initialize(out: $stdout, err: $stderr)
131
+ @out = out
132
+ @err = err
133
+ end
134
+
135
+ def info(message) = @out.puts(message)
136
+ def warn(message) = @err.puts("warning: #{message}")
137
+ def error(message) = @err.puts(message)
138
+ def progress(message) # tty-spinner in interactive terminals;
139
+ # falls back to info when stdout is not a TTY
140
+ end
141
+
142
+ class Null
143
+ def info(_) = nil
144
+ def warn(_) = nil
145
+ def error(_) = nil
146
+ def progress(_) = nil
147
+ end
148
+ end
149
+ end
150
+ ```
151
+
152
+ `#progress` is the one method that behaves differently from a plain `puts`. Long operations (fork, clone) show a spinner in interactive terminals. `tty-spinner` detects non-TTY environments automatically and falls back to a plain line — no caller checks `$stdout.tty?`.
153
+
154
+ ### Interactive prompts
155
+
156
+ `Init` is the one command that reads input. Its current `stdout.print` + injected `@gets` lambda is replaced by `tty-prompt`:
157
+
158
+ ```ruby
159
+ prompt = TTY::Prompt.new(input: @input, output: @output)
160
+ chosen = prompt.ask("Where should I clone repos?", default: DEFAULT_SUGGESTION)
161
+ proceed = prompt.yes?("Authenticate with GitHub now?")
162
+ ```
163
+
164
+ `TTY::Prompt.new(input:, output:)` provides the same test injection story the current `gets:` lambda provides, with proper default-value display, Y/n handling, and non-TTY fallback built in.
165
+
166
+ ### How CLI verbs change
167
+
168
+ CLI verbs print around service calls. The operation returns a `Result`; the verb interprets it:
169
+
170
+ ```ruby
171
+ def execute(adapter, project, flags)
172
+ @output.progress("Forking #{project.owner}/#{project.repo}...")
173
+
174
+ case @fork_op.call(adapter: adapter, project: project)
175
+ in Success(fork_result)
176
+ @output.info(fork_result.reused ? " Reusing existing fork." : " Forked.")
177
+ continue_with(fork_result, flags)
178
+ in Failure(:unauthenticated)
179
+ @output.error("Not authenticated. Run `gem-contribute auth login` first.")
180
+ 1
181
+ in Failure(:adapter_error, message)
182
+ @output.error(message)
183
+ 1
184
+ end
185
+ end
186
+ ```
187
+
188
+ The `with_workflow_rescues` wrapper in `Workflow` is retired — pattern matching on `Result` replaces it.
189
+
190
+ ## TUI pipeline
191
+
192
+ The TUI never uses an `Output` object. A Rooibos Command wraps the service call, runs it off-thread, and returns a message to `Update`:
193
+
194
+ ```ruby
195
+ def fix_command(adapter:, project:, issue:, viewer:)
196
+ -> {
197
+ result = Operations::FixPipeline.new.call(
198
+ adapter: adapter, project: project, issue: issue, viewer: viewer
199
+ )
200
+ { type: :fix_completed, result: result }
201
+ }
202
+ end
203
+ ```
204
+
205
+ `Update` pattern-matches on the message:
206
+
207
+ ```ruby
208
+ def update(msg, model)
209
+ case msg
210
+ in { type: :fix_completed, result: Success({ fork:, clone:, branch: }) }
211
+ model.with(local_path: clone.path, branch_name: branch, status: :done)
212
+ in { type: :fix_completed, result: Failure(:unauthenticated) }
213
+ [model.with(pending_action: :fix), AuthOverlay.start_command]
214
+ in { type: :fix_completed, result: Failure(:adapter_error, message) }
215
+ model.with(error: message)
216
+ end
217
+ end
218
+ ```
219
+
220
+ The service object returns a `Result`; the Command wraps it in a typed message; `Update` renders it as model state. No output object, no stdout, no cross-layer exceptions.
221
+
222
+ ## Plugin pipelines
223
+
224
+ Per [ADR-0014](adr/0014-ship-bundler-and-rubygems-plugins.md), v1 ships three entry points in a single `gem-contribute` gem: the standalone `gem-contribute` binary, a Bundler plugin (`bundle contribute`), and a RubyGems plugin (`gem contribute`). Both plugins are CLI-only; the TUI is a property of the standalone binary.
225
+
226
+ - **Bundler plugin** — `plugins.rb` at the gem root, per Bundler convention. Registers a `bundle contribute` command and delegates to the same dispatch table the standalone CLI uses.
227
+ - **RubyGems plugin** — `rubygems_plugin.rb`, per RubyGems convention. Registers a `Gem::Command` subclass for `gem contribute` and delegates the same way.
228
+
229
+ Both plugin entry points MUST NOT require Rooibos or `ratatui_ruby`. The TUI gets loaded only by the standalone-binary entry point, which keeps plugin install lightweight and plugin invocations fast.
230
+
231
+ When `dry-cli` is added (alongside the plugin shims), it replaces the hand-rolled `COMMANDS` dispatch in `cli.rb` with declarative command registration. All three entry points register the same underlying commands against their respective hosts — the commands themselves do not change.
232
+
233
+ The plugin work is deferred until the service layer and CLI pipeline are clean (this document's Phases 1 and 2) and the TUI lands ([ROADMAP](ROADMAP.md) Phase 3).
234
+
235
+ ## New dependencies
236
+
237
+ | Gem | Role | Phase |
238
+ |---|---|---|
239
+ | `dry-monads` | `Success`/`Failure` Result types | 1 |
240
+ | `dry-operation` | Multi-step pipeline composition for `fix` | 1 |
241
+ | `dry-initializer` | Clean option declarations; removes rubocop suppressions | 1 |
242
+ | `tty-spinner` | Spinner in `Output::Standard#progress` | 2 |
243
+ | `tty-prompt` | Interactive prompts in `Init` | 2 |
244
+ | `dry-cli` | Command registration across all three entry points | 4/5 (with plugin shims) |
245
+
246
+ Pin all new dependencies to a minor version (`~> x.y`). Bump deliberately; record significant bumps in an ADR note.
247
+
248
+ ## Migration sequence
249
+
250
+ ### Phase 1 — Service layer
251
+
252
+ 1. Add `dry-monads`, `dry-operation`, `dry-initializer` to the gemspec.
253
+ 2. Remove `stdout:` from `Operations::Fork` and `Operations::Clone`. Add `reused:` to `Clone::Result`.
254
+ 3. Convert both operations to return `Success(Result)` | `Failure(reason)`.
255
+ 4. Convert `Workflow#build_adapter` to return `Success(adapter)` | `Failure(:unauthenticated)`.
256
+ 5. Build `Operations::FixPipeline` using `dry-operation`.
257
+ 6. Replace long initializers in `CLI::Fork` and `CLI::Fix` with `dry-initializer` option declarations.
258
+ 7. Update all callers to pattern-match on `Result`. Retire `with_workflow_rescues`.
259
+
260
+ ### Phase 2 — CLI pipeline
261
+
262
+ 1. Introduce `Output::Standard` and `Output::Null`.
263
+ 2. Replace raw `@stdout`/`@stderr` in all CLI verbs with `@output`.
264
+ 3. Add `tty-spinner` for `Output::Standard#progress`.
265
+ 4. Replace `Init`'s `stdout.print` + `@gets` with `tty-prompt`.
266
+
267
+ ### Phases 4 and 5 — Bundler and RubyGems plugins
268
+
269
+ Per ADR-0014 and the [ROADMAP](ROADMAP.md), the gem-plugin work is split into two ROADMAP phases (one per plugin) and lives within the `gem-contribute` gem rather than as separate `bundler-contribute` / `rubygems-contribute` gems.
270
+
271
+ 1. Add `dry-cli`. Replace the `COMMANDS` dispatch in `cli.rb` with `dry-cli` command registration so multiple entry points can hang off it.
272
+ 2. Add `plugins.rb` at the gem root (Bundler plugin entry point). Register a `bundle contribute` command that dispatches into the same registration table.
273
+ 3. Add `rubygems_plugin.rb` (RubyGems plugin entry point). Register a `Gem::Command` subclass that does the same.
274
+ 4. Smoke-test both plugin install paths in CI.
275
+
276
+ Each ADR-0012 phase is independently releasable. Phase 1 has no user-visible behaviour change. Phase 2 changes the look of progress output and prompts. The plugin phases add new entry points.
277
+
278
+ ## Testing strategy
279
+
280
+ **Service layer:** pure function in, `Result` out. Every operation gets unit tests that assert on `Success`/`Failure` values directly. No stdout assertion. No mock needed for output. VCR cassettes for adapter-touching operations; committed to the repo.
281
+
282
+ **CLI pipeline:** inject `Output::Null` for tests that don't assert on output; inject a capturing double for tests that do. Assert on captured calls to `#info`/`#error`/`#progress`, not on raw stdout strings.
283
+
284
+ **TUI pipeline:** the Command closure is a plain lambda — call it directly and assert on the returned message hash. `Update` is a pure function — call it with a message and assert on the returned model. Rooibos's snapshot test helpers (per ADR-0008/0013) supplement this for full-flow scenarios.
285
+
286
+ **gem plugin:** thin entry-point tests only. The CLI pipeline tests cover the behaviour.
287
+
288
+ ## What doesn't change
289
+
290
+ - The `HostAdapter` interface and `GitHubAdapter` implementation.
291
+ - `Resolver`, `LockfileParser`, `TokenStore`, `Cache` — already output-free.
292
+ - The Auth flow shape. `CLI::Auth` keeps its existing structure; `Workflow#build_adapter`'s change is internal wiring.
293
+ - Caching strategy and TTLs.
294
+ - The Rooibos framework choice (ADR-0013, which superseded ADR-0010).
295
+ - ADR-0005 (render labels verbatim), ADR-0007 (show CONTRIBUTING), ADR-0009 (namespace). None of these touch the interface boundary.
data/docs/design.md CHANGED
@@ -61,19 +61,42 @@ The `host` is parsed from the URL: `github.com`, `gitlab.com`, `codeberg.org`, o
61
61
  ### `HostAdapter` (interface) and `GitHubAdapter` (implementation)
62
62
 
63
63
  Input: a `Project` plus, for some methods, an auth token.
64
- Output: issues, CONTRIBUTING content, fork results.
64
+ Output: issues, CONTRIBUTING content, fork results, host-specific URLs.
65
65
 
66
66
  ```ruby
67
- def issues(project, labels:) # public, no auth needed
68
- def community_profile(project) # public, no auth needed
69
- def file_contents(project, path) # public, no auth needed
70
- def fork(project) # auth required
71
- def already_forked?(project) # auth required
67
+ # Reads no auth
68
+ def issues(project, labels:)
69
+ def issue(project, number)
70
+ def issue_comments(project, number)
71
+ def community_profile(project)
72
+ def file_contents(project, path)
73
+ def search_issues(query)
74
+
75
+ # Writes — auth required
76
+ def fork(project) # idempotent, blocks until ready; → ForkResult
77
+ def comment(project, issue:, body:)
78
+ def pull_request_url(upstream, head_owner:, head_branch:, title:, body:)
79
+
80
+ # Identity / URL helpers
81
+ def viewer_login # auth required
82
+ def clone_url(owner, repo) # pure templating, no network
83
+ def repo_url(owner, repo) # pure templating, no network
72
84
  ```
73
85
 
74
86
  `GitHubAdapter` checks for a cached token before any auth-required call. If there's no token, it raises `AuthRequired` with the host name. The TUI catches this through its message machinery and triggers the device flow. See [ADR-0001](adr/0001-just-in-time-auth.md).
75
87
 
76
- Adding a new host (GitLab, Codeberg) means writing a new adapter that conforms to the interface. The TUI doesn't change.
88
+ `fork` is idempotent and blocking: if the viewer already owns the fork it returns `reused: true` without a POST; otherwise it POSTs and polls the host until the new fork is reachable. Higher layers don't see the polling. PR creation is not an API call — the adapter's `pull_request_url` returns a host-specific compare/MR URL that gets opened in the browser, so the user reviews the PR text before submitting. See [ADR-0011](adr/0011-host-adapter-owns-host-verbs.md).
89
+
90
+ Adding a new host (GitLab, Codeberg) means writing a new adapter that conforms to the interface — including its own `clone_url` / `repo_url` / `pull_request_url` templates and its own readiness model for `fork`. Operations and CLI don't change.
91
+
92
+ ### `Operations::Fork` and `Operations::Clone`
93
+
94
+ Two thin primitives sitting between the adapter and the CLI verbs. They're the bootstrap step `fix` and `fork` share:
95
+
96
+ - `Operations::Fork` calls `adapter.fork(project)` and packages the result with the upstream URL for summary output.
97
+ - `Operations::Clone` clones the fork into `<root>/<owner>/<repo>` (reusing an existing clone if one is there) and adds an `upstream` remote pointing at `adapter.clone_url(upstream)`.
98
+
99
+ The "reuse if `.git` exists" rule and the upstream-remote convention are gem-contribute policy on top of git, not git primitives — that's why they live here rather than in the `Git` wrapper. See [ADR-0011](adr/0011-host-adapter-owns-host-verbs.md).
77
100
 
78
101
  ### `Auth`
79
102
 
@@ -225,7 +248,7 @@ The Rooibos snapshot tooling normalizes dynamic content (timestamps, paths in `~
225
248
 
226
249
  ## Roadmap (non-promises)
227
250
 
228
- **v0.1 (workshop):** GitHub-only, JIT auth, fork-clone-branch working, four primary fragments, Rooibos throughout, snapshot tests for the main flows.
251
+ **v0.1 (workshop):** GitHub-only, JIT auth, `fix` working, four primary fragments, Rooibos throughout, snapshot tests for the main flows.
229
252
 
230
253
  **v0.2:** Better empty states, rate-limit display in the status bar, `r` keybinding to refresh the current view, keyboard help overlay (an additional fragment).
231
254
 
data/docs/ideas.md ADDED
@@ -0,0 +1 @@
1
+ - Make sure it respects PR templates
data/docs/index.md CHANGED
@@ -38,7 +38,7 @@ gem-contribute submit # push, then open the PR compare page in y
38
38
 
39
39
  - **v0.1**: a CLI with `scan`, `issues`, `auth`, `fix`, `submit`, and `config`. GitHub-only.
40
40
  - **Planned**: a Rooibos TUI that does all of the above as a single keyboard-driven session ([issue #2](https://github.com/cdhagmann/gem-contribute/issues/2)).
41
- - **Workshop project**: built for [Blue Ridge Ruby 2026](https://blueridgeruby.com).
41
+ - **Workshop project**: built for [Blue Ridge Ruby 2026](https://blueridgeruby.com). [Lightning talk slides →](talk/)
42
42
 
43
43
  ## Commands
44
44
 
@@ -50,7 +50,7 @@ gem-contribute submit # push, then open the PR compare page in y
50
50
  | `gem-contribute auth login` | Authenticate with GitHub via OAuth device flow (no token paste, no client secret). |
51
51
  | `gem-contribute auth status` | Show whether the cached token is still valid. |
52
52
  | `gem-contribute auth logout` | Drop the cached token. |
53
- | `gem-contribute fix <gem>/<n>` | Fork the gem's repo, clone the fork to `<clone_root>/<owner>/<repo>`, branch from default. Alias: `fork-clone-branch`. |
53
+ | `gem-contribute fix <gem>/<n>` | Fork the gem's repo, clone the fork to `<clone_root>/<owner>/<repo>`, branch from default. |
54
54
  | `gem-contribute submit` | From inside a clone, push the current branch and open a pre-filled PR compare page in your browser. |
55
55
  | `gem-contribute config set <k> <v>` | Persist user preferences. |
56
56
  | `gem-contribute config list` | Show current configuration. |
data/docs/prep-plan.md CHANGED
@@ -36,14 +36,14 @@ Minimum viable workshop = Stages 1, 2, and 4 done. Stage 3 (the TUI) can become
36
36
  **Deliberately not in this stage:**
37
37
  - No auth code. Anonymous GitHub API only.
38
38
  - No TUI. CLI output via plain `puts`.
39
- - No fork-clone-branch. That's Stage 2.
39
+ - No `fix` action. That's Stage 2.
40
40
  - No Rooibos dependency yet.
41
41
 
42
42
  **Stop here and check in with Chris.** Demo the script against `gem-contribute`'s own `Gemfile.lock`. The output should make Chris want to keep going.
43
43
 
44
44
  ### Stage 2 — Auth and the action
45
45
 
46
- **Goal:** Add device-flow auth and the fork-clone-branch action. Still no TUI; everything is CLI flags. Proves the auth and action layers work.
46
+ **Goal:** Add device-flow auth and the `fix` action. Still no TUI; everything is CLI flags. Proves the auth and action layers work.
47
47
 
48
48
  **Acceptance:**
49
49
 
@@ -53,7 +53,7 @@ Minimum viable workshop = Stages 1, 2, and 4 done. Stage 3 (the TUI) can become
53
53
  - [ ] Polling respects `slow_down` errors and the 15-minute device-code expiry
54
54
  - [ ] `gem-contribute auth login` and `gem-contribute auth status` CLI commands work
55
55
  - [ ] `GitHubAdapter` gains `fork`, `already_forked?` methods that raise `AuthRequired` if no token
56
- - [ ] A `fork-clone-branch` CLI subcommand takes a `gem/issue_number` argument, performs the full sequence, prints the local path
56
+ - [ ] A `fix` CLI subcommand takes a `gem/issue_number` argument, performs the full sequence, prints the local path
57
57
  - [ ] Unit tests for the auth state machine (the protocol is deterministic — test it)
58
58
  - [ ] Integration test gated on `GEM_CONTRIBUTE_INTEGRATION=1` against a small friendly gem
59
59
  - [ ] **Use the tool to open one real PR** — even a typo fix in a README. The proof that the architecture works.
@@ -88,7 +88,7 @@ Minimum viable workshop = Stages 1, 2, and 4 done. Stage 3 (the TUI) can become
88
88
  - No label normalization (ADR-0005)
89
89
  - No CONTRIBUTING parsing (ADR-0007)
90
90
  - No private-repo support
91
- - No `Worker` orchestrator class — fork-clone-branch is a state machine in `Update`
91
+ - No `Worker` orchestrator class — `fix` is a state machine in `Update`
92
92
 
93
93
  **Stop and check in with Chris.** This is the demo for the workshop opening.
94
94
 
@@ -118,7 +118,7 @@ Minimum viable workshop = Stages 1, 2, and 4 done. Stage 3 (the TUI) can become
118
118
  - "Authenticated as @user" indicator
119
119
  - Sort gems by issue count
120
120
  - Skip path/git source gems with a clear status line
121
- - Confirmation dialog before fork-clone-branch
121
+ - Confirmation dialog before `fix`
122
122
  - `o` to open the gem's homepage in browser
123
123
  - "Last updated" warning for stale-looking gems
124
124
 
@@ -147,7 +147,7 @@ You're ready when, on a fresh laptop:
147
147
 
148
148
  1. `git clone … && cd gem-contribute && bundle install` succeeds
149
149
  2. `bin/gem-contribute` launches the TUI against a real `Gemfile.lock`
150
- 3. `f` on an issue completes the fork-clone-branch flow with the device-flow prompt firing
150
+ 3. `f` on an issue completes the `fix` flow with the device-flow prompt firing
151
151
  4. The workshop issues are visible on the public GitHub repo with the `workshop` label
152
152
  5. `docs/workshop.md` reads cleanly to someone who hasn't seen the project
153
153
 
@@ -0,0 +1,45 @@
1
+ # Talk source
2
+
3
+ Lightning talk for Blue Ridge Ruby 2026: *Building what you cannot find*.
4
+
5
+ `lightning.md` is a [Marp](https://marp.app/) presentation. The same file
6
+ is the source of truth for HTML, PDF, and the slides shown live.
7
+
8
+ ## Render the slides
9
+
10
+ The easiest path is the Marp VS Code extension — open `lightning.md` and
11
+ preview it in the side panel. For a finished export:
12
+
13
+ ```sh
14
+ # one-time
15
+ npm install -g @marp-team/marp-cli
16
+
17
+ # preview live in browser
18
+ marp --server talk/
19
+
20
+ # publish to GitHub Pages (committed; appears at /talk/ on the docs site)
21
+ marp talk/lightning.md --html -o docs/talk/index.html
22
+
23
+ # export to PDF (recommended for the talk itself — survives wifi)
24
+ marp talk/lightning.md --pdf -o talk/lightning.pdf
25
+ ```
26
+
27
+ The HTML version under `docs/talk/index.html` is served via the docs
28
+ site at <https://cdhagmann.com/gem-contribute/talk/>. Re-run the `--html`
29
+ command and commit the changed file whenever the slides change.
30
+
31
+ ## Speaker notes
32
+
33
+ Embedded as `<!-- ... -->` HTML comments inside `lightning.md`. They
34
+ don't render in the slide output but show up in Marp's presenter view.
35
+
36
+ ## Demo recording
37
+
38
+ Always record the live demo before the talk and have the video queued
39
+ on the second monitor. Wifi at conferences is unreliable. If `submit`
40
+ hangs, you swap to the recording without losing the audience.
41
+
42
+ ## Timing
43
+
44
+ Target 5 minutes. Practice with a phone timer at least four times
45
+ standing up. Cut on every pass.