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
@@ -6,36 +6,48 @@ require "uri"
6
6
 
7
7
  module GemContribute
8
8
  module HostAdapters
9
- # GitHub adapter. v0.1 implements the unauthenticated read methods
10
- # (issues, community_profile, file_contents). The auth-required methods
11
- # raise AuthRequired so the calling layer (CLI in Stage 2, TUI in Stage 3)
12
- # can trigger device flow. See ADR-0001 and ADR-0004.
9
+ # GitHub adapter. Implements the `HostAdapter` interface for github.com.
10
+ # Public read methods work anonymously; auth-required methods raise
11
+ # `AuthRequired` without a cached token (ADR-0001 / ADR-0004).
13
12
  #
14
- # `token` is optional and reserved for Stage 2; when present it's sent as
15
- # `Authorization: Bearer …` to lift the rate limit and unlock fork/etc.
13
+ # `token` is optional. When present it's sent as `Authorization: Bearer …`
14
+ # to lift the rate limit and unlock fork / comment / etc.
15
+ #
16
+ # Class length: this adapter wraps a growing surface area of GitHub
17
+ # endpoints. The 150-line metric isn't a useful constraint here — we'd
18
+ # just split into arbitrary sub-modules — so it's disabled below with
19
+ # this rationale.
20
+ # rubocop:disable Metrics/ClassLength
16
21
  class GitHubAdapter < HostAdapter
17
22
  API_BASE = "https://api.github.com"
18
23
  ACCEPT = "application/vnd.github+json"
19
24
  API_VERSION = "2022-11-28"
20
25
  MAX_REDIRECTS = 3
21
26
 
27
+ # GitHub's POST /forks returns 202 immediately; the fork resource may
28
+ # 404 for a few seconds while propagation finishes. Bound the wait at
29
+ # 12 × 5s = 60s.
30
+ FORK_READINESS_RETRIES = 12
31
+ FORK_READINESS_INTERVAL = 5
32
+
22
33
  RateLimit = Data.define(:limit, :remaining, :reset_at)
23
34
 
24
35
  attr_reader :rate_limit
25
36
 
26
- def initialize(cache: Cache.new, http: Net::HTTP, token: nil)
37
+ def initialize(cache: Cache.new, http: Net::HTTP, token: nil,
38
+ sleeper: ->(s) { Kernel.sleep(s) })
27
39
  super()
28
40
  @cache = cache
29
41
  @http = http
30
42
  @token = token
43
+ @sleeper = sleeper
31
44
  @rate_limit = nil
32
45
  end
33
46
 
34
47
  # @return [Hash] a single issue's full payload (uncached — submit only).
35
- def issue(owner, repo, number)
36
- ensure_known_host!(Project.new(gem_name: repo, host: "github.com",
37
- owner: owner, repo: repo, metadata: {}))
38
- get_json("/repos/#{owner}/#{repo}/issues/#{number}")
48
+ def issue(project, number)
49
+ ensure_known_host!(project)
50
+ get_json("/repos/#{project.owner}/#{project.repo}/issues/#{number}")
39
51
  end
40
52
 
41
53
  # @return [Array<Hash>] open issues filtered to the given labels (if any)
@@ -76,35 +88,24 @@ module GemContribute
76
88
  @cache.write("files", cache_key, body)
77
89
  end
78
90
 
79
- # POST /repos/:owner/:repo/forks. Returns the fork's parsed body
80
- # (clone_url, owner.login, name, etc.). GitHub responds 202 (accepted)
81
- # immediately even if the fork is still propagating; callers that need
82
- # to clone right after may want to poll readiness — see
83
- # `fork_ready?` below.
91
+ # Idempotent, blocking fork. If the viewer already owns a fork at the
92
+ # same name, returns it as `reused: true` without a POST. Otherwise
93
+ # POSTs to /repos/:owner/:repo/forks and polls until the fork is
94
+ # reachable. Returns a `HostAdapter::ForkResult`.
84
95
  def fork(project)
85
96
  raise AuthRequired, "github.com" unless @token
86
97
 
87
- ensure_known_host!(project)
88
- post_json("/repos/#{project.owner}/#{project.repo}/forks")
89
- end
90
-
91
- # GET /repos/:viewer/:repo. True iff the viewer already owns a fork of
92
- # the upstream repo at the same name.
93
- def already_forked?(project)
94
- raise AuthRequired, "github.com" unless @token
95
-
96
98
  ensure_known_host!(project)
97
99
  viewer = viewer_login
98
- get_json("/repos/#{viewer}/#{project.repo}")
99
- true
100
- rescue AdapterError => e
101
- return false if e.message.include?("404")
100
+ return existing_fork_result(viewer, project) if fork_exists?(viewer, project.repo)
102
101
 
103
- raise
102
+ body = post_json("/repos/#{project.owner}/#{project.repo}/forks")
103
+ wait_until_fork_ready(viewer, project.repo)
104
+ new_fork_result(viewer, project, body)
104
105
  end
105
106
 
106
- # GET /user. Used by `auth status` and `already_forked?`. Returns the
107
- # authenticated user's login string (e.g. "cdhagmann").
107
+ # GET /user. Used by `auth status` and internally by `fork`. Returns
108
+ # the authenticated user's login string (e.g. "cdhagmann").
108
109
  def viewer_login
109
110
  raise AuthRequired, "github.com" unless @token
110
111
 
@@ -112,12 +113,88 @@ module GemContribute
112
113
  body.fetch("login")
113
114
  end
114
115
 
115
- # GET /repos/:viewer/:repo, returning true once GitHub has finished
116
- # provisioning the fork. The fork endpoint returns 202 immediately;
117
- # the resource may 404 for a few seconds before becoming live.
118
- def fork_ready?(viewer, repo_name)
116
+ # POST /repos/:owner/:repo/issues/:n/comments. Returns the created
117
+ # comment payload (id, body, html_url, ...). Used by `fix` to post
118
+ # the "working on this" announcement.
119
+ def comment(project, issue:, body:)
120
+ raise AuthRequired, "github.com" unless @token
121
+
122
+ ensure_known_host!(project)
123
+ post_json("/repos/#{project.owner}/#{project.repo}/issues/#{issue}/comments",
124
+ { "body" => body })
125
+ end
126
+
127
+ # GET /repos/:owner/:repo/issues/:n/comments. Returns an array of
128
+ # comment payloads. Uncached (callers may want fresh data, e.g. to
129
+ # check for an idempotency marker).
130
+ def issue_comments(project, number)
119
131
  raise AuthRequired, "github.com" unless @token
120
132
 
133
+ ensure_known_host!(project)
134
+ get_json("/repos/#{project.owner}/#{project.repo}/issues/#{number}/comments")
135
+ end
136
+
137
+ # GET /search/issues. Wraps GitHub's issue search; works without auth
138
+ # (subject to the 60/hr anonymous rate limit). Returns an array of
139
+ # issue payloads (the search response's `items` key). Cached under the
140
+ # `issues` namespace using the query as the key. Used to find issues
141
+ # already claimed via the gem-contribute marker.
142
+ def search_issues(query)
143
+ cache_key = "search:#{query}"
144
+ cached = @cache.fetch("issues", cache_key)
145
+ return cached if cached
146
+
147
+ raw = get_json("/search/issues", q: query)
148
+ items = raw.fetch("items", [])
149
+ @cache.write("issues", cache_key, items)
150
+ end
151
+
152
+ # Builds GitHub's pre-filled compare URL. The browser-based PR flow
153
+ # (ADR-0011) means the user reviews the title/body before submitting,
154
+ # so this method just templates — it doesn't post.
155
+ def pull_request_url(upstream, head_owner:, head_branch:, title:, body:)
156
+ ensure_known_host!(upstream)
157
+ same_repo = head_owner == upstream.owner
158
+ head = same_repo ? head_branch : "#{head_owner}:#{head_branch}"
159
+ params = { "expand" => "1", "title" => title, "body" => body }
160
+ "https://github.com/#{upstream.owner}/#{upstream.repo}/compare/#{head}?" \
161
+ "#{URI.encode_www_form(params)}"
162
+ end
163
+
164
+ # Pure URL templating — no auth, no network. Used by Operations to
165
+ # construct the `upstream` remote and by CLI verbs for summary output.
166
+ def clone_url(owner, repo)
167
+ "https://github.com/#{owner}/#{repo}.git"
168
+ end
169
+
170
+ def repo_url(owner, repo)
171
+ "https://github.com/#{owner}/#{repo}"
172
+ end
173
+
174
+ private
175
+
176
+ def existing_fork_result(viewer, project)
177
+ ForkResult.new(
178
+ clone_url: clone_url(viewer, project.repo),
179
+ fork_url: repo_url(viewer, project.repo),
180
+ viewer: viewer,
181
+ reused: true
182
+ )
183
+ end
184
+
185
+ def new_fork_result(viewer, project, body)
186
+ ForkResult.new(
187
+ clone_url: body.fetch("clone_url", clone_url(viewer, project.repo)),
188
+ fork_url: body.fetch("html_url", repo_url(viewer, project.repo)),
189
+ viewer: viewer,
190
+ reused: false
191
+ )
192
+ end
193
+
194
+ # GET /repos/:viewer/:repo. True iff the viewer already owns a repo at
195
+ # that name (which, for the fork flow, means an existing fork of the
196
+ # upstream).
197
+ def fork_exists?(viewer, repo_name)
121
198
  get_json("/repos/#{viewer}/#{repo_name}")
122
199
  true
123
200
  rescue AdapterError => e
@@ -126,7 +203,18 @@ module GemContribute
126
203
  raise
127
204
  end
128
205
 
129
- private
206
+ def wait_until_fork_ready(viewer, repo_name)
207
+ ready = FORK_READINESS_RETRIES.times.any? do |i|
208
+ break true if fork_exists?(viewer, repo_name)
209
+
210
+ @sleeper.call(FORK_READINESS_INTERVAL) unless i == FORK_READINESS_RETRIES - 1
211
+ false
212
+ end
213
+ return if ready
214
+
215
+ raise AdapterError,
216
+ "fork not reachable after #{FORK_READINESS_RETRIES * FORK_READINESS_INTERVAL}s"
217
+ end
130
218
 
131
219
  def issue_cache_key(project, labels)
132
220
  label_segment = Array(labels).sort.join(",")
@@ -211,5 +299,6 @@ module GemContribute
211
299
  )
212
300
  end
213
301
  end
302
+ # rubocop:enable Metrics/ClassLength
214
303
  end
215
304
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+
5
+ module GemContribute
6
+ module Operations
7
+ # Bootstrap step 4: post (or skip) a "working on this" comment on the
8
+ # upstream issue. The marker is an HTML comment in the body so re-runs
9
+ # detect prior posts deterministically.
10
+ #
11
+ # Format: `<!-- gem-contribute:<verb> v<n> -->`. Output-free per
12
+ # ADR-0012; callers render the outcome.
13
+ #
14
+ # Returns:
15
+ # Success(:posted) — comment was posted
16
+ # Success(:skipped) — gating said no, OR the marker was already present
17
+ # Failure([:announce_failed, message]) — adapter raised; the caller
18
+ # usually treats this as a non-fatal warning rather than a fix failure
19
+ class Announce
20
+ include Dry::Monads[:result]
21
+
22
+ WORKING_MARKER = "<!-- gem-contribute:working v1 -->"
23
+ WORKING_BODY = <<~BODY.freeze
24
+ #{WORKING_MARKER}
25
+ 👋 I've started working on this. I'll open a PR shortly.
26
+
27
+ <sub>Posted via [gem-contribute](https://github.com/cdhagmann/gem-contribute).</sub>
28
+ BODY
29
+
30
+ def call(adapter:, project:, issue:, allow:)
31
+ return Success(:skipped) unless allow
32
+ return Success(:skipped) if already_announced?(adapter, project, issue)
33
+
34
+ adapter.comment(project, issue: issue, body: WORKING_BODY)
35
+ Success(:posted)
36
+ rescue GemContribute::AdapterError => e
37
+ Failure([:announce_failed, e.message])
38
+ end
39
+
40
+ private
41
+
42
+ def already_announced?(adapter, project, issue)
43
+ comments = adapter.issue_comments(project, issue)
44
+ comments.any? { |c| c["body"].to_s.include?(WORKING_MARKER) }
45
+ rescue GemContribute::AdapterError
46
+ # If we can't fetch comments, assume not announced and let the
47
+ # post attempt fail safely on its own.
48
+ false
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+
5
+ module GemContribute
6
+ module Operations
7
+ # Bootstrap step 3: create the per-issue working branch in the fork
8
+ # clone. The branch name is `gem-contribute/issue-<N>`. Output-free
9
+ # per ADR-0012.
10
+ #
11
+ # Note: `git checkout -b` fails if the branch already exists, which
12
+ # surfaces as `Failure([:adapter_error, ...])` here. That preserves
13
+ # the pre-extraction behaviour where re-running `fix` on an issue
14
+ # whose branch already exists locally errored out — see [#10] for
15
+ # the friendlier-message follow-up.
16
+ class Branch
17
+ include Dry::Monads[:result]
18
+
19
+ Result = Data.define(:name)
20
+ PREFIX = "gem-contribute/issue-"
21
+
22
+ def initialize(git: Git.new)
23
+ @git = git
24
+ end
25
+
26
+ def call(path:, issue:)
27
+ name = "#{PREFIX}#{issue}"
28
+ @git.checkout_branch(path, name)
29
+ Success(Result.new(name: name))
30
+ rescue GemContribute::AdapterError => e
31
+ Failure([:adapter_error, e.message])
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+ require "fileutils"
5
+
6
+ module GemContribute
7
+ module Operations
8
+ # Bootstrap step 2: clone the fork into `<root>/<owner>/<repo>` (reusing
9
+ # an existing clone if one is there), and ensure an `upstream` remote
10
+ # points at the canonical project. Returns a `Result` carrying the
11
+ # local path and a `reused` flag, or a tagged `Failure`.
12
+ #
13
+ # The "reuse if `.git` exists" rule and the upstream-remote convention
14
+ # are gem-contribute policy on top of git, not git itself — that's why
15
+ # they live here rather than in `Git`.
16
+ class Clone
17
+ include Dry::Monads[:result]
18
+
19
+ Result = Data.define(:path, :reused)
20
+
21
+ def initialize(git: Git.new)
22
+ @git = git
23
+ end
24
+
25
+ def call(adapter:, project:, fork_clone_url:, root:)
26
+ target = File.join(root, project.owner, project.repo)
27
+ reused = File.directory?(File.join(target, ".git"))
28
+
29
+ unless reused
30
+ FileUtils.mkdir_p(File.dirname(target))
31
+ @git.clone(fork_clone_url, target)
32
+ end
33
+
34
+ @git.add_remote(target, "upstream", adapter.clone_url(project.owner, project.repo))
35
+ Success(Result.new(path: target, reused: reused))
36
+ rescue GemContribute::AdapterError => e
37
+ Failure([:adapter_error, e.message])
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/operation"
4
+ require "dry/monads"
5
+
6
+ module GemContribute
7
+ module Operations
8
+ # Composes the four `fix` steps — Fork → Clone → Branch → Announce —
9
+ # using `dry-operation`. Each `step` short-circuits on Failure;
10
+ # Announce is called outside `step` because its Failure is
11
+ # informational (the fix has already happened) and should not
12
+ # propagate as a pipeline-level Failure.
13
+ #
14
+ # The pipeline itself is output-free per ADR-0012 — no spinners, no
15
+ # progress lines, no stdout. Callers (the CLI verb today; a TUI
16
+ # Command tomorrow) render the outcome.
17
+ #
18
+ # Inputs:
19
+ # adapter — HostAdapter instance (already authenticated)
20
+ # project — Project struct
21
+ # issue — String/Integer issue number (used in branch name)
22
+ # root — Clone-root directory
23
+ # allow_announce — Boolean. Verb-level gating (e.g. --no-comment,
24
+ # `comment_on_fix?` config) is collapsed into this
25
+ # bool by the caller. The pipeline additionally
26
+ # skips announce when `viewer == project.owner`
27
+ # (you don't need to announce on your own repo).
28
+ #
29
+ # Returns Success(hash) on the happy path:
30
+ # { fork: Operations::Fork::Result,
31
+ # clone: Operations::Clone::Result,
32
+ # branch: Operations::Branch::Result,
33
+ # announce: Result (Success(:posted | :skipped) | Failure([:announce_failed, msg])) }
34
+ class FixPipeline < Dry::Operation
35
+ def initialize(fork: nil, clone: nil, branch: nil, announce: nil, git: nil)
36
+ super()
37
+ git ||= Git.new
38
+ @fork = fork || Operations::Fork.new
39
+ @clone = clone || Operations::Clone.new(git: git)
40
+ @branch = branch || Operations::Branch.new(git: git)
41
+ @announce = announce || Operations::Announce.new
42
+ end
43
+
44
+ # `Dry::Operation` wraps the method's final return value in `Success`,
45
+ # so the body returns a raw hash. `step` unwraps Success and short-
46
+ # circuits on Failure; Announce is called outside `step` because its
47
+ # Failure is informational (the fix has already happened).
48
+ def call(adapter:, project:, issue:, root:, allow_announce:)
49
+ fork_result = step @fork.call(adapter: adapter, project: project)
50
+ clone_result = step @clone.call(
51
+ adapter: adapter, project: project,
52
+ fork_clone_url: fork_result.clone_url, root: root
53
+ )
54
+ branch_result = step @branch.call(path: clone_result.path, issue: issue)
55
+
56
+ allow = allow_announce && fork_result.viewer != project.owner
57
+ announce_result = @announce.call(
58
+ adapter: adapter, project: project, issue: issue, allow: allow
59
+ )
60
+
61
+ {
62
+ fork: fork_result,
63
+ clone: clone_result,
64
+ branch: branch_result,
65
+ announce: announce_result
66
+ }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+
5
+ module GemContribute
6
+ module Operations
7
+ # Bootstrap step 1: ensure the viewer owns a fork of `project` (creating
8
+ # one if needed). Returns a `Result` describing what happened, or a
9
+ # tagged `Failure` for the caller to render. Does no filesystem work
10
+ # — that's `Operations::Clone`'s job. Does no I/O — that's the caller's
11
+ # job (per ADR-0012).
12
+ class Fork
13
+ include Dry::Monads[:result]
14
+
15
+ Result = Data.define(:clone_url, :fork_url, :upstream_url, :viewer, :reused)
16
+
17
+ def call(adapter:, project:)
18
+ fork = adapter.fork(project)
19
+ Success(
20
+ Result.new(
21
+ clone_url: fork.clone_url,
22
+ fork_url: fork.fork_url,
23
+ upstream_url: adapter.repo_url(project.owner, project.repo),
24
+ viewer: fork.viewer,
25
+ reused: fork.reused
26
+ )
27
+ )
28
+ rescue GemContribute::AuthRequired
29
+ Failure(:unauthenticated)
30
+ rescue GemContribute::AdapterError => e
31
+ Failure([:adapter_error, e.message])
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemContribute
4
+ module Output
5
+ # No-op output sink. Inject into CLI verbs in tests that don't care
6
+ # about output (or want to assert on a separate capturing double).
7
+ #
8
+ # `#progress` accepts a block (mirroring `Output::Standard#progress`)
9
+ # and yields it; the spinner machinery is skipped entirely.
10
+ class Null
11
+ def info(_message) = nil
12
+ def warn(_message) = nil
13
+ def error(_message) = nil
14
+
15
+ def progress(_message)
16
+ block_given? ? yield : nil
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-spinner"
4
+
5
+ module GemContribute
6
+ module Output
7
+ # Semantic output abstraction for CLI verbs (per ADR-0012). Wraps
8
+ # stdout/stderr behind verb-shaped methods so the look-and-feel can
9
+ # evolve independently of the service layer.
10
+ #
11
+ # `#warn` and `#error` both write to stderr without prefixing — the
12
+ # caller's message already carries its own framing ("Note: ...",
13
+ # "warning: ...", "fix failed: ..."). The semantic split exists so a
14
+ # later styling pass (color, severity icons) has somewhere to hang.
15
+ #
16
+ # `#progress` has two forms:
17
+ #
18
+ # * No block — equivalent to #info. Use when there's nothing to
19
+ # wrap, e.g. you're announcing intent before a sequence of calls.
20
+ # * Block form — runs the block while showing a tty-spinner in
21
+ # interactive terminals. In non-TTY contexts (CI, piped output,
22
+ # test StringIOs) it falls back to a plain line + yield. The
23
+ # block's return value is the method's return value.
24
+ class Standard
25
+ def initialize(out: $stdout, err: $stderr)
26
+ @out = out
27
+ @err = err
28
+ end
29
+
30
+ def info(message)
31
+ @out.puts(message)
32
+ end
33
+
34
+ def progress(message, &)
35
+ return @out.puts(message) unless block_given?
36
+ return puts_and_yield(message, &) unless interactive?
37
+
38
+ spin(message, &)
39
+ end
40
+
41
+ def warn(message)
42
+ @err.puts(message)
43
+ end
44
+
45
+ def error(message)
46
+ @err.puts(message)
47
+ end
48
+
49
+ private
50
+
51
+ def interactive?
52
+ @out.respond_to?(:tty?) && @out.tty?
53
+ end
54
+
55
+ def puts_and_yield(message)
56
+ @out.puts(message)
57
+ yield
58
+ end
59
+
60
+ def spin(message)
61
+ spinner = TTY::Spinner.new("[:spinner] #{message}", output: @out, format: :dots)
62
+ spinner.auto_spin
63
+ begin
64
+ yield
65
+ ensure
66
+ spinner.stop
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GemContribute
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -1,15 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "zeitwerk"
3
4
  require_relative "gem_contribute/version"
4
5
  require_relative "gem_contribute/errors"
5
6
 
6
- module GemContribute
7
- autoload :LockedGem, "gem_contribute/locked_gem"
8
- autoload :Project, "gem_contribute/project"
7
+ loader = Zeitwerk::Loader.for_gem
8
+ loader.ignore("#{__dir__}/gem_contribute/version.rb")
9
+ loader.ignore("#{__dir__}/gem_contribute/errors.rb")
10
+ loader.inflector.inflect(
11
+ "cli" => "CLI",
12
+ "github_adapter" => "GitHubAdapter"
13
+ )
14
+ loader.setup
9
15
 
10
- # The canonical Project for gem-contribute itself. Used by the CLI to
11
- # short-circuit resolution (gem-contribute isn't on RubyGems yet) and
12
- # to auto-inject the tool into its own scan results.
16
+ module GemContribute
13
17
  SELF_PROJECT = Project.new(
14
18
  gem_name: "gem-contribute",
15
19
  host: "github.com",
@@ -17,16 +21,4 @@ module GemContribute
17
21
  repo: "gem-contribute",
18
22
  metadata: { self_injected: true }
19
23
  ).freeze
20
- autoload :LockfileParser, "gem_contribute/lockfile_parser"
21
- autoload :Cache, "gem_contribute/cache"
22
- autoload :Resolver, "gem_contribute/resolver"
23
- autoload :HostAdapter, "gem_contribute/host_adapter"
24
- autoload :Auth, "gem_contribute/auth"
25
- autoload :Config, "gem_contribute/config"
26
- autoload :TokenStore, "gem_contribute/token_store"
27
- autoload :CLI, "gem_contribute/cli"
28
-
29
- module HostAdapters
30
- autoload :GitHubAdapter, "gem_contribute/host_adapters/github_adapter"
31
- end
32
24
  end