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
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module GemContribute
|
|
6
|
+
module CLI
|
|
7
|
+
# Optional post-clone hooks invoked by `fix` when the user passes
|
|
8
|
+
# `-e` (open editor) and/or `-a` (launch AI tool). Extracted so the
|
|
9
|
+
# `fix` state machine stays focused on the fork/clone/branch
|
|
10
|
+
# sequence.
|
|
11
|
+
class PostCloneHooks
|
|
12
|
+
def initialize(stdout: $stdout, stderr: $stderr, output: nil,
|
|
13
|
+
config: GemContribute::Config.new,
|
|
14
|
+
editor_runner: ->(cmd, path) { Kernel.system("#{cmd} #{path.shellescape}") },
|
|
15
|
+
ai_runner: ->(cmd, path) { Kernel.system(cmd, chdir: path) })
|
|
16
|
+
@output = output || Output::Standard.new(out: stdout, err: stderr)
|
|
17
|
+
@config = config
|
|
18
|
+
@editor_runner = editor_runner
|
|
19
|
+
@ai_runner = ai_runner
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(local_path, editor:, ai_tool:)
|
|
23
|
+
open_editor(local_path) if editor
|
|
24
|
+
launch_ai(local_path) if ai_tool
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def open_editor(local_path)
|
|
30
|
+
editor = @config.editor || ENV.fetch("EDITOR", nil)
|
|
31
|
+
if editor.nil? || editor.empty?
|
|
32
|
+
@output.warn("-e: no editor configured. " \
|
|
33
|
+
"Set with `gem-contribute config set editor <cmd>` or set $EDITOR.")
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
@editor_runner.call(editor, local_path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def launch_ai(local_path)
|
|
40
|
+
ai_tool = @config.ai_tool
|
|
41
|
+
if ai_tool.nil? || ai_tool.empty?
|
|
42
|
+
@output.warn("-a: no ai_tool configured. " \
|
|
43
|
+
"Set with `gem-contribute config set ai_tool \"<cmd>\"`.")
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
@ai_runner.call(ai_tool, local_path)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -13,15 +13,17 @@ module GemContribute
|
|
|
13
13
|
module_function
|
|
14
14
|
|
|
15
15
|
# @param adapter [GemContribute::HostAdapters::GitHubAdapter]
|
|
16
|
-
# @param
|
|
17
|
-
|
|
16
|
+
# @param output [GemContribute::Output::Standard, GemContribute::Output::Null]
|
|
17
|
+
# @param stdout [IO] backward-compat for callers that haven't migrated to `output:`
|
|
18
|
+
def print(adapter:, output: nil, stdout: $stdout)
|
|
19
|
+
output ||= Output::Standard.new(out: stdout)
|
|
18
20
|
rate_limit = adapter.respond_to?(:rate_limit) ? adapter.rate_limit : nil
|
|
19
21
|
return if rate_limit.nil?
|
|
20
22
|
|
|
21
23
|
remaining = format_with_separators(rate_limit.remaining)
|
|
22
24
|
limit = format_with_separators(rate_limit.limit)
|
|
23
25
|
reset = rate_limit.reset_at.utc.strftime("%H:%M")
|
|
24
|
-
|
|
26
|
+
output.info("GitHub rate limit: #{remaining} / #{limit} remaining · resets at #{reset} UTC")
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def format_with_separators(integer)
|
|
@@ -21,9 +21,10 @@ module GemContribute
|
|
|
21
21
|
# filtering. Future stages can call once per label and dedupe.
|
|
22
22
|
DEFAULT_LABEL = "good first issue"
|
|
23
23
|
|
|
24
|
-
def initialize(stdout: $stdout, stderr: $stderr,
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
def initialize(stdout: $stdout, stderr: $stderr, output: nil,
|
|
25
|
+
resolver: Resolver.new,
|
|
26
|
+
adapter: HostAdapters::GitHubAdapter.new)
|
|
27
|
+
@output = output || Output::Standard.new(out: stdout, err: stderr)
|
|
27
28
|
@resolver = resolver
|
|
28
29
|
@adapter = adapter
|
|
29
30
|
end
|
|
@@ -33,21 +34,21 @@ module GemContribute
|
|
|
33
34
|
def run(argv)
|
|
34
35
|
path = argv.first || "Gemfile.lock"
|
|
35
36
|
gems = LockfileParser.parse(path)
|
|
36
|
-
@
|
|
37
|
+
@output.progress("Scanning #{path} (#{gems.size} gems)...")
|
|
37
38
|
|
|
38
39
|
projects = gems.map { |gem| @resolver.resolve(gem) }
|
|
39
40
|
# Summary tally reflects only the lockfile contents — the
|
|
40
41
|
# self-injection is intentionally additive, not part of the count.
|
|
41
42
|
print_summary(tally_hosts(projects), gems.size)
|
|
42
43
|
scan_github_projects(projects)
|
|
43
|
-
RateLimitFooter.print(adapter: @adapter,
|
|
44
|
+
RateLimitFooter.print(adapter: @adapter, output: @output)
|
|
44
45
|
0
|
|
45
46
|
rescue LockfileNotFound => e
|
|
46
|
-
@
|
|
47
|
+
@output.error("gem-contribute: #{e.message}")
|
|
47
48
|
1
|
|
48
49
|
rescue Errno::ECONNREFUSED, SocketError => e
|
|
49
|
-
@
|
|
50
|
-
@
|
|
50
|
+
@output.error("gem-contribute: network unreachable (#{e.class}: #{e.message})")
|
|
51
|
+
@output.error("Re-run when you have connectivity, or use cached data with --refresh disabled.")
|
|
51
52
|
1
|
|
52
53
|
end
|
|
53
54
|
|
|
@@ -61,12 +62,13 @@ module GemContribute
|
|
|
61
62
|
|
|
62
63
|
def scan_github_projects(projects)
|
|
63
64
|
github_from_lockfile = projects.select { |p| p.host == "github.com" }
|
|
64
|
-
@
|
|
65
|
+
@output.info("\nNo github.com projects in this lockfile.") if github_from_lockfile.empty?
|
|
65
66
|
|
|
66
67
|
ranked = rank_by_issue_count(inject_self(github_from_lockfile))
|
|
67
68
|
return if ranked.empty?
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
claim_index = IssueAnnouncer.fetch_claim_index(@adapter)
|
|
71
|
+
print_ranked(ranked, claim_index)
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
def tally_hosts(projects)
|
|
@@ -81,7 +83,7 @@ module GemContribute
|
|
|
81
83
|
label = host == :unknown ? "unknown source" : "on #{host}"
|
|
82
84
|
parts << "#{count} #{label}"
|
|
83
85
|
end
|
|
84
|
-
@
|
|
86
|
+
@output.info(parts.join(" · "))
|
|
85
87
|
end
|
|
86
88
|
|
|
87
89
|
def rank_by_issue_count(projects)
|
|
@@ -100,17 +102,19 @@ module GemContribute
|
|
|
100
102
|
issues = @adapter.issues(project, labels: [DEFAULT_LABEL])
|
|
101
103
|
issues.size
|
|
102
104
|
rescue AdapterError, AuthRequired => e
|
|
103
|
-
@
|
|
105
|
+
@output.warn(" warning: #{project.gem_name} (#{project.host}/#{project.owner}/#{project.repo}): #{e.message}")
|
|
104
106
|
0
|
|
105
107
|
end
|
|
106
108
|
|
|
107
|
-
def print_ranked(ranked)
|
|
108
|
-
@
|
|
109
|
-
@
|
|
109
|
+
def print_ranked(ranked, claim_index)
|
|
110
|
+
@output.info("")
|
|
111
|
+
@output.info("Top contributable projects (by open `good first issue` count):")
|
|
110
112
|
col_name = ranked.map { |p, _| p.gem_name.length }.max
|
|
111
113
|
ranked.each do |project, count|
|
|
112
114
|
location = "#{project.host}/#{project.owner}/#{project.repo}"
|
|
113
|
-
|
|
115
|
+
claimed = claim_index["#{project.owner}/#{project.repo}"] || []
|
|
116
|
+
suffix = claimed.empty? ? "" : " · #{claimed.size} claimed"
|
|
117
|
+
@output.info(format(" %-#{col_name}s %3d %s%s", project.gem_name, count, location, suffix))
|
|
114
118
|
end
|
|
115
119
|
end
|
|
116
120
|
end
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
|
-
require "uri"
|
|
5
4
|
|
|
6
5
|
module GemContribute
|
|
7
6
|
module CLI
|
|
@@ -13,21 +12,23 @@ module GemContribute
|
|
|
13
12
|
# - upstream remote → canonical owner/repo (where the PR is filed)
|
|
14
13
|
# - current branch → must match `gem-contribute/issue-<N>`
|
|
15
14
|
#
|
|
16
|
-
# The PR itself is NOT opened via API. We push, then open
|
|
17
|
-
# page in the browser with title and body pre-filled. This
|
|
18
|
-
# `auth login` UX (browser handles the human step) and means
|
|
19
|
-
# always reviews the PR text before submitting.
|
|
15
|
+
# The PR itself is NOT opened via API. We push, then open the host's
|
|
16
|
+
# compare/MR page in the browser with title and body pre-filled. This
|
|
17
|
+
# mirrors the `auth login` UX (browser handles the human step) and means
|
|
18
|
+
# the user always reviews the PR text before submitting. The host-specific
|
|
19
|
+
# URL is built by the adapter (ADR-0011).
|
|
20
20
|
class Submit
|
|
21
|
+
include PlatformTools
|
|
22
|
+
|
|
21
23
|
BRANCH_REGEX = %r{\Agem-contribute/issue-(\d+)\z}
|
|
22
24
|
|
|
23
|
-
def initialize(stdout: $stdout, stderr: $stderr,
|
|
24
|
-
git: Git.new,
|
|
25
|
+
def initialize(stdout: $stdout, stderr: $stderr, output: nil,
|
|
26
|
+
git: GemContribute::Git.new,
|
|
25
27
|
adapter_factory: ->(token:) { HostAdapters::GitHubAdapter.new(token: token) },
|
|
26
28
|
store: TokenStore.new,
|
|
27
29
|
browser_opener: nil,
|
|
28
30
|
working_dir: Dir.pwd)
|
|
29
|
-
@
|
|
30
|
-
@stderr = stderr
|
|
31
|
+
@output = output || Output::Standard.new(out: stdout, err: stderr)
|
|
31
32
|
@git = git
|
|
32
33
|
@adapter_factory = adapter_factory
|
|
33
34
|
@store = store
|
|
@@ -47,19 +48,30 @@ module GemContribute
|
|
|
47
48
|
# separate fork and no `upstream` remote — fall back to origin and
|
|
48
49
|
# build a same-repo PR.
|
|
49
50
|
upstream = parse_remote("upstream", required: false) || origin
|
|
50
|
-
|
|
51
|
-
title = fetch_issue_title(upstream, issue_number)
|
|
52
|
-
push_branch(branch)
|
|
53
|
-
url = compare_url(upstream, origin, branch, issue_number, title)
|
|
54
|
-
open_and_print(url)
|
|
55
|
-
0
|
|
51
|
+
execute(branch, issue_number, origin, upstream)
|
|
56
52
|
rescue AdapterError => e
|
|
57
|
-
@
|
|
53
|
+
@output.error("submit failed: #{e.message}")
|
|
58
54
|
1
|
|
59
55
|
end
|
|
60
56
|
|
|
61
57
|
private
|
|
62
58
|
|
|
59
|
+
def execute(branch, issue_number, origin, upstream)
|
|
60
|
+
adapter = @adapter_factory.call(token: @store.token_for("github.com"))
|
|
61
|
+
upstream_project = project_for(upstream)
|
|
62
|
+
title = fetch_issue_title(adapter, upstream_project, issue_number)
|
|
63
|
+
push_branch(branch)
|
|
64
|
+
url = adapter.pull_request_url(
|
|
65
|
+
upstream_project,
|
|
66
|
+
head_owner: origin[:owner],
|
|
67
|
+
head_branch: branch,
|
|
68
|
+
title: pr_title(issue_number, title),
|
|
69
|
+
body: pr_body(issue_number)
|
|
70
|
+
)
|
|
71
|
+
open_and_print(url)
|
|
72
|
+
0
|
|
73
|
+
end
|
|
74
|
+
|
|
63
75
|
def current_branch
|
|
64
76
|
# symbolic-ref works even on a fresh branch with no commits;
|
|
65
77
|
# rev-parse --abbrev-ref doesn't.
|
|
@@ -73,82 +85,66 @@ module GemContribute
|
|
|
73
85
|
match = BRANCH_REGEX.match(branch)
|
|
74
86
|
return match[1].to_i if match
|
|
75
87
|
|
|
76
|
-
@
|
|
77
|
-
@
|
|
88
|
+
@output.error("submit: branch #{branch.inspect} doesn't match #{BRANCH_REGEX.source}.")
|
|
89
|
+
@output.error("Run `gem-contribute fix <gem>/<issue#>` first to set up the branch.")
|
|
78
90
|
nil
|
|
79
91
|
end
|
|
80
92
|
|
|
81
93
|
def parse_remote(name, required:)
|
|
82
94
|
out, _err, status = Open3.capture3("git", "-C", @working_dir, "remote", "get-url", name)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
@stderr.puts "submit: no `#{name}` remote configured. " \
|
|
86
|
-
"Are you inside a git clone?"
|
|
87
|
-
end
|
|
88
|
-
return nil
|
|
89
|
-
end
|
|
95
|
+
return missing_remote_error(name) if !status.success? && required
|
|
96
|
+
return nil unless status.success?
|
|
90
97
|
|
|
91
98
|
owner_repo_from_url(out.strip)
|
|
92
99
|
end
|
|
93
100
|
|
|
101
|
+
def missing_remote_error(name)
|
|
102
|
+
@output.error("submit: no `#{name}` remote configured. Are you inside a git clone?")
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
94
106
|
# Accepts both https://github.com/owner/repo(.git) and git@github.com:owner/repo.git
|
|
95
107
|
def owner_repo_from_url(url)
|
|
96
108
|
if (m = url.match(%r{github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?\z}))
|
|
97
109
|
{ owner: m[1], repo: m[2] }
|
|
98
110
|
else
|
|
99
|
-
@
|
|
111
|
+
@output.error("submit: can't parse GitHub owner/repo from #{url.inspect}")
|
|
100
112
|
nil
|
|
101
113
|
end
|
|
102
114
|
end
|
|
103
115
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
def project_for(owner_repo)
|
|
117
|
+
Project.new(
|
|
118
|
+
gem_name: owner_repo[:repo], host: "github.com",
|
|
119
|
+
owner: owner_repo[:owner], repo: owner_repo[:repo], metadata: {}
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def fetch_issue_title(adapter, upstream_project, number)
|
|
124
|
+
adapter.issue(upstream_project, number).fetch("title", nil)
|
|
108
125
|
rescue AdapterError => e
|
|
109
|
-
@
|
|
126
|
+
@output.warn("submit: couldn't fetch issue title (#{e.message}). Continuing without it.")
|
|
110
127
|
nil
|
|
111
128
|
end
|
|
112
129
|
|
|
113
|
-
def
|
|
114
|
-
|
|
115
|
-
@git.push(@working_dir, "origin", branch)
|
|
130
|
+
def pr_title(issue_number, title)
|
|
131
|
+
title ? "Fix ##{issue_number}: #{title}" : "Fix ##{issue_number}"
|
|
116
132
|
end
|
|
117
133
|
|
|
118
|
-
def
|
|
119
|
-
|
|
120
|
-
# Cross-fork: /<upstream>/compare/<fork-owner>:<branch>
|
|
121
|
-
# Same-repo: /<upstream>/compare/<branch>
|
|
122
|
-
# We omit the explicit base so GitHub auto-resolves to default.
|
|
123
|
-
# `expand=1` opens the PR creation form pre-filled.
|
|
124
|
-
same_repo = origin[:owner] == upstream[:owner] && origin[:repo] == upstream[:repo]
|
|
125
|
-
head = same_repo ? branch : "#{origin[:owner]}:#{branch}"
|
|
126
|
-
full_title = title ? "Fix ##{issue_number}: #{title}" : "Fix ##{issue_number}"
|
|
127
|
-
params = {
|
|
128
|
-
"expand" => "1",
|
|
129
|
-
"title" => full_title,
|
|
130
|
-
"body" => "Closes ##{issue_number}.\n\n_Opened via `gem-contribute submit`._"
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
"https://github.com/#{upstream[:owner]}/#{upstream[:repo]}/compare/#{head}?" \
|
|
134
|
-
"#{URI.encode_www_form(params)}"
|
|
134
|
+
def pr_body(issue_number)
|
|
135
|
+
"Closes ##{issue_number}.\n\n_Opened via `gem-contribute submit`._"
|
|
135
136
|
end
|
|
136
137
|
|
|
137
|
-
def
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
def push_branch(branch)
|
|
139
|
+
@output.progress("Pushing #{branch} to origin...") do
|
|
140
|
+
@git.push(@working_dir, "origin", branch)
|
|
141
|
+
end
|
|
141
142
|
end
|
|
142
143
|
|
|
143
|
-
def
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
when /mswin|mingw|cygwin/ then "start"
|
|
148
|
-
end
|
|
149
|
-
cmd && Kernel.system(cmd, uri)
|
|
150
|
-
rescue StandardError
|
|
151
|
-
false
|
|
144
|
+
def open_and_print(url)
|
|
145
|
+
opened = @browser_opener.call(url)
|
|
146
|
+
@output.info(opened ? "Opened browser to:" : "Open this URL to file the PR:")
|
|
147
|
+
@output.info(" #{url}")
|
|
152
148
|
end
|
|
153
149
|
end
|
|
154
150
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/monads"
|
|
4
|
+
|
|
5
|
+
module GemContribute
|
|
6
|
+
module CLI
|
|
7
|
+
# Shared scaffolding for action-style CLI verbs (Fix, Fork, future
|
|
8
|
+
# Abandon). Each verb owns its own `parse_argv`, `execute`, and
|
|
9
|
+
# `print_usage_error`; this module captures the common pieces around
|
|
10
|
+
# them: the missing-clone_root error, the auth-token check (as a
|
|
11
|
+
# Result-returning service per ADR-0012), and the project resolver.
|
|
12
|
+
#
|
|
13
|
+
# Including classes are expected to hold:
|
|
14
|
+
# @output, @resolver, @store, @adapter_factory, @clone_root
|
|
15
|
+
module Workflow
|
|
16
|
+
include Dry::Monads[:result]
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def missing_clone_root
|
|
21
|
+
@output.error("clone_root is not configured. Run `gem-contribute init` first.")
|
|
22
|
+
1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Per ADR-0012: returns Success(adapter) | Failure(:unauthenticated).
|
|
26
|
+
# Callers pattern-match — no nil + stderr side effect, no exceptions.
|
|
27
|
+
def build_adapter
|
|
28
|
+
token = @store.token_for("github.com")
|
|
29
|
+
return Failure(:unauthenticated) if token.nil?
|
|
30
|
+
|
|
31
|
+
Success(@adapter_factory.call(token: token))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Resolves a CLI target to a `github.com` Project, or prints an
|
|
35
|
+
# error and returns nil. With `allow_owner_repo: true` the slash
|
|
36
|
+
# form (`owner/repo`) bypasses RubyGems and constructs the Project
|
|
37
|
+
# directly — useful for verbs that don't require a published gem.
|
|
38
|
+
def resolve_target(target, verb:, allow_owner_repo: false)
|
|
39
|
+
if allow_owner_repo && target.include?("/")
|
|
40
|
+
owner, repo = target.split("/", 2)
|
|
41
|
+
return GemContribute::Project.new(
|
|
42
|
+
gem_name: repo, host: "github.com",
|
|
43
|
+
owner: owner, repo: repo, metadata: {}
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return GemContribute::SELF_PROJECT if target == GemContribute::SELF_PROJECT.gem_name
|
|
48
|
+
|
|
49
|
+
gem = LockedGem.new(name: target, version: "*",
|
|
50
|
+
source_type: :rubygems, source_uri: "https://rubygems.org/")
|
|
51
|
+
project = @resolver.resolve(gem)
|
|
52
|
+
|
|
53
|
+
if project.host != "github.com"
|
|
54
|
+
@output.error("Cannot run `#{verb}`: #{target} resolves to #{project.host} " \
|
|
55
|
+
"(only github.com is supported at v0.1)")
|
|
56
|
+
return nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
project
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/gem_contribute/cli.rb
CHANGED
|
@@ -4,15 +4,6 @@ require "optparse"
|
|
|
4
4
|
|
|
5
5
|
module GemContribute
|
|
6
6
|
module CLI
|
|
7
|
-
autoload :Scan, "gem_contribute/cli/scan"
|
|
8
|
-
autoload :Auth, "gem_contribute/cli/auth"
|
|
9
|
-
autoload :Config, "gem_contribute/cli/config"
|
|
10
|
-
autoload :Init, "gem_contribute/cli/init"
|
|
11
|
-
autoload :Issues, "gem_contribute/cli/issues"
|
|
12
|
-
autoload :ForkCloneBranch, "gem_contribute/cli/fork_clone_branch"
|
|
13
|
-
autoload :Git, "gem_contribute/cli/fork_clone_branch"
|
|
14
|
-
autoload :Submit, "gem_contribute/cli/submit"
|
|
15
|
-
autoload :RateLimitFooter, "gem_contribute/cli/rate_limit_footer"
|
|
16
7
|
USAGE = <<~USAGE
|
|
17
8
|
Usage: gem-contribute <command> [options]
|
|
18
9
|
|
|
@@ -27,8 +18,13 @@ module GemContribute
|
|
|
27
18
|
auth login Authenticate with GitHub via OAuth device flow.
|
|
28
19
|
auth status Show whether you're authenticated.
|
|
29
20
|
auth logout Remove the cached token for github.com.
|
|
21
|
+
fork <gem|owner/repo> Fork (and clone) any GitHub repo. Pass a gem name
|
|
22
|
+
to look it up on RubyGems, or `owner/repo` for any
|
|
23
|
+
GitHub project (e.g. `rubyevents/rubyevents`).
|
|
24
|
+
Lands on the default branch.
|
|
25
|
+
Flags: -e (editor), -a (AI tool).
|
|
30
26
|
fix <gem>/<issue#> Fork the gem's repo, clone the fork, branch from main.
|
|
31
|
-
|
|
27
|
+
Flags: -e (editor), -a (AI tool), --no-comment.
|
|
32
28
|
submit Push the current branch and open a pre-filled
|
|
33
29
|
PR compare page in the browser. Run from inside
|
|
34
30
|
a clone created by `fix`.
|
|
@@ -66,13 +62,10 @@ module GemContribute
|
|
|
66
62
|
"issues" => ->(o, e) { Issues.new(stdout: o, stderr: e, adapter: github_adapter) },
|
|
67
63
|
"config" => ->(o, e) { Config.new(stdout: o, stderr: e) },
|
|
68
64
|
"auth" => ->(o, e) { Auth.new(stdout: o, stderr: e) },
|
|
65
|
+
"fork" => ->(o, e) { Fork.new(stdout: o, stderr: e, clone_root: GemContribute::Config.new.clone_root) },
|
|
69
66
|
"fix" => lambda { |o, e|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
},
|
|
73
|
-
"fork-clone-branch" => lambda { |o, e|
|
|
74
|
-
ForkCloneBranch.new(stdout: o, stderr: e,
|
|
75
|
-
clone_root: GemContribute::Config.new.clone_root)
|
|
67
|
+
config = GemContribute::Config.new
|
|
68
|
+
Fix.new(stdout: o, stderr: e, clone_root: config.clone_root, config: config)
|
|
76
69
|
},
|
|
77
70
|
"submit" => ->(o, e) { Submit.new(stdout: o, stderr: e) }
|
|
78
71
|
}.freeze
|
|
@@ -8,7 +8,7 @@ module GemContribute
|
|
|
8
8
|
# Honors XDG_CONFIG_HOME so tests stay hermetic and unusual layouts work.
|
|
9
9
|
# Missing or corrupt files are treated as an empty config (no crash).
|
|
10
10
|
class Config
|
|
11
|
-
KNOWN_KEYS = %w[clone_root].freeze
|
|
11
|
+
KNOWN_KEYS = %w[clone_root editor ai_tool comment_on_fix].freeze
|
|
12
12
|
|
|
13
13
|
def initialize(path: self.class.default_path)
|
|
14
14
|
@path = path
|
|
@@ -20,6 +20,25 @@ module GemContribute
|
|
|
20
20
|
raw ? File.expand_path(raw) : nil
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def editor
|
|
24
|
+
@data["editor"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ai_tool
|
|
28
|
+
@data["ai_tool"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns whether `fix` should post a "working on this" comment.
|
|
32
|
+
# Pass a repo (`"owner/repo"`) to check the per-repo override; without
|
|
33
|
+
# a repo, returns the global default. Default is true when unset.
|
|
34
|
+
def comment_on_fix?(repo = nil)
|
|
35
|
+
overrides = @data["comment_on_fix_overrides"]
|
|
36
|
+
return truthy?(overrides[repo]) if repo && overrides.is_a?(Hash) && overrides.key?(repo)
|
|
37
|
+
|
|
38
|
+
raw = @data["comment_on_fix"]
|
|
39
|
+
raw.nil? || truthy?(raw)
|
|
40
|
+
end
|
|
41
|
+
|
|
23
42
|
def set(key, value)
|
|
24
43
|
raise ArgumentError, "unknown config key #{key.inspect}. Known keys: #{KNOWN_KEYS.join(", ")}" \
|
|
25
44
|
unless KNOWN_KEYS.include?(key)
|
|
@@ -39,6 +58,13 @@ module GemContribute
|
|
|
39
58
|
|
|
40
59
|
private
|
|
41
60
|
|
|
61
|
+
def truthy?(value)
|
|
62
|
+
case value
|
|
63
|
+
when true, false then value
|
|
64
|
+
else value.to_s.downcase != "false"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
42
68
|
def load_file
|
|
43
69
|
return {} unless File.exist?(@path)
|
|
44
70
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module GemContribute
|
|
6
|
+
# Thin wrapper around the `git` CLI so callers can substitute a fake in
|
|
7
|
+
# tests without shelling out. Uses Open3 with arg-list invocation (no shell)
|
|
8
|
+
# so there's no injection surface.
|
|
9
|
+
class Git
|
|
10
|
+
def clone(url, target)
|
|
11
|
+
run!(["git", "clone", url, target])
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def checkout_branch(path, branch)
|
|
15
|
+
run!(["git", "-C", path, "checkout", "-b", branch])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add_remote(path, name, url)
|
|
19
|
+
# Idempotent: if the remote already exists (e.g. reusing a clone)
|
|
20
|
+
# we silently succeed rather than fail the whole flow.
|
|
21
|
+
return if remote_exists?(path, name)
|
|
22
|
+
|
|
23
|
+
run!(["git", "-C", path, "remote", "add", name, url])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def push(path, remote, branch)
|
|
27
|
+
run!(["git", "-C", path, "push", "-u", remote, branch])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def remote_exists?(path, name)
|
|
31
|
+
out, _err, status = Open3.capture3("git", "-C", path, "remote")
|
|
32
|
+
status.success? && out.split("\n").include?(name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def branch_exists?(path, branch)
|
|
36
|
+
_out, _err, status = Open3.capture3("git", "-C", path,
|
|
37
|
+
"rev-parse", "--verify", "--quiet",
|
|
38
|
+
"refs/heads/#{branch}")
|
|
39
|
+
status.success?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run!(argv)
|
|
43
|
+
_stdout, stderr_str, status = Open3.capture3(*argv)
|
|
44
|
+
return if status.success?
|
|
45
|
+
|
|
46
|
+
raise GemContribute::AdapterError, "git #{argv[1..].join(" ")} failed: #{stderr_str.strip}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -3,24 +3,51 @@
|
|
|
3
3
|
module GemContribute
|
|
4
4
|
# Abstract host adapter. Concrete implementations (GitHubAdapter, future
|
|
5
5
|
# GitLabAdapter, future CodebergAdapter) conform to this interface so the
|
|
6
|
-
# TUI doesn't have to special-case
|
|
7
|
-
# adapter for a project's host.
|
|
6
|
+
# rest of the app — Operations, CLI verbs, TUI — doesn't have to special-case
|
|
7
|
+
# anything beyond looking up the right adapter for a project's host.
|
|
8
|
+
#
|
|
9
|
+
# See ADR-0011: HostAdapter owns the host-API verbs (fork, comment,
|
|
10
|
+
# pull_request_url) plus the host-specific URL templating (clone_url,
|
|
11
|
+
# repo_url). Higher layers compose those primitives; they don't construct
|
|
12
|
+
# host URLs themselves.
|
|
8
13
|
#
|
|
9
14
|
# Public-API methods (no auth needed):
|
|
10
15
|
# issues(project, labels:)
|
|
16
|
+
# issue(project, number)
|
|
17
|
+
# issue_comments(project, number)
|
|
11
18
|
# community_profile(project)
|
|
12
19
|
# file_contents(project, path)
|
|
20
|
+
# search_issues(query)
|
|
21
|
+
# clone_url(owner, repo)
|
|
22
|
+
# repo_url(owner, repo)
|
|
13
23
|
#
|
|
14
24
|
# Auth-required methods (raise AuthRequired without a cached token):
|
|
15
|
-
# fork(project)
|
|
16
|
-
#
|
|
25
|
+
# fork(project) — idempotent, blocks until the fork is reachable
|
|
26
|
+
# comment(project, issue:, body:)
|
|
27
|
+
# pull_request_url(upstream, head_owner:, head_branch:, title:, body:)
|
|
28
|
+
# viewer_login
|
|
17
29
|
#
|
|
18
30
|
# See ADR-0001 for the JIT auth contract.
|
|
19
31
|
class HostAdapter
|
|
32
|
+
# Result of a successful `fork(project)`.
|
|
33
|
+
# - clone_url: HTTPS URL suitable for `git clone`.
|
|
34
|
+
# - fork_url: human-readable web URL of the fork (used in summaries).
|
|
35
|
+
# - viewer: the authenticated user's login (and the fork's owner).
|
|
36
|
+
# - reused: true if the fork already existed; false if just created.
|
|
37
|
+
ForkResult = Data.define(:clone_url, :fork_url, :viewer, :reused)
|
|
38
|
+
|
|
20
39
|
def issues(_project, labels: nil)
|
|
21
40
|
raise NotImplementedError
|
|
22
41
|
end
|
|
23
42
|
|
|
43
|
+
def issue(_project, _number)
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def issue_comments(_project, _number)
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
end
|
|
50
|
+
|
|
24
51
|
def community_profile(_project)
|
|
25
52
|
raise NotImplementedError
|
|
26
53
|
end
|
|
@@ -29,11 +56,31 @@ module GemContribute
|
|
|
29
56
|
raise NotImplementedError
|
|
30
57
|
end
|
|
31
58
|
|
|
59
|
+
def search_issues(_query)
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
end
|
|
62
|
+
|
|
32
63
|
def fork(_project)
|
|
33
64
|
raise NotImplementedError
|
|
34
65
|
end
|
|
35
66
|
|
|
36
|
-
def
|
|
67
|
+
def comment(_project, issue:, body:)
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def pull_request_url(_upstream, head_owner:, head_branch:, title:, body:)
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def viewer_login
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def clone_url(_owner, _repo)
|
|
80
|
+
raise NotImplementedError
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def repo_url(_owner, _repo)
|
|
37
84
|
raise NotImplementedError
|
|
38
85
|
end
|
|
39
86
|
end
|