gem-contribute 0.2.0 → 0.3.1

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 (53) 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/.github/workflows/release.yml +71 -0
  6. data/CHANGELOG.md +38 -0
  7. data/CLAUDE.md +1 -1
  8. data/CONTRIBUTING.md +10 -4
  9. data/MAINTAINER.md +119 -2
  10. data/README.md +13 -1
  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 +2 -2
  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 -3
  21. data/docs/design-interface-layer.md +295 -0
  22. data/docs/design.md +31 -8
  23. data/docs/index.md +1 -1
  24. data/docs/prep-plan.md +6 -6
  25. data/lib/gem_contribute/cli/auth.rb +22 -44
  26. data/lib/gem_contribute/cli/config.rb +29 -15
  27. data/lib/gem_contribute/cli/fix.rb +122 -0
  28. data/lib/gem_contribute/cli/fork.rb +145 -0
  29. data/lib/gem_contribute/cli/init.rb +19 -24
  30. data/lib/gem_contribute/cli/issue_announcer.rb +42 -0
  31. data/lib/gem_contribute/cli/issues.rb +36 -47
  32. data/lib/gem_contribute/cli/platform_tools.rb +33 -0
  33. data/lib/gem_contribute/cli/post_clone_hooks.rb +50 -0
  34. data/lib/gem_contribute/cli/rate_limit_footer.rb +5 -3
  35. data/lib/gem_contribute/cli/scan.rb +20 -16
  36. data/lib/gem_contribute/cli/submit.rb +60 -64
  37. data/lib/gem_contribute/cli/workflow.rb +63 -0
  38. data/lib/gem_contribute/cli.rb +9 -16
  39. data/lib/gem_contribute/config.rb +27 -1
  40. data/lib/gem_contribute/git.rb +49 -0
  41. data/lib/gem_contribute/host_adapter.rb +52 -5
  42. data/lib/gem_contribute/host_adapters/github_adapter.rb +126 -37
  43. data/lib/gem_contribute/operations/announce.rb +52 -0
  44. data/lib/gem_contribute/operations/branch.rb +35 -0
  45. data/lib/gem_contribute/operations/clone.rb +41 -0
  46. data/lib/gem_contribute/operations/fix_pipeline.rb +70 -0
  47. data/lib/gem_contribute/operations/fork.rb +35 -0
  48. data/lib/gem_contribute/output/null.rb +20 -0
  49. data/lib/gem_contribute/output/standard.rb +71 -0
  50. data/lib/gem_contribute/version.rb +1 -1
  51. data/lib/gem_contribute.rb +10 -18
  52. metadata +115 -5
  53. data/lib/gem_contribute/cli/fork_clone_branch.rb +0 -204
data/docs/adr/README.md CHANGED
@@ -11,11 +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) — superseded by 0010
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)
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
19
23
 
20
24
  ## When to add an ADR
21
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/index.md CHANGED
@@ -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
 
@@ -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