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
|
@@ -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,20 +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)
|
|
44
|
+
RateLimitFooter.print(adapter: @adapter, output: @output)
|
|
43
45
|
0
|
|
44
46
|
rescue LockfileNotFound => e
|
|
45
|
-
@
|
|
47
|
+
@output.error("gem-contribute: #{e.message}")
|
|
46
48
|
1
|
|
47
49
|
rescue Errno::ECONNREFUSED, SocketError => e
|
|
48
|
-
@
|
|
49
|
-
@
|
|
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.")
|
|
50
52
|
1
|
|
51
53
|
end
|
|
52
54
|
|
|
@@ -60,12 +62,13 @@ module GemContribute
|
|
|
60
62
|
|
|
61
63
|
def scan_github_projects(projects)
|
|
62
64
|
github_from_lockfile = projects.select { |p| p.host == "github.com" }
|
|
63
|
-
@
|
|
65
|
+
@output.info("\nNo github.com projects in this lockfile.") if github_from_lockfile.empty?
|
|
64
66
|
|
|
65
67
|
ranked = rank_by_issue_count(inject_self(github_from_lockfile))
|
|
66
68
|
return if ranked.empty?
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
claim_index = IssueAnnouncer.fetch_claim_index(@adapter)
|
|
71
|
+
print_ranked(ranked, claim_index)
|
|
69
72
|
end
|
|
70
73
|
|
|
71
74
|
def tally_hosts(projects)
|
|
@@ -80,7 +83,7 @@ module GemContribute
|
|
|
80
83
|
label = host == :unknown ? "unknown source" : "on #{host}"
|
|
81
84
|
parts << "#{count} #{label}"
|
|
82
85
|
end
|
|
83
|
-
@
|
|
86
|
+
@output.info(parts.join(" · "))
|
|
84
87
|
end
|
|
85
88
|
|
|
86
89
|
def rank_by_issue_count(projects)
|
|
@@ -99,17 +102,19 @@ module GemContribute
|
|
|
99
102
|
issues = @adapter.issues(project, labels: [DEFAULT_LABEL])
|
|
100
103
|
issues.size
|
|
101
104
|
rescue AdapterError, AuthRequired => e
|
|
102
|
-
@
|
|
105
|
+
@output.warn(" warning: #{project.gem_name} (#{project.host}/#{project.owner}/#{project.repo}): #{e.message}")
|
|
103
106
|
0
|
|
104
107
|
end
|
|
105
108
|
|
|
106
|
-
def print_ranked(ranked)
|
|
107
|
-
@
|
|
108
|
-
@
|
|
109
|
+
def print_ranked(ranked, claim_index)
|
|
110
|
+
@output.info("")
|
|
111
|
+
@output.info("Top contributable projects (by open `good first issue` count):")
|
|
109
112
|
col_name = ranked.map { |p, _| p.gem_name.length }.max
|
|
110
113
|
ranked.each do |project, count|
|
|
111
114
|
location = "#{project.host}/#{project.owner}/#{project.repo}"
|
|
112
|
-
|
|
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))
|
|
113
118
|
end
|
|
114
119
|
end
|
|
115
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,17 +4,11 @@ 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 :Issues, "gem_contribute/cli/issues"
|
|
11
|
-
autoload :ForkCloneBranch, "gem_contribute/cli/fork_clone_branch"
|
|
12
|
-
autoload :Git, "gem_contribute/cli/fork_clone_branch"
|
|
13
|
-
autoload :Submit, "gem_contribute/cli/submit"
|
|
14
7
|
USAGE = <<~USAGE
|
|
15
8
|
Usage: gem-contribute <command> [options]
|
|
16
9
|
|
|
17
10
|
Commands:
|
|
11
|
+
init One-time interactive setup (sets clone_root).
|
|
18
12
|
scan [path] Summarize the contributable surface of a Gemfile.lock.
|
|
19
13
|
Path defaults to ./Gemfile.lock.
|
|
20
14
|
issues <gem|all> List open "good first issue" issues for a gem (or all gems).
|
|
@@ -24,8 +18,13 @@ module GemContribute
|
|
|
24
18
|
auth login Authenticate with GitHub via OAuth device flow.
|
|
25
19
|
auth status Show whether you're authenticated.
|
|
26
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).
|
|
27
26
|
fix <gem>/<issue#> Fork the gem's repo, clone the fork, branch from main.
|
|
28
|
-
|
|
27
|
+
Flags: -e (editor), -a (AI tool), --no-comment.
|
|
29
28
|
submit Push the current branch and open a pre-filled
|
|
30
29
|
PR compare page in the browser. Run from inside
|
|
31
30
|
a clone created by `fix`.
|
|
@@ -58,17 +57,15 @@ module GemContribute
|
|
|
58
57
|
end
|
|
59
58
|
|
|
60
59
|
COMMANDS = {
|
|
60
|
+
"init" => ->(o, e) { Init.new(stdout: o, stderr: e) },
|
|
61
61
|
"scan" => ->(o, e) { Scan.new(stdout: o, stderr: e, adapter: github_adapter) },
|
|
62
62
|
"issues" => ->(o, e) { Issues.new(stdout: o, stderr: e, adapter: github_adapter) },
|
|
63
63
|
"config" => ->(o, e) { Config.new(stdout: o, stderr: e) },
|
|
64
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) },
|
|
65
66
|
"fix" => lambda { |o, e|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
},
|
|
69
|
-
"fork-clone-branch" => lambda { |o, e|
|
|
70
|
-
ForkCloneBranch.new(stdout: o, stderr: e,
|
|
71
|
-
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)
|
|
72
69
|
},
|
|
73
70
|
"submit" => ->(o, e) { Submit.new(stdout: o, stderr: e) }
|
|
74
71
|
}.freeze
|
|
@@ -8,9 +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
|
-
|
|
12
|
-
|
|
13
|
-
KNOWN_KEYS = %w[clone_root].freeze
|
|
11
|
+
KNOWN_KEYS = %w[clone_root editor ai_tool comment_on_fix].freeze
|
|
14
12
|
|
|
15
13
|
def initialize(path: self.class.default_path)
|
|
16
14
|
@path = path
|
|
@@ -19,7 +17,26 @@ module GemContribute
|
|
|
19
17
|
|
|
20
18
|
def clone_root
|
|
21
19
|
raw = @data["clone_root"]
|
|
22
|
-
raw ? File.expand_path(raw) :
|
|
20
|
+
raw ? File.expand_path(raw) : nil
|
|
21
|
+
end
|
|
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)
|
|
23
40
|
end
|
|
24
41
|
|
|
25
42
|
def set(key, value)
|
|
@@ -41,6 +58,13 @@ module GemContribute
|
|
|
41
58
|
|
|
42
59
|
private
|
|
43
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
|
+
|
|
44
68
|
def load_file
|
|
45
69
|
return {} unless File.exist?(@path)
|
|
46
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
|