gem-contribute 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/PULL_REQUEST_TEMPLATE.md +14 -8
- data/.github/workflows/ci.yml +26 -0
- data/.github/workflows/pr-template-check.yml +100 -0
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +1 -1
- data/CONTRIBUTING.md +10 -4
- data/README.md +13 -1
- data/docs/OPEN_QUESTIONS.md +167 -0
- data/docs/ROADMAP.md +266 -0
- data/docs/adr/0006-standalone-gem-not-plugin.md +1 -1
- data/docs/adr/0008-rooibos-tui-framework.md +3 -3
- data/docs/adr/0010-charm-ruby-tui-framework.md +2 -2
- data/docs/adr/0011-host-adapter-owns-host-verbs.md +58 -0
- data/docs/adr/0012-output-free-service-objects-three-interface-architecture.md +79 -0
- data/docs/adr/0013-revert-to-rooibos.md +71 -0
- data/docs/adr/0014-ship-bundler-and-rubygems-plugins.md +75 -0
- data/docs/adr/README.md +7 -3
- data/docs/design-interface-layer.md +295 -0
- data/docs/design.md +31 -8
- data/docs/index.md +1 -1
- data/docs/prep-plan.md +6 -6
- data/lib/gem_contribute/cli/auth.rb +22 -44
- data/lib/gem_contribute/cli/config.rb +29 -15
- data/lib/gem_contribute/cli/fix.rb +122 -0
- data/lib/gem_contribute/cli/fork.rb +145 -0
- data/lib/gem_contribute/cli/init.rb +19 -24
- data/lib/gem_contribute/cli/issue_announcer.rb +42 -0
- data/lib/gem_contribute/cli/issues.rb +36 -47
- data/lib/gem_contribute/cli/platform_tools.rb +33 -0
- data/lib/gem_contribute/cli/post_clone_hooks.rb +50 -0
- data/lib/gem_contribute/cli/rate_limit_footer.rb +5 -3
- data/lib/gem_contribute/cli/scan.rb +20 -16
- data/lib/gem_contribute/cli/submit.rb +60 -64
- data/lib/gem_contribute/cli/workflow.rb +63 -0
- data/lib/gem_contribute/cli.rb +9 -16
- data/lib/gem_contribute/config.rb +27 -1
- data/lib/gem_contribute/git.rb +49 -0
- data/lib/gem_contribute/host_adapter.rb +52 -5
- data/lib/gem_contribute/host_adapters/github_adapter.rb +126 -37
- data/lib/gem_contribute/operations/announce.rb +52 -0
- data/lib/gem_contribute/operations/branch.rb +35 -0
- data/lib/gem_contribute/operations/clone.rb +41 -0
- data/lib/gem_contribute/operations/fix_pipeline.rb +70 -0
- data/lib/gem_contribute/operations/fork.rb +35 -0
- data/lib/gem_contribute/output/null.rb +20 -0
- data/lib/gem_contribute/output/standard.rb +71 -0
- data/lib/gem_contribute/version.rb +1 -1
- data/lib/gem_contribute.rb +10 -18
- metadata +109 -3
- 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
|
-
|
|
68
|
-
def
|
|
69
|
-
def
|
|
70
|
-
def
|
|
71
|
-
def
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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 `
|
|
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 —
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
@
|
|
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
|
-
@
|
|
43
|
+
@output.info(USAGE)
|
|
42
44
|
0
|
|
43
45
|
else
|
|
44
|
-
@
|
|
45
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
71
|
+
@output.info("#{url_prefix}: #{device_code.verification_uri}")
|
|
70
72
|
|
|
71
|
-
@
|
|
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
|
-
@
|
|
97
|
+
@output.info("Authenticated. Token saved to #{TokenStore.default_path} (mode 0600).")
|
|
96
98
|
0
|
|
97
99
|
when :expired
|
|
98
|
-
@
|
|
100
|
+
@output.error("Device code expired. Run `gem-contribute auth login` again.")
|
|
99
101
|
1
|
|
100
102
|
when :denied
|
|
101
|
-
@
|
|
103
|
+
@output.error("Authorization denied.")
|
|
102
104
|
1
|
|
103
105
|
else
|
|
104
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
126
|
-
@
|
|
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
|
-
@
|
|
134
|
+
@output.info("Logged out of #{DEFAULT_HOST}.")
|
|
133
135
|
else
|
|
134
|
-
@
|
|
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
|