gem-contribute 0.2.0 → 0.3.1
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/.github/workflows/release.yml +71 -0
- data/CHANGELOG.md +38 -0
- data/CLAUDE.md +1 -1
- data/CONTRIBUTING.md +10 -4
- data/MAINTAINER.md +119 -2
- 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 +115 -5
- data/lib/gem_contribute/cli/fork_clone_branch.rb +0 -204
|
@@ -21,11 +21,18 @@ module GemContribute
|
|
|
21
21
|
clone_root Directory where forks are cloned. Set with
|
|
22
22
|
`gem-contribute init` (interactive) or
|
|
23
23
|
`gem-contribute config set clone_root <path>`.
|
|
24
|
+
editor Editor command for `fix -e`. Falls back to $EDITOR.
|
|
25
|
+
Example: gem-contribute config set editor code
|
|
26
|
+
ai_tool Shell command for `fix -a` (run in clone dir).
|
|
27
|
+
Example: gem-contribute config set ai_tool "claude ."
|
|
28
|
+
comment_on_fix Whether `fix` posts a "working on this" comment.
|
|
29
|
+
Default: true. Per-repo overrides via
|
|
30
|
+
`comment_on_fix_overrides` in the YAML.
|
|
24
31
|
USAGE
|
|
25
32
|
|
|
26
|
-
def initialize(stdout: $stdout, stderr: $stderr,
|
|
27
|
-
|
|
28
|
-
@
|
|
33
|
+
def initialize(stdout: $stdout, stderr: $stderr, output: nil,
|
|
34
|
+
config: GemContribute::Config.new)
|
|
35
|
+
@output = output || Output::Standard.new(out: stdout, err: stderr)
|
|
29
36
|
@config = config
|
|
30
37
|
end
|
|
31
38
|
|
|
@@ -35,11 +42,11 @@ module GemContribute
|
|
|
35
42
|
when "get" then get(argv)
|
|
36
43
|
when "list" then list
|
|
37
44
|
when nil, "help", "-h", "--help"
|
|
38
|
-
@
|
|
45
|
+
@output.info(USAGE)
|
|
39
46
|
0
|
|
40
47
|
else
|
|
41
|
-
@
|
|
42
|
-
@
|
|
48
|
+
@output.error("gem-contribute: unknown config subcommand")
|
|
49
|
+
@output.error(USAGE)
|
|
43
50
|
2
|
|
44
51
|
end
|
|
45
52
|
end
|
|
@@ -50,38 +57,45 @@ module GemContribute
|
|
|
50
57
|
key = argv.shift
|
|
51
58
|
value = argv.shift
|
|
52
59
|
if key.nil? || value.nil?
|
|
53
|
-
@
|
|
60
|
+
@output.error("Usage: gem-contribute config set <key> <value>")
|
|
54
61
|
return 2
|
|
55
62
|
end
|
|
56
63
|
|
|
57
64
|
@config.set(key, value)
|
|
58
|
-
@
|
|
65
|
+
@output.info("#{key} = #{value}")
|
|
59
66
|
0
|
|
60
67
|
rescue ArgumentError => e
|
|
61
|
-
@
|
|
68
|
+
@output.error(e.message)
|
|
62
69
|
1
|
|
63
70
|
end
|
|
64
71
|
|
|
65
72
|
def get(argv)
|
|
66
73
|
key = argv.shift
|
|
67
74
|
if key.nil?
|
|
68
|
-
@
|
|
75
|
+
@output.error("Usage: gem-contribute config get <key>")
|
|
69
76
|
return 2
|
|
70
77
|
end
|
|
71
78
|
|
|
72
79
|
unless GemContribute::Config::KNOWN_KEYS.include?(key)
|
|
73
|
-
@
|
|
80
|
+
@output.error("unknown config key #{key.inspect}")
|
|
74
81
|
return 1
|
|
75
82
|
end
|
|
76
83
|
|
|
77
|
-
@
|
|
84
|
+
@output.info(@config.to_h.fetch(key, "(not set; run `gem-contribute init`)"))
|
|
78
85
|
0
|
|
79
86
|
end
|
|
80
87
|
|
|
81
88
|
def list
|
|
82
|
-
@
|
|
83
|
-
|
|
84
|
-
@
|
|
89
|
+
@output.info("Configuration (#{GemContribute::Config.default_path}):")
|
|
90
|
+
@output.info(" clone_root = #{@config.clone_root || "(not set; run `gem-contribute init`)"}")
|
|
91
|
+
@output.info(" editor = #{@config.editor || "(not set)"}")
|
|
92
|
+
@output.info(" ai_tool = #{@config.ai_tool || "(not set)"}")
|
|
93
|
+
@output.info(" comment_on_fix = #{@config.comment_on_fix?}")
|
|
94
|
+
overrides = @config.to_h["comment_on_fix_overrides"]
|
|
95
|
+
if overrides.is_a?(Hash) && !overrides.empty?
|
|
96
|
+
@output.info(" comment_on_fix_overrides:")
|
|
97
|
+
overrides.each { |repo, val| @output.info(" #{repo}: #{val}") }
|
|
98
|
+
end
|
|
85
99
|
0
|
|
86
100
|
end
|
|
87
101
|
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/initializer"
|
|
4
|
+
require "dry/monads"
|
|
5
|
+
|
|
6
|
+
module GemContribute
|
|
7
|
+
module CLI
|
|
8
|
+
# `gem-contribute fix <gem>/<issue#> [-e] [-a] [--no-comment]`
|
|
9
|
+
#
|
|
10
|
+
# The issue-tied path: run `Operations::FixPipeline` (Fork → Clone →
|
|
11
|
+
# Branch → Announce), then optionally open the user's editor or AI
|
|
12
|
+
# tool. The verb is a thin Result-pattern-matching shell around the
|
|
13
|
+
# pipeline (per ADR-0012).
|
|
14
|
+
class Fix
|
|
15
|
+
extend Dry::Initializer
|
|
16
|
+
include Workflow
|
|
17
|
+
include Dry::Monads[:result]
|
|
18
|
+
|
|
19
|
+
DEFAULT_CLONE_ROOT = File.expand_path("~/code/oss")
|
|
20
|
+
|
|
21
|
+
option :stdout, default: -> { $stdout }
|
|
22
|
+
option :stderr, default: -> { $stderr }
|
|
23
|
+
option :output, default: -> { Output::Standard.new(out: stdout, err: stderr) }
|
|
24
|
+
option :resolver, default: -> { Resolver.new }
|
|
25
|
+
option :store, default: -> { TokenStore.new }
|
|
26
|
+
option :adapter_factory,
|
|
27
|
+
default: -> { ->(token:) { HostAdapters::GitHubAdapter.new(token: token) } }
|
|
28
|
+
option :git, default: -> { GemContribute::Git.new }
|
|
29
|
+
option :clone_root, default: -> { DEFAULT_CLONE_ROOT }
|
|
30
|
+
option :post_clone_hooks, default: -> { PostCloneHooks.new(output: output) }
|
|
31
|
+
option :config, default: -> { GemContribute::Config.new }
|
|
32
|
+
option :pipeline, default: -> { Operations::FixPipeline.new(git: git) }
|
|
33
|
+
|
|
34
|
+
def run(argv)
|
|
35
|
+
return missing_clone_root if @clone_root.nil?
|
|
36
|
+
|
|
37
|
+
target, flags = parse_argv(argv)
|
|
38
|
+
return print_usage_error if target.nil? || !target.include?("/")
|
|
39
|
+
|
|
40
|
+
gem_name, issue = target.split("/", 2)
|
|
41
|
+
|
|
42
|
+
case build_adapter
|
|
43
|
+
in Success(adapter)
|
|
44
|
+
project = resolve_target(gem_name, verb: "fix")
|
|
45
|
+
return 1 if project.nil?
|
|
46
|
+
|
|
47
|
+
execute(adapter, project, issue, flags)
|
|
48
|
+
in Failure(:unauthenticated)
|
|
49
|
+
@output.error("Not authenticated. Run `gem-contribute auth login` first.")
|
|
50
|
+
1
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def parse_argv(argv)
|
|
57
|
+
flags = { editor: false, ai_tool: false, no_comment: false }
|
|
58
|
+
positional = []
|
|
59
|
+
argv.each do |arg|
|
|
60
|
+
case arg
|
|
61
|
+
when "-e", "--editor" then flags[:editor] = true
|
|
62
|
+
when "-a", "--ai" then flags[:ai_tool] = true
|
|
63
|
+
when "--no-comment" then flags[:no_comment] = true
|
|
64
|
+
else positional << arg
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
[positional.first, flags]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def print_usage_error
|
|
71
|
+
@output.error("Usage: gem-contribute fix <gem>/<issue#> [-e] [-a]")
|
|
72
|
+
2
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def execute(adapter, project, issue, flags)
|
|
76
|
+
allow_announce = !flags[:no_comment] &&
|
|
77
|
+
@config.comment_on_fix?("#{project.owner}/#{project.repo}")
|
|
78
|
+
|
|
79
|
+
result = @output.progress("Forking #{project.owner}/#{project.repo}...") do
|
|
80
|
+
@pipeline.call(adapter: adapter, project: project, issue: issue,
|
|
81
|
+
root: @clone_root, allow_announce: allow_announce)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
case result
|
|
85
|
+
in Success(fork: fork_data, clone: clone_data, branch: branch_data, announce: announce_data)
|
|
86
|
+
print_summary(clone_data.path, branch_data.name, fork_data)
|
|
87
|
+
print_announce_outcome(announce_data, issue)
|
|
88
|
+
@post_clone_hooks.call(clone_data.path, editor: flags[:editor], ai_tool: flags[:ai_tool])
|
|
89
|
+
0
|
|
90
|
+
in Failure(:unauthenticated)
|
|
91
|
+
@output.error("Not authenticated. Run `gem-contribute auth login` first.")
|
|
92
|
+
1
|
|
93
|
+
in Failure(:adapter_error, message)
|
|
94
|
+
@output.error("fix failed: #{message}")
|
|
95
|
+
1
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def print_summary(local_path, branch_name, fork_info)
|
|
100
|
+
@output.info("Forked, cloned, and branched.")
|
|
101
|
+
@output.info(" path: #{local_path}")
|
|
102
|
+
@output.info(" branch: #{branch_name}")
|
|
103
|
+
@output.info(" upstream: #{fork_info.upstream_url}")
|
|
104
|
+
@output.info(" fork: #{fork_info.fork_url}")
|
|
105
|
+
@output.info("")
|
|
106
|
+
@output.info("Next: cd #{local_path} && make your changes, then `gem-contribute submit`.")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def print_announce_outcome(announce_result, issue)
|
|
110
|
+
case announce_result
|
|
111
|
+
in Success(:posted)
|
|
112
|
+
@output.info("Posted 'working on this' comment to issue ##{issue}.")
|
|
113
|
+
in Success(:skipped)
|
|
114
|
+
# no output for skipped — quiet success
|
|
115
|
+
in Failure(:announce_failed, message)
|
|
116
|
+
@output.warn("Note: couldn't post 'working on this' comment to issue ##{issue}: " \
|
|
117
|
+
"#{message}. Continuing.")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/initializer"
|
|
4
|
+
require "dry/monads"
|
|
5
|
+
|
|
6
|
+
module GemContribute
|
|
7
|
+
module CLI
|
|
8
|
+
# `gem-contribute fork <gem|owner/repo> [-e] [-a]`. Resolve the target,
|
|
9
|
+
# bootstrap a fork+clone via `Operations::Fork` + `Operations::Clone`,
|
|
10
|
+
# print a summary, run post-clone hooks. The CLI verb is a thin
|
|
11
|
+
# composition; the host-API ceremony lives in the adapter and the
|
|
12
|
+
# filesystem policy lives in Operations (ADR-0011). Operations are
|
|
13
|
+
# output-free per ADR-0012; this verb does the printing.
|
|
14
|
+
class Fork
|
|
15
|
+
extend Dry::Initializer
|
|
16
|
+
include Workflow
|
|
17
|
+
include Dry::Monads[:result]
|
|
18
|
+
|
|
19
|
+
DEFAULT_CLONE_ROOT = File.expand_path("~/code/oss")
|
|
20
|
+
|
|
21
|
+
option :stdout, default: -> { $stdout }
|
|
22
|
+
option :stderr, default: -> { $stderr }
|
|
23
|
+
option :output, default: -> { Output::Standard.new(out: stdout, err: stderr) }
|
|
24
|
+
option :resolver, default: -> { Resolver.new }
|
|
25
|
+
option :store, default: -> { TokenStore.new }
|
|
26
|
+
option :adapter_factory,
|
|
27
|
+
default: -> { ->(token:) { HostAdapters::GitHubAdapter.new(token: token) } }
|
|
28
|
+
option :git, default: -> { GemContribute::Git.new }
|
|
29
|
+
option :clone_root, default: -> { DEFAULT_CLONE_ROOT }
|
|
30
|
+
option :post_clone_hooks, default: -> { PostCloneHooks.new(output: output) }
|
|
31
|
+
option :fork_op, default: -> { Operations::Fork.new }
|
|
32
|
+
option :clone_op, default: -> { Operations::Clone.new(git: git) }
|
|
33
|
+
|
|
34
|
+
def run(argv)
|
|
35
|
+
return missing_clone_root if @clone_root.nil?
|
|
36
|
+
|
|
37
|
+
target, flags = parse_argv(argv)
|
|
38
|
+
return print_usage_error if target.nil?
|
|
39
|
+
|
|
40
|
+
case build_adapter
|
|
41
|
+
in Success(adapter)
|
|
42
|
+
project = resolve_target(target, verb: "fork", allow_owner_repo: true)
|
|
43
|
+
return 1 if project.nil?
|
|
44
|
+
|
|
45
|
+
execute(adapter, project, flags)
|
|
46
|
+
in Failure(:unauthenticated)
|
|
47
|
+
@output.error("Not authenticated. Run `gem-contribute auth login` first.")
|
|
48
|
+
1
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The bootstrap primitive Fix shares: fork (or reuse) → clone (or
|
|
53
|
+
# reuse) → upstream remote. Returns `Success([local_path, fork_info])`
|
|
54
|
+
# on the happy path; `Failure(reason)` propagated from Operations
|
|
55
|
+
# otherwise.
|
|
56
|
+
def bootstrap(adapter, project)
|
|
57
|
+
@output.progress("Forking #{project.owner}/#{project.repo}...")
|
|
58
|
+
fork_result = @fork_op.call(adapter: adapter, project: project)
|
|
59
|
+
return fork_result if fork_result.failure?
|
|
60
|
+
|
|
61
|
+
fork_info = fork_result.value!
|
|
62
|
+
@output.info(fork_status_line(fork_info, project))
|
|
63
|
+
|
|
64
|
+
clone_result = @clone_op.call(adapter: adapter, project: project,
|
|
65
|
+
fork_clone_url: fork_info.clone_url, root: @clone_root)
|
|
66
|
+
return clone_result if clone_result.failure?
|
|
67
|
+
|
|
68
|
+
clone_info = clone_result.value!
|
|
69
|
+
@output.info(clone_status_line(clone_info))
|
|
70
|
+
|
|
71
|
+
Success([clone_info.path, fork_info])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def parse_argv(argv)
|
|
77
|
+
flags = { editor: false, ai_tool: false }
|
|
78
|
+
positional = []
|
|
79
|
+
argv.each do |arg|
|
|
80
|
+
case arg
|
|
81
|
+
when "-e", "--editor" then flags[:editor] = true
|
|
82
|
+
when "-a", "--ai" then flags[:ai_tool] = true
|
|
83
|
+
else positional << arg
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
[positional.first, flags]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def print_usage_error
|
|
90
|
+
@output.error("Usage: gem-contribute fork <gem|owner/repo> [-e] [-a]")
|
|
91
|
+
2
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def execute(adapter, project, flags)
|
|
95
|
+
case bootstrap(adapter, project)
|
|
96
|
+
in Success(local_path, fork_info)
|
|
97
|
+
print_summary(local_path, project, fork_info, flags)
|
|
98
|
+
@post_clone_hooks.call(local_path, editor: flags[:editor], ai_tool: flags[:ai_tool])
|
|
99
|
+
0
|
|
100
|
+
in Failure(:unauthenticated)
|
|
101
|
+
@output.error("Not authenticated. Run `gem-contribute auth login` first.")
|
|
102
|
+
1
|
|
103
|
+
in Failure(:adapter_error, message)
|
|
104
|
+
@output.error("fork failed: #{message}")
|
|
105
|
+
1
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def fork_status_line(info, project)
|
|
110
|
+
if info.reused
|
|
111
|
+
" Reusing existing fork at #{info.viewer}/#{project.repo}."
|
|
112
|
+
else
|
|
113
|
+
" Forked → #{info.viewer}/#{project.repo}."
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def clone_status_line(info)
|
|
118
|
+
if info.reused
|
|
119
|
+
"Reusing existing clone at #{info.path}."
|
|
120
|
+
else
|
|
121
|
+
"Cloned into #{info.path}."
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def print_summary(local_path, project, fork_info, flags)
|
|
126
|
+
@output.info("Forked and cloned. You're on the default branch.")
|
|
127
|
+
@output.info(" path: #{local_path}")
|
|
128
|
+
@output.info(" upstream: #{fork_info.upstream_url}")
|
|
129
|
+
@output.info(" fork: #{fork_info.fork_url}")
|
|
130
|
+
@output.info("")
|
|
131
|
+
@output.info(next_hint(local_path, project, flags))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def next_hint(local_path, project, flags)
|
|
135
|
+
fix_command = "`gem-contribute fix #{project.gem_name}/<issue#>`"
|
|
136
|
+
if flags[:editor] || flags[:ai_tool]
|
|
137
|
+
"Next: pick an issue and run #{fix_command} to branch off the default."
|
|
138
|
+
else
|
|
139
|
+
"Next: cd #{local_path} && $EDITOR . " \
|
|
140
|
+
"When you pick an issue, #{fix_command} branches off the default."
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "tty-prompt"
|
|
4
|
+
|
|
3
5
|
module GemContribute
|
|
4
6
|
module CLI
|
|
5
7
|
# `gem-contribute init` — interactive one-time setup. Writes the user's
|
|
@@ -8,6 +10,10 @@ module GemContribute
|
|
|
8
10
|
#
|
|
9
11
|
# Without init, `fix` errors with a hint to run init. The point is to
|
|
10
12
|
# avoid creating directories or assuming auth without explicit consent.
|
|
13
|
+
#
|
|
14
|
+
# Prompt input/output goes through `TTY::Prompt` (per ADR-0012 Phase 2,
|
|
15
|
+
# commit #31). The injected `prompt:` keyword lets tests pass a
|
|
16
|
+
# `TTY::Prompt.new(input:, output:)` with StringIO streams.
|
|
11
17
|
class Init
|
|
12
18
|
USAGE = <<~USAGE
|
|
13
19
|
Usage: gem-contribute init
|
|
@@ -20,17 +26,16 @@ module GemContribute
|
|
|
20
26
|
DEFAULT_SUGGESTION = "~/code/oss"
|
|
21
27
|
AUTH_HOST = "github.com"
|
|
22
28
|
|
|
23
|
-
def initialize(stdout: $stdout, stderr: $stderr,
|
|
29
|
+
def initialize(stdout: $stdout, stderr: $stderr, output: nil,
|
|
24
30
|
config: GemContribute::Config.new,
|
|
25
31
|
store: GemContribute::TokenStore.new,
|
|
26
32
|
auth: nil,
|
|
27
|
-
|
|
28
|
-
@
|
|
29
|
-
@stderr = stderr
|
|
33
|
+
prompt: nil)
|
|
34
|
+
@output = output || Output::Standard.new(out: stdout, err: stderr)
|
|
30
35
|
@config = config
|
|
31
36
|
@store = store
|
|
32
|
-
@auth = auth || GemContribute::CLI::Auth.new(
|
|
33
|
-
@
|
|
37
|
+
@auth = auth || GemContribute::CLI::Auth.new(output: @output, store: store)
|
|
38
|
+
@prompt = prompt || TTY::Prompt.new
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
def run(argv)
|
|
@@ -46,36 +51,26 @@ module GemContribute
|
|
|
46
51
|
def prompt_clone_root
|
|
47
52
|
current = @config.to_h["clone_root"]
|
|
48
53
|
default = current || DEFAULT_SUGGESTION
|
|
49
|
-
|
|
50
|
-
@stdout.print "Where should I clone repos? [#{default}]: "
|
|
51
|
-
@stdout.flush
|
|
52
|
-
input = @gets.call.to_s.chomp.strip
|
|
53
|
-
chosen = input.empty? ? default : input
|
|
54
|
-
|
|
54
|
+
chosen = @prompt.ask("Where should I clone repos?", default: default)
|
|
55
55
|
@config.set("clone_root", chosen)
|
|
56
|
-
@
|
|
56
|
+
@output.info("Clone root set to #{File.expand_path(chosen)}")
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def maybe_authenticate
|
|
60
60
|
if @store.token_for(AUTH_HOST)
|
|
61
|
-
@
|
|
61
|
+
@output.info("GitHub: already authenticated.")
|
|
62
62
|
return
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
@
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if %w[n no].include?(answer)
|
|
70
|
-
@stdout.puts "Skipping auth. Run `gem-contribute auth login` when you're ready."
|
|
71
|
-
return
|
|
65
|
+
if @prompt.yes?("Authenticate with GitHub now?", default: true)
|
|
66
|
+
@auth.run(["login"])
|
|
67
|
+
else
|
|
68
|
+
@output.info("Skipping auth. Run `gem-contribute auth login` when you're ready.")
|
|
72
69
|
end
|
|
73
|
-
|
|
74
|
-
@auth.run(["login"])
|
|
75
70
|
end
|
|
76
71
|
|
|
77
72
|
def print_usage
|
|
78
|
-
@
|
|
73
|
+
@output.info(USAGE)
|
|
79
74
|
0
|
|
80
75
|
end
|
|
81
76
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemContribute
|
|
4
|
+
module CLI
|
|
5
|
+
# CLI-side helpers for the "I'm working on this" claim machinery.
|
|
6
|
+
#
|
|
7
|
+
# The actual posting (and gating) lives in `Operations::Announce`
|
|
8
|
+
# (output-free, Result-returning) — see ADR-0012. What remains here
|
|
9
|
+
# is the index-fetching used by `scan` and `issues` to flag claimed
|
|
10
|
+
# issues in their output. Both halves share `Operations::Announce::WORKING_MARKER`.
|
|
11
|
+
module IssueAnnouncer
|
|
12
|
+
MARKER = Operations::Announce::WORKING_MARKER
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Builds a lookup hash of {"owner/repo" => Set<issue_number>} from
|
|
17
|
+
# GitHub's issue search for our marker. Used by `scan` and `issues`
|
|
18
|
+
# to flag claimed issues. One search call per process (the adapter
|
|
19
|
+
# caches the result for the issues TTL). Degrades to an empty hash
|
|
20
|
+
# if the search fails (anonymous rate limits, network, etc.).
|
|
21
|
+
def fetch_claim_index(adapter)
|
|
22
|
+
items = adapter.search_issues("\"#{MARKER}\" is:issue is:open")
|
|
23
|
+
items.each_with_object(Hash.new { |h, k| h[k] = [] }) do |item, index|
|
|
24
|
+
parsed = parse_issue_url(item["html_url"])
|
|
25
|
+
next unless parsed
|
|
26
|
+
|
|
27
|
+
owner, repo, number = parsed
|
|
28
|
+
index["#{owner}/#{repo}"] << number
|
|
29
|
+
end
|
|
30
|
+
rescue GemContribute::AdapterError, GemContribute::AuthRequired
|
|
31
|
+
{}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_issue_url(url)
|
|
35
|
+
match = url.to_s.match(%r{github\.com/([^/]+)/([^/]+)/issues/(\d+)})
|
|
36
|
+
return nil unless match
|
|
37
|
+
|
|
38
|
+
[match[1], match[2], match[3].to_i]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -8,16 +8,17 @@ module GemContribute
|
|
|
8
8
|
# With "all": iterates every github.com gem in Gemfile.lock.
|
|
9
9
|
#
|
|
10
10
|
# Issue numbers appear prominently so they can be passed directly to
|
|
11
|
-
# `
|
|
11
|
+
# `fix <gem>/<issue#>`.
|
|
12
12
|
class Issues
|
|
13
|
+
include Workflow
|
|
14
|
+
|
|
13
15
|
DEFAULT_LABEL = "good first issue"
|
|
14
16
|
|
|
15
|
-
def initialize(stdout: $stdout, stderr: $stderr,
|
|
17
|
+
def initialize(stdout: $stdout, stderr: $stderr, output: nil,
|
|
16
18
|
resolver: Resolver.new,
|
|
17
19
|
adapter: HostAdapters::GitHubAdapter.new,
|
|
18
20
|
lockfile_path: "Gemfile.lock")
|
|
19
|
-
@
|
|
20
|
-
@stderr = stderr
|
|
21
|
+
@output = output || Output::Standard.new(out: stdout, err: stderr)
|
|
21
22
|
@resolver = resolver
|
|
22
23
|
@adapter = adapter
|
|
23
24
|
@lockfile_path = lockfile_path
|
|
@@ -27,30 +28,29 @@ module GemContribute
|
|
|
27
28
|
target = argv.shift
|
|
28
29
|
return print_usage if target.nil?
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
project = resolve_or_fail(target)
|
|
34
|
-
if project.nil?
|
|
35
|
-
1
|
|
36
|
-
else
|
|
37
|
-
list_issues(project)
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
RateLimitFooter.print(adapter: @adapter, stdout: @stdout)
|
|
31
|
+
@claim_index = IssueAnnouncer.fetch_claim_index(@adapter)
|
|
32
|
+
status = target == "all" ? run_all : run_single(target)
|
|
33
|
+
RateLimitFooter.print(adapter: @adapter, output: @output)
|
|
41
34
|
status
|
|
42
35
|
rescue AdapterError => e
|
|
43
|
-
@
|
|
36
|
+
@output.error("gem-contribute: #{e.message}")
|
|
44
37
|
1
|
|
45
38
|
end
|
|
46
39
|
|
|
47
40
|
private
|
|
48
41
|
|
|
49
42
|
def print_usage
|
|
50
|
-
@
|
|
43
|
+
@output.error("Usage: gem-contribute issues <gem|all>")
|
|
51
44
|
2
|
|
52
45
|
end
|
|
53
46
|
|
|
47
|
+
def run_single(target)
|
|
48
|
+
project = resolve_target(target, verb: "issues")
|
|
49
|
+
return 1 if project.nil?
|
|
50
|
+
|
|
51
|
+
list_issues(project)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
54
|
def run_all
|
|
55
55
|
gems = LockfileParser.parse(@lockfile_path)
|
|
56
56
|
projects = gems.filter_map do |gem|
|
|
@@ -58,7 +58,7 @@ module GemContribute
|
|
|
58
58
|
project if project.host == "github.com"
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
@
|
|
61
|
+
@output.info("Scanning #{projects.size} github.com gems from #{@lockfile_path}...\n")
|
|
62
62
|
|
|
63
63
|
any = false
|
|
64
64
|
projects.each do |project|
|
|
@@ -69,57 +69,46 @@ module GemContribute
|
|
|
69
69
|
print_project_issues(project, issues)
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
@
|
|
72
|
+
@output.info("(no good first issues found across #{projects.size} gems)") unless any
|
|
73
73
|
0
|
|
74
74
|
rescue LockfileNotFound => e
|
|
75
|
-
@
|
|
75
|
+
@output.error("gem-contribute: #{e.message}")
|
|
76
76
|
1
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def fetch_issues(project)
|
|
80
80
|
@adapter.issues(project, labels: [DEFAULT_LABEL])
|
|
81
81
|
rescue AdapterError => e
|
|
82
|
-
@
|
|
82
|
+
@output.warn(" warning: #{project.gem_name}: #{e.message}")
|
|
83
83
|
[]
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
def resolve_or_fail(gem_name)
|
|
87
|
-
# gem-contribute isn't on RubyGems yet; short-circuit to the canonical
|
|
88
|
-
# self-project so the tool's own issues are reachable today.
|
|
89
|
-
return GemContribute::SELF_PROJECT if gem_name == GemContribute::SELF_PROJECT.gem_name
|
|
90
|
-
|
|
91
|
-
gem = LockedGem.new(name: gem_name, version: "*",
|
|
92
|
-
source_type: :rubygems, source_uri: "https://rubygems.org/")
|
|
93
|
-
project = @resolver.resolve(gem)
|
|
94
|
-
|
|
95
|
-
if project.host != "github.com"
|
|
96
|
-
@stderr.puts "#{gem_name}: resolves to #{project.host} (only github.com is supported)"
|
|
97
|
-
return nil
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
project
|
|
101
|
-
end
|
|
102
|
-
|
|
103
86
|
def list_issues(project)
|
|
104
87
|
issues = @adapter.issues(project, labels: [DEFAULT_LABEL])
|
|
105
88
|
print_project_issues(project, issues)
|
|
106
|
-
@
|
|
89
|
+
@output.info("To contribute: gem-contribute fix #{project.gem_name}/<issue#>")
|
|
107
90
|
0
|
|
108
91
|
end
|
|
109
92
|
|
|
110
93
|
def print_project_issues(project, issues)
|
|
111
94
|
repo_url = "https://github.com/#{project.owner}/#{project.repo}"
|
|
112
|
-
@
|
|
95
|
+
@output.info("#{project.gem_name} — #{issues.size} open \"#{DEFAULT_LABEL}\" issues (#{repo_url})")
|
|
113
96
|
|
|
114
97
|
if issues.empty?
|
|
115
|
-
@
|
|
98
|
+
@output.info(" (none — browse #{repo_url}/issues directly)")
|
|
116
99
|
else
|
|
117
|
-
@
|
|
118
|
-
issues
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
100
|
+
@output.info("")
|
|
101
|
+
print_issue_list(project, issues)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def print_issue_list(project, issues)
|
|
106
|
+
claimed = @claim_index["#{project.owner}/#{project.repo}"] || []
|
|
107
|
+
issues.each do |issue|
|
|
108
|
+
label = claimed.include?(issue["number"]) ? "[claimed] " : ""
|
|
109
|
+
@output.info(" ##{issue["number"]} #{label}#{issue["title"]}")
|
|
110
|
+
@output.info(" #{issue["html_url"]}")
|
|
111
|
+
@output.info("")
|
|
123
112
|
end
|
|
124
113
|
end
|
|
125
114
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemContribute
|
|
4
|
+
module CLI
|
|
5
|
+
module PlatformTools
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def default_browser_opener(uri)
|
|
9
|
+
cmd = case RbConfig::CONFIG["host_os"]
|
|
10
|
+
when /darwin/ then "open"
|
|
11
|
+
when /linux/ then "xdg-open"
|
|
12
|
+
when /mswin|mingw|cygwin/ then "start"
|
|
13
|
+
end
|
|
14
|
+
cmd && Kernel.system(cmd, uri)
|
|
15
|
+
rescue StandardError
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def default_clipper(text)
|
|
20
|
+
cmd = case RbConfig::CONFIG["host_os"]
|
|
21
|
+
when /darwin/ then "pbcopy"
|
|
22
|
+
when /linux/ then ["xclip", "-selection", "clipboard"]
|
|
23
|
+
end
|
|
24
|
+
return false unless cmd
|
|
25
|
+
|
|
26
|
+
IO.popen(cmd, "w") { |p| p.write(text) }
|
|
27
|
+
true
|
|
28
|
+
rescue StandardError
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|