gem-contribute 0.1.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 +7 -0
- data/.github/ISSUE_TEMPLATE/workshop-issue.md +29 -0
- data/.github/workflows/auto-merge-kicked-tires.yml +88 -0
- data/CHANGELOG.md +24 -0
- data/CLAUDE.md +47 -0
- data/CONTRIBUTING.md +46 -0
- data/KICKED_THE_TIRES.yml +22 -0
- data/LICENSE +21 -0
- data/MAINTAINER.md +92 -0
- data/README.md +89 -0
- data/Rakefile +10 -0
- data/docs/_config.yml +30 -0
- data/docs/adr/0001-just-in-time-auth.md +44 -0
- data/docs/adr/0002-bundler-lockfile-parser.md +35 -0
- data/docs/adr/0003-issue-tracker-preference.md +33 -0
- data/docs/adr/0004-device-flow-auth.md +36 -0
- data/docs/adr/0005-render-labels-verbatim.md +46 -0
- data/docs/adr/0006-standalone-gem-not-plugin.md +31 -0
- data/docs/adr/0007-display-contributing-verbatim.md +39 -0
- data/docs/adr/0008-rooibos-tui-framework.md +62 -0
- data/docs/adr/0009-top-level-namespace.md +37 -0
- data/docs/adr/README.md +21 -0
- data/docs/claude-code-prompt.md +40 -0
- data/docs/design.md +234 -0
- data/docs/index.md +102 -0
- data/docs/prep-plan.md +165 -0
- data/docs/workshop.md +60 -0
- data/exe/gem-contribute +7 -0
- data/lib/gem_contribute/auth.rb +161 -0
- data/lib/gem_contribute/cache.rb +98 -0
- data/lib/gem_contribute/cli/auth.rb +164 -0
- data/lib/gem_contribute/cli/config.rb +87 -0
- data/lib/gem_contribute/cli/fork_clone_branch.rb +197 -0
- data/lib/gem_contribute/cli/issues.rb +123 -0
- data/lib/gem_contribute/cli/scan.rb +117 -0
- data/lib/gem_contribute/cli/submit.rb +155 -0
- data/lib/gem_contribute/cli.rb +104 -0
- data/lib/gem_contribute/config.rb +60 -0
- data/lib/gem_contribute/errors.rb +32 -0
- data/lib/gem_contribute/host_adapter.rb +40 -0
- data/lib/gem_contribute/host_adapters/github_adapter.rb +215 -0
- data/lib/gem_contribute/locked_gem.rb +26 -0
- data/lib/gem_contribute/lockfile_parser.rb +61 -0
- data/lib/gem_contribute/project.rb +21 -0
- data/lib/gem_contribute/resolver.rb +131 -0
- data/lib/gem_contribute/token_store.rb +86 -0
- data/lib/gem_contribute/version.rb +5 -0
- data/lib/gem_contribute.rb +32 -0
- data/script/lint-kicked-tires.rb +76 -0
- data/sig/gem_contribute.rbs +3 -0
- metadata +114 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemContribute
|
|
4
|
+
module CLI
|
|
5
|
+
# `gem-contribute auth <subcommand>` — Stage 2 entry point for OAuth.
|
|
6
|
+
#
|
|
7
|
+
# Subcommands:
|
|
8
|
+
# login — start device flow, poll for completion, persist the token
|
|
9
|
+
# status — show whether a token is cached for github.com (and try to
|
|
10
|
+
# validate it by hitting /user)
|
|
11
|
+
# logout — drop the cached token for github.com
|
|
12
|
+
class Auth
|
|
13
|
+
USAGE = <<~USAGE
|
|
14
|
+
Usage: gem-contribute auth <subcommand>
|
|
15
|
+
|
|
16
|
+
Subcommands:
|
|
17
|
+
login Authenticate with GitHub via OAuth device flow.
|
|
18
|
+
status Show whether you're authenticated.
|
|
19
|
+
logout Remove the cached token for github.com.
|
|
20
|
+
USAGE
|
|
21
|
+
|
|
22
|
+
DEFAULT_HOST = "github.com"
|
|
23
|
+
|
|
24
|
+
def initialize(stdout: $stdout, stderr: $stderr, store: TokenStore.new,
|
|
25
|
+
sleeper: ->(s) { Kernel.sleep(s) },
|
|
26
|
+
browser_opener: nil, clipper: nil)
|
|
27
|
+
@stdout = stdout
|
|
28
|
+
@stderr = stderr
|
|
29
|
+
@store = store
|
|
30
|
+
@sleeper = sleeper
|
|
31
|
+
@browser_opener = browser_opener || method(:default_browser_opener)
|
|
32
|
+
@clipper = clipper || method(:default_clipper)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run(argv)
|
|
36
|
+
case argv.shift
|
|
37
|
+
when "login" then login
|
|
38
|
+
when "status" then status
|
|
39
|
+
when "logout" then logout
|
|
40
|
+
when nil, "help", "-h", "--help"
|
|
41
|
+
@stdout.puts USAGE
|
|
42
|
+
0
|
|
43
|
+
else
|
|
44
|
+
@stderr.puts "gem-contribute: unknown auth subcommand"
|
|
45
|
+
@stderr.puts USAGE
|
|
46
|
+
2
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def login
|
|
53
|
+
device_code = GemContribute::Auth.request_device_code(GemContribute::Auth::CLIENT_ID)
|
|
54
|
+
prompt_user(device_code)
|
|
55
|
+
result = poll_loop(device_code)
|
|
56
|
+
persist_or_report(result)
|
|
57
|
+
rescue GemContribute::Auth::AuthError => e
|
|
58
|
+
@stderr.puts "auth login failed: #{e.message}"
|
|
59
|
+
1
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def prompt_user(device_code)
|
|
63
|
+
copied = @clipper.call(device_code.user_code)
|
|
64
|
+
code_suffix = copied ? " (copied to clipboard)" : ""
|
|
65
|
+
@stdout.puts "Your one-time code#{code_suffix}: #{device_code.user_code}"
|
|
66
|
+
|
|
67
|
+
opened = @browser_opener.call(device_code.verification_uri)
|
|
68
|
+
url_prefix = opened ? "Browser opened to" : "Visit"
|
|
69
|
+
@stdout.puts "#{url_prefix}: #{device_code.verification_uri}"
|
|
70
|
+
|
|
71
|
+
@stdout.puts "Waiting for you to authorize..."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def poll_loop(device_code)
|
|
75
|
+
loop do
|
|
76
|
+
if device_code.expired?
|
|
77
|
+
return GemContribute::Auth::Result.new(status: :expired, token: nil, scope: nil, error_message: nil)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@sleeper.call(device_code.interval)
|
|
81
|
+
result = GemContribute::Auth.poll(device_code, GemContribute::Auth::CLIENT_ID)
|
|
82
|
+
|
|
83
|
+
case result.status
|
|
84
|
+
when :pending then next
|
|
85
|
+
when :slow_down then device_code = device_code.with_interval(device_code.interval + 5)
|
|
86
|
+
else return result
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def persist_or_report(result)
|
|
92
|
+
case result.status
|
|
93
|
+
when :ok
|
|
94
|
+
@store.store(DEFAULT_HOST, access_token: result.token, scope: result.scope)
|
|
95
|
+
@stdout.puts "Authenticated. Token saved to #{TokenStore.default_path} (mode 0600)."
|
|
96
|
+
0
|
|
97
|
+
when :expired
|
|
98
|
+
@stderr.puts "Device code expired. Run `gem-contribute auth login` again."
|
|
99
|
+
1
|
|
100
|
+
when :denied
|
|
101
|
+
@stderr.puts "Authorization denied."
|
|
102
|
+
1
|
|
103
|
+
else
|
|
104
|
+
@stderr.puts "auth login failed: #{result.error_message}"
|
|
105
|
+
1
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def status
|
|
110
|
+
entry = @store.entry_for(DEFAULT_HOST)
|
|
111
|
+
if entry.nil?
|
|
112
|
+
@stdout.puts "Not authenticated. Run `gem-contribute auth login`."
|
|
113
|
+
return 1
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
verify_and_print(entry)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def verify_and_print(entry)
|
|
120
|
+
adapter = HostAdapters::GitHubAdapter.new(token: entry["access_token"])
|
|
121
|
+
login_name = adapter.viewer_login
|
|
122
|
+
@stdout.puts "Authenticated as @#{login_name} on #{DEFAULT_HOST} (scope: #{entry["scope"] || "unknown"})"
|
|
123
|
+
0
|
|
124
|
+
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
|
+
1
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def logout
|
|
131
|
+
if @store.delete(DEFAULT_HOST)
|
|
132
|
+
@stdout.puts "Logged out of #{DEFAULT_HOST}."
|
|
133
|
+
else
|
|
134
|
+
@stdout.puts "No cached token for #{DEFAULT_HOST}."
|
|
135
|
+
end
|
|
136
|
+
0
|
|
137
|
+
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
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemContribute
|
|
4
|
+
module CLI
|
|
5
|
+
# `gem-contribute config <subcommand>`
|
|
6
|
+
#
|
|
7
|
+
# Subcommands:
|
|
8
|
+
# set <key> <value> Write a value to ~/.config/gem-contribute/config.yml
|
|
9
|
+
# get <key> Print the current value of a key
|
|
10
|
+
# list Print all configured values
|
|
11
|
+
class Config
|
|
12
|
+
USAGE = <<~USAGE
|
|
13
|
+
Usage: gem-contribute config <subcommand>
|
|
14
|
+
|
|
15
|
+
Subcommands:
|
|
16
|
+
set <key> <value> Set a configuration value.
|
|
17
|
+
get <key> Print the current value of a key.
|
|
18
|
+
list Print all configured values.
|
|
19
|
+
|
|
20
|
+
Keys:
|
|
21
|
+
clone_root Directory where forks are cloned (default: ~/code/oss).
|
|
22
|
+
Example: gem-contribute config set clone_root ~/Projects/oss
|
|
23
|
+
USAGE
|
|
24
|
+
|
|
25
|
+
def initialize(stdout: $stdout, stderr: $stderr, config: GemContribute::Config.new)
|
|
26
|
+
@stdout = stdout
|
|
27
|
+
@stderr = stderr
|
|
28
|
+
@config = config
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run(argv)
|
|
32
|
+
case argv.shift
|
|
33
|
+
when "set" then set(argv)
|
|
34
|
+
when "get" then get(argv)
|
|
35
|
+
when "list" then list
|
|
36
|
+
when nil, "help", "-h", "--help"
|
|
37
|
+
@stdout.puts USAGE
|
|
38
|
+
0
|
|
39
|
+
else
|
|
40
|
+
@stderr.puts "gem-contribute: unknown config subcommand"
|
|
41
|
+
@stderr.puts USAGE
|
|
42
|
+
2
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def set(argv)
|
|
49
|
+
key = argv.shift
|
|
50
|
+
value = argv.shift
|
|
51
|
+
if key.nil? || value.nil?
|
|
52
|
+
@stderr.puts "Usage: gem-contribute config set <key> <value>"
|
|
53
|
+
return 2
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@config.set(key, value)
|
|
57
|
+
@stdout.puts "#{key} = #{value}"
|
|
58
|
+
0
|
|
59
|
+
rescue ArgumentError => e
|
|
60
|
+
@stderr.puts e.message
|
|
61
|
+
1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def get(argv)
|
|
65
|
+
key = argv.shift
|
|
66
|
+
if key.nil?
|
|
67
|
+
@stderr.puts "Usage: gem-contribute config get <key>"
|
|
68
|
+
return 2
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
unless GemContribute::Config::KNOWN_KEYS.include?(key)
|
|
72
|
+
@stderr.puts "unknown config key #{key.inspect}"
|
|
73
|
+
return 1
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@stdout.puts @config.to_h.fetch(key, "(not set — default applies)")
|
|
77
|
+
0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def list
|
|
81
|
+
@stdout.puts "Configuration (#{GemContribute::Config.default_path}):"
|
|
82
|
+
@stdout.puts " clone_root = #{@config.clone_root}"
|
|
83
|
+
0
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module GemContribute
|
|
6
|
+
module CLI
|
|
7
|
+
# `gem-contribute fork-clone-branch <gem>/<issue#>`
|
|
8
|
+
#
|
|
9
|
+
# Performs the full sequence the TUI's `f` keybinding will trigger in
|
|
10
|
+
# Stage 3:
|
|
11
|
+
#
|
|
12
|
+
# 1. Resolve <gem> via the RubyGems Resolver (no lockfile required;
|
|
13
|
+
# the lockfile is for discovery via `scan`, not gating here).
|
|
14
|
+
# 2. Read the cached GitHub token; raise AuthRequired with a clear
|
|
15
|
+
# `auth login` hint if missing.
|
|
16
|
+
# 3. Look up the viewer's login.
|
|
17
|
+
# 4. If they don't already have a fork, fork the upstream repo.
|
|
18
|
+
# 5. Poll until the fork is reachable (forks return 202 immediately
|
|
19
|
+
# but the resource may 404 for a few seconds).
|
|
20
|
+
# 6. `git clone` the fork to `<clone_root>/<owner>/<repo>`.
|
|
21
|
+
# 7. `git checkout -b gem-contribute/issue-<N>` from the default branch.
|
|
22
|
+
# 8. Print the local path on stdout.
|
|
23
|
+
#
|
|
24
|
+
# The shell-outs use Open3 with explicit args (not strings) to avoid any
|
|
25
|
+
# shell-injection surface.
|
|
26
|
+
class ForkCloneBranch
|
|
27
|
+
DEFAULT_CLONE_ROOT = File.expand_path("~/code/oss")
|
|
28
|
+
BRANCH_PREFIX = "gem-contribute/issue-"
|
|
29
|
+
FORK_READINESS_RETRIES = 12 # 12 × 5s = 60s ceiling
|
|
30
|
+
FORK_READINESS_INTERVAL = 5
|
|
31
|
+
|
|
32
|
+
def initialize(stdout: $stdout,
|
|
33
|
+
stderr: $stderr,
|
|
34
|
+
resolver: Resolver.new,
|
|
35
|
+
store: TokenStore.new,
|
|
36
|
+
adapter_factory: ->(token:) { HostAdapters::GitHubAdapter.new(token: token) },
|
|
37
|
+
git: Git.new,
|
|
38
|
+
clone_root: DEFAULT_CLONE_ROOT,
|
|
39
|
+
sleeper: ->(s) { Kernel.sleep(s) })
|
|
40
|
+
@stdout = stdout
|
|
41
|
+
@stderr = stderr
|
|
42
|
+
@resolver = resolver
|
|
43
|
+
@store = store
|
|
44
|
+
@adapter_factory = adapter_factory
|
|
45
|
+
@git = git
|
|
46
|
+
@clone_root = clone_root
|
|
47
|
+
@sleeper = sleeper
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def run(argv)
|
|
51
|
+
target = argv.shift
|
|
52
|
+
return print_usage_error if target.nil? || !target.include?("/")
|
|
53
|
+
|
|
54
|
+
gem_name, issue = target.split("/", 2)
|
|
55
|
+
adapter = build_adapter
|
|
56
|
+
return 1 if adapter.nil?
|
|
57
|
+
|
|
58
|
+
project = resolve_or_fail(gem_name)
|
|
59
|
+
return 1 if project.nil?
|
|
60
|
+
|
|
61
|
+
execute(adapter, project, issue)
|
|
62
|
+
rescue AuthRequired
|
|
63
|
+
@stderr.puts "Not authenticated. Run `gem-contribute auth login` first."
|
|
64
|
+
1
|
|
65
|
+
rescue AdapterError => e
|
|
66
|
+
@stderr.puts "fork-clone-branch failed: #{e.message}"
|
|
67
|
+
1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def print_usage_error
|
|
73
|
+
@stderr.puts "Usage: gem-contribute fork-clone-branch <gem>/<issue#>"
|
|
74
|
+
2
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_adapter
|
|
78
|
+
token = @store.token_for("github.com")
|
|
79
|
+
if token.nil?
|
|
80
|
+
@stderr.puts "Not authenticated. Run `gem-contribute auth login` first."
|
|
81
|
+
return nil
|
|
82
|
+
end
|
|
83
|
+
@adapter_factory.call(token: token)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def resolve_or_fail(gem_name)
|
|
87
|
+
return GemContribute::SELF_PROJECT if gem_name == GemContribute::SELF_PROJECT.gem_name
|
|
88
|
+
|
|
89
|
+
gem = LockedGem.new(name: gem_name, version: "*", source_type: :rubygems, source_uri: "https://rubygems.org/")
|
|
90
|
+
project = @resolver.resolve(gem)
|
|
91
|
+
|
|
92
|
+
if project.host != "github.com"
|
|
93
|
+
@stderr.puts "Cannot fork-clone-branch: #{gem_name} resolves to #{project.host} " \
|
|
94
|
+
"(only github.com is supported at v0.1)"
|
|
95
|
+
return nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
project
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def execute(adapter, project, issue)
|
|
102
|
+
viewer = adapter.viewer_login
|
|
103
|
+
clone_url = ensure_fork(adapter, project, viewer)
|
|
104
|
+
local_path = clone_into_root(project, clone_url)
|
|
105
|
+
branch_name = "#{BRANCH_PREFIX}#{issue}"
|
|
106
|
+
@git.checkout_branch(local_path, branch_name)
|
|
107
|
+
# `submit` needs to know the canonical project to point the PR at.
|
|
108
|
+
# Naming it `upstream` follows the standard fork workflow convention.
|
|
109
|
+
@git.add_remote(local_path, "upstream",
|
|
110
|
+
"https://github.com/#{project.owner}/#{project.repo}.git")
|
|
111
|
+
|
|
112
|
+
@stdout.puts "Forked, cloned, and branched."
|
|
113
|
+
@stdout.puts " path: #{local_path}"
|
|
114
|
+
@stdout.puts " branch: #{branch_name}"
|
|
115
|
+
@stdout.puts " upstream: https://github.com/#{project.owner}/#{project.repo}"
|
|
116
|
+
@stdout.puts " fork: https://github.com/#{viewer}/#{project.repo}"
|
|
117
|
+
@stdout.puts
|
|
118
|
+
@stdout.puts "Next: cd #{local_path} && make your changes, then `gem-contribute submit`."
|
|
119
|
+
0
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def ensure_fork(adapter, project, viewer)
|
|
123
|
+
if adapter.already_forked?(project)
|
|
124
|
+
@stdout.puts "You already have a fork at #{viewer}/#{project.repo}. Skipping fork."
|
|
125
|
+
return "https://github.com/#{viewer}/#{project.repo}.git"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
@stdout.puts "Forking #{project.owner}/#{project.repo} → #{viewer}/#{project.repo}..."
|
|
129
|
+
body = adapter.fork(project)
|
|
130
|
+
wait_until_ready(adapter, viewer, project.repo)
|
|
131
|
+
body.fetch("clone_url")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def wait_until_ready(adapter, viewer, name)
|
|
135
|
+
ready = FORK_READINESS_RETRIES.times.any? do |i|
|
|
136
|
+
break true if adapter.fork_ready?(viewer, name)
|
|
137
|
+
|
|
138
|
+
@sleeper.call(FORK_READINESS_INTERVAL) unless i == FORK_READINESS_RETRIES - 1
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
return if ready
|
|
142
|
+
|
|
143
|
+
raise AdapterError, "fork not reachable after #{FORK_READINESS_RETRIES * FORK_READINESS_INTERVAL}s"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def clone_into_root(project, clone_url)
|
|
147
|
+
target = File.join(@clone_root, project.owner, project.repo)
|
|
148
|
+
if File.directory?(File.join(target, ".git"))
|
|
149
|
+
@stdout.puts "Reusing existing clone at #{target}."
|
|
150
|
+
return target
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
154
|
+
@stdout.puts "Cloning into #{target}..."
|
|
155
|
+
@git.clone(clone_url, target)
|
|
156
|
+
target
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Thin wrapper around git so the spec can swap in a fake without shelling
|
|
161
|
+
# out. The real implementation uses Open3 with arg-list invocation — no
|
|
162
|
+
# shell, so no injection surface.
|
|
163
|
+
class Git
|
|
164
|
+
def clone(url, target)
|
|
165
|
+
run!(["git", "clone", url, target])
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def checkout_branch(path, branch)
|
|
169
|
+
run!(["git", "-C", path, "checkout", "-b", branch])
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def add_remote(path, name, url)
|
|
173
|
+
# Idempotent: if the remote already exists (e.g. reusing a clone)
|
|
174
|
+
# we silently succeed rather than fail the whole flow.
|
|
175
|
+
return if remote_exists?(path, name)
|
|
176
|
+
|
|
177
|
+
run!(["git", "-C", path, "remote", "add", name, url])
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def push(path, remote, branch)
|
|
181
|
+
run!(["git", "-C", path, "push", "-u", remote, branch])
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def remote_exists?(path, name)
|
|
185
|
+
out, _err, status = Open3.capture3("git", "-C", path, "remote")
|
|
186
|
+
status.success? && out.split("\n").include?(name)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def run!(argv)
|
|
190
|
+
_stdout, stderr_str, status = Open3.capture3(*argv)
|
|
191
|
+
return if status.success?
|
|
192
|
+
|
|
193
|
+
raise GemContribute::AdapterError, "git #{argv[1..].join(" ")} failed: #{stderr_str.strip}"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemContribute
|
|
4
|
+
module CLI
|
|
5
|
+
# `gem-contribute issues <gem|all>` — list open "good first issue" issues.
|
|
6
|
+
#
|
|
7
|
+
# With a gem name: lists issues for that gem.
|
|
8
|
+
# With "all": iterates every github.com gem in Gemfile.lock.
|
|
9
|
+
#
|
|
10
|
+
# Issue numbers appear prominently so they can be passed directly to
|
|
11
|
+
# `fork-clone-branch <gem>/<issue#>`.
|
|
12
|
+
class Issues
|
|
13
|
+
DEFAULT_LABEL = "good first issue"
|
|
14
|
+
|
|
15
|
+
def initialize(stdout: $stdout, stderr: $stderr,
|
|
16
|
+
resolver: Resolver.new,
|
|
17
|
+
adapter: HostAdapters::GitHubAdapter.new,
|
|
18
|
+
lockfile_path: "Gemfile.lock")
|
|
19
|
+
@stdout = stdout
|
|
20
|
+
@stderr = stderr
|
|
21
|
+
@resolver = resolver
|
|
22
|
+
@adapter = adapter
|
|
23
|
+
@lockfile_path = lockfile_path
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(argv)
|
|
27
|
+
target = argv.shift
|
|
28
|
+
return print_usage if target.nil?
|
|
29
|
+
|
|
30
|
+
if target == "all"
|
|
31
|
+
run_all
|
|
32
|
+
else
|
|
33
|
+
project = resolve_or_fail(target)
|
|
34
|
+
return 1 if project.nil?
|
|
35
|
+
|
|
36
|
+
list_issues(project)
|
|
37
|
+
end
|
|
38
|
+
rescue AdapterError => e
|
|
39
|
+
@stderr.puts "gem-contribute: #{e.message}"
|
|
40
|
+
1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def print_usage
|
|
46
|
+
@stderr.puts "Usage: gem-contribute issues <gem|all>"
|
|
47
|
+
2
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def run_all
|
|
51
|
+
gems = LockfileParser.parse(@lockfile_path)
|
|
52
|
+
projects = gems.filter_map do |gem|
|
|
53
|
+
project = @resolver.resolve(gem)
|
|
54
|
+
project if project.host == "github.com"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@stdout.puts "Scanning #{projects.size} github.com gems from #{@lockfile_path}...\n\n"
|
|
58
|
+
|
|
59
|
+
any = false
|
|
60
|
+
projects.each do |project|
|
|
61
|
+
issues = fetch_issues(project)
|
|
62
|
+
next if issues.empty?
|
|
63
|
+
|
|
64
|
+
any = true
|
|
65
|
+
print_project_issues(project, issues)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@stdout.puts "(no good first issues found across #{projects.size} gems)" unless any
|
|
69
|
+
0
|
|
70
|
+
rescue LockfileNotFound => e
|
|
71
|
+
@stderr.puts "gem-contribute: #{e.message}"
|
|
72
|
+
1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fetch_issues(project)
|
|
76
|
+
@adapter.issues(project, labels: [DEFAULT_LABEL])
|
|
77
|
+
rescue AdapterError => e
|
|
78
|
+
@stderr.puts " warning: #{project.gem_name}: #{e.message}"
|
|
79
|
+
[]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve_or_fail(gem_name)
|
|
83
|
+
# gem-contribute isn't on RubyGems yet; short-circuit to the canonical
|
|
84
|
+
# self-project so the tool's own issues are reachable today.
|
|
85
|
+
return GemContribute::SELF_PROJECT if gem_name == GemContribute::SELF_PROJECT.gem_name
|
|
86
|
+
|
|
87
|
+
gem = LockedGem.new(name: gem_name, version: "*",
|
|
88
|
+
source_type: :rubygems, source_uri: "https://rubygems.org/")
|
|
89
|
+
project = @resolver.resolve(gem)
|
|
90
|
+
|
|
91
|
+
if project.host != "github.com"
|
|
92
|
+
@stderr.puts "#{gem_name}: resolves to #{project.host} (only github.com is supported)"
|
|
93
|
+
return nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
project
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def list_issues(project)
|
|
100
|
+
issues = @adapter.issues(project, labels: [DEFAULT_LABEL])
|
|
101
|
+
print_project_issues(project, issues)
|
|
102
|
+
@stdout.puts "To contribute: gem-contribute fix #{project.gem_name}/<issue#>"
|
|
103
|
+
0
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def print_project_issues(project, issues)
|
|
107
|
+
repo_url = "https://github.com/#{project.owner}/#{project.repo}"
|
|
108
|
+
@stdout.puts "#{project.gem_name} — #{issues.size} open \"#{DEFAULT_LABEL}\" issues (#{repo_url})"
|
|
109
|
+
|
|
110
|
+
if issues.empty?
|
|
111
|
+
@stdout.puts " (none — browse #{repo_url}/issues directly)"
|
|
112
|
+
else
|
|
113
|
+
@stdout.puts
|
|
114
|
+
issues.each do |issue|
|
|
115
|
+
@stdout.puts " ##{issue["number"]} #{issue["title"]}"
|
|
116
|
+
@stdout.puts " #{issue["html_url"]}"
|
|
117
|
+
@stdout.puts
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemContribute
|
|
4
|
+
module CLI
|
|
5
|
+
# `gem-contribute scan [path]` — Stage 1's command.
|
|
6
|
+
#
|
|
7
|
+
# Reads a Gemfile.lock, resolves each rubygems-sourced gem, hits the
|
|
8
|
+
# GitHub adapter for `good first issue`-tagged issue counts on every
|
|
9
|
+
# github.com project, and prints:
|
|
10
|
+
#
|
|
11
|
+
# <N> gems · <N> on github.com · <N> on <other> · <N> unknown source
|
|
12
|
+
#
|
|
13
|
+
# Top contributable projects (by open `good first issue` count):
|
|
14
|
+
# <gem-name> <count> <github.com/owner/repo>
|
|
15
|
+
# ...
|
|
16
|
+
class Scan
|
|
17
|
+
# GitHub's `labels=foo,bar` query is an AND, not an OR, so passing the
|
|
18
|
+
# full set of beginner-friendly variants returns almost nothing. Stage 1
|
|
19
|
+
# uses the canonical `good first issue` label only — the "render labels
|
|
20
|
+
# verbatim" promise in ADR-0005 belongs to display, not to server-side
|
|
21
|
+
# filtering. Future stages can call once per label and dedupe.
|
|
22
|
+
DEFAULT_LABEL = "good first issue"
|
|
23
|
+
|
|
24
|
+
def initialize(stdout: $stdout, stderr: $stderr, resolver: Resolver.new, adapter: HostAdapters::GitHubAdapter.new)
|
|
25
|
+
@stdout = stdout
|
|
26
|
+
@stderr = stderr
|
|
27
|
+
@resolver = resolver
|
|
28
|
+
@adapter = adapter
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param argv [Array<String>] passed-in args (no leading "scan")
|
|
32
|
+
# @return [Integer] exit status
|
|
33
|
+
def run(argv)
|
|
34
|
+
path = argv.first || "Gemfile.lock"
|
|
35
|
+
gems = LockfileParser.parse(path)
|
|
36
|
+
@stdout.puts "Scanning #{path} (#{gems.size} gems)..."
|
|
37
|
+
|
|
38
|
+
projects = gems.map { |gem| @resolver.resolve(gem) }
|
|
39
|
+
# Summary tally reflects only the lockfile contents — the
|
|
40
|
+
# self-injection is intentionally additive, not part of the count.
|
|
41
|
+
print_summary(tally_hosts(projects), gems.size)
|
|
42
|
+
scan_github_projects(projects)
|
|
43
|
+
0
|
|
44
|
+
rescue LockfileNotFound => e
|
|
45
|
+
@stderr.puts "gem-contribute: #{e.message}"
|
|
46
|
+
1
|
|
47
|
+
rescue Errno::ECONNREFUSED, SocketError => e
|
|
48
|
+
@stderr.puts "gem-contribute: network unreachable (#{e.class}: #{e.message})"
|
|
49
|
+
@stderr.puts "Re-run when you have connectivity, or use cached data with --refresh disabled."
|
|
50
|
+
1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def inject_self(github_projects)
|
|
56
|
+
return github_projects if github_projects.any? { |p| p.gem_name == GemContribute::SELF_PROJECT.gem_name }
|
|
57
|
+
|
|
58
|
+
github_projects + [GemContribute::SELF_PROJECT]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def scan_github_projects(projects)
|
|
62
|
+
github_from_lockfile = projects.select { |p| p.host == "github.com" }
|
|
63
|
+
@stdout.puts "\nNo github.com projects in this lockfile." if github_from_lockfile.empty?
|
|
64
|
+
|
|
65
|
+
ranked = rank_by_issue_count(inject_self(github_from_lockfile))
|
|
66
|
+
return if ranked.empty?
|
|
67
|
+
|
|
68
|
+
print_ranked(ranked)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def tally_hosts(projects)
|
|
72
|
+
counts = Hash.new(0)
|
|
73
|
+
projects.each { |p| counts[p.host] += 1 }
|
|
74
|
+
counts
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def print_summary(host_counts, total)
|
|
78
|
+
parts = ["#{total} gems"]
|
|
79
|
+
host_counts.each do |host, count|
|
|
80
|
+
label = host == :unknown ? "unknown source" : "on #{host}"
|
|
81
|
+
parts << "#{count} #{label}"
|
|
82
|
+
end
|
|
83
|
+
@stdout.puts parts.join(" · ")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def rank_by_issue_count(projects)
|
|
87
|
+
# We hit the API anonymously here. With a 60/hr unauthenticated rate
|
|
88
|
+
# limit, scanning a 50-gem lockfile is the dominant pressure on this
|
|
89
|
+
# CLI. The 7-day RubyGems and 5-min issues caches absorb most repeats.
|
|
90
|
+
results = projects.map do |project|
|
|
91
|
+
count = issue_count(project)
|
|
92
|
+
[project, count]
|
|
93
|
+
end.compact
|
|
94
|
+
|
|
95
|
+
results.reject { |_, count| count.zero? }.sort_by { |_, count| -count }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def issue_count(project)
|
|
99
|
+
issues = @adapter.issues(project, labels: [DEFAULT_LABEL])
|
|
100
|
+
issues.size
|
|
101
|
+
rescue AdapterError, AuthRequired => e
|
|
102
|
+
@stderr.puts " warning: #{project.gem_name} (#{project.host}/#{project.owner}/#{project.repo}): #{e.message}"
|
|
103
|
+
0
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def print_ranked(ranked)
|
|
107
|
+
@stdout.puts
|
|
108
|
+
@stdout.puts "Top contributable projects (by open `good first issue` count):"
|
|
109
|
+
col_name = ranked.map { |p, _| p.gem_name.length }.max
|
|
110
|
+
ranked.each do |project, count|
|
|
111
|
+
location = "#{project.host}/#{project.owner}/#{project.repo}"
|
|
112
|
+
@stdout.printf(" %-#{col_name}s %3d %s\n", project.gem_name, count, location)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|