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.
- checksums.yaml +4 -4
- data/.gem_release.yml +1 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +28 -0
- data/.github/workflows/ci.yml +26 -0
- data/.github/workflows/pr-template-check.yml +100 -0
- data/CHANGELOG.md +41 -0
- data/CLAUDE.md +1 -1
- data/CODE_OF_CONDUCT.md +86 -0
- data/CONTRIBUTING.md +12 -13
- data/README.md +21 -8
- 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 +84 -0
- 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 -2
- data/docs/design-interface-layer.md +295 -0
- data/docs/design.md +31 -8
- data/docs/ideas.md +1 -0
- data/docs/index.md +2 -2
- data/docs/prep-plan.md +6 -6
- data/docs/talk/README.md +45 -0
- data/docs/talk/index.html +4165 -0
- data/docs/talk/lightning.md +425 -0
- data/docs/talk/lightning.pdf +0 -0
- data/lib/gem_contribute/cli/auth.rb +22 -44
- data/lib/gem_contribute/cli/config.rb +32 -16
- 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 +78 -0
- data/lib/gem_contribute/cli/issue_announcer.rb +42 -0
- data/lib/gem_contribute/cli/issues.rb +37 -44
- 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 +34 -0
- data/lib/gem_contribute/cli/scan.rb +20 -15
- data/lib/gem_contribute/cli/submit.rb +60 -64
- data/lib/gem_contribute/cli/workflow.rb +63 -0
- data/lib/gem_contribute/cli.rb +11 -14
- data/lib/gem_contribute/config.rb +28 -4
- 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 +120 -3
- 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.
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
|
15
|
-
#
|
|
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(
|
|
36
|
-
ensure_known_host!(
|
|
37
|
-
|
|
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
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
#
|
|
116
|
-
#
|
|
117
|
-
# the
|
|
118
|
-
def
|
|
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
|
-
|
|
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
|
data/lib/gem_contribute.rb
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|