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
|
@@ -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
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-prompt"
|
|
4
|
+
|
|
5
|
+
module GemContribute
|
|
6
|
+
module CLI
|
|
7
|
+
# `gem-contribute init` — interactive one-time setup. Writes the user's
|
|
8
|
+
# `clone_root` to ~/.config/gem-contribute/config.yml and, if no GitHub
|
|
9
|
+
# token is cached, offers to run `auth login`.
|
|
10
|
+
#
|
|
11
|
+
# Without init, `fix` errors with a hint to run init. The point is to
|
|
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.
|
|
17
|
+
class Init
|
|
18
|
+
USAGE = <<~USAGE
|
|
19
|
+
Usage: gem-contribute init
|
|
20
|
+
|
|
21
|
+
Interactively set the directory where forks are cloned (clone_root),
|
|
22
|
+
then offer to authenticate with GitHub if you haven't already.
|
|
23
|
+
Re-run any time to change.
|
|
24
|
+
USAGE
|
|
25
|
+
|
|
26
|
+
DEFAULT_SUGGESTION = "~/code/oss"
|
|
27
|
+
AUTH_HOST = "github.com"
|
|
28
|
+
|
|
29
|
+
def initialize(stdout: $stdout, stderr: $stderr, output: nil,
|
|
30
|
+
config: GemContribute::Config.new,
|
|
31
|
+
store: GemContribute::TokenStore.new,
|
|
32
|
+
auth: nil,
|
|
33
|
+
prompt: nil)
|
|
34
|
+
@output = output || Output::Standard.new(out: stdout, err: stderr)
|
|
35
|
+
@config = config
|
|
36
|
+
@store = store
|
|
37
|
+
@auth = auth || GemContribute::CLI::Auth.new(output: @output, store: store)
|
|
38
|
+
@prompt = prompt || TTY::Prompt.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run(argv)
|
|
42
|
+
return print_usage if %w[help -h --help].include?(argv.first)
|
|
43
|
+
|
|
44
|
+
prompt_clone_root
|
|
45
|
+
maybe_authenticate
|
|
46
|
+
0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def prompt_clone_root
|
|
52
|
+
current = @config.to_h["clone_root"]
|
|
53
|
+
default = current || DEFAULT_SUGGESTION
|
|
54
|
+
chosen = @prompt.ask("Where should I clone repos?", default: default)
|
|
55
|
+
@config.set("clone_root", chosen)
|
|
56
|
+
@output.info("Clone root set to #{File.expand_path(chosen)}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def maybe_authenticate
|
|
60
|
+
if @store.token_for(AUTH_HOST)
|
|
61
|
+
@output.info("GitHub: already authenticated.")
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
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.")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def print_usage
|
|
73
|
+
@output.info(USAGE)
|
|
74
|
+
0
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
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,26 +28,29 @@ module GemContribute
|
|
|
27
28
|
target = argv.shift
|
|
28
29
|
return print_usage if target.nil?
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return 1 if project.nil?
|
|
35
|
-
|
|
36
|
-
list_issues(project)
|
|
37
|
-
end
|
|
31
|
+
@claim_index = IssueAnnouncer.fetch_claim_index(@adapter)
|
|
32
|
+
status = target == "all" ? run_all : run_single(target)
|
|
33
|
+
RateLimitFooter.print(adapter: @adapter, output: @output)
|
|
34
|
+
status
|
|
38
35
|
rescue AdapterError => e
|
|
39
|
-
@
|
|
36
|
+
@output.error("gem-contribute: #{e.message}")
|
|
40
37
|
1
|
|
41
38
|
end
|
|
42
39
|
|
|
43
40
|
private
|
|
44
41
|
|
|
45
42
|
def print_usage
|
|
46
|
-
@
|
|
43
|
+
@output.error("Usage: gem-contribute issues <gem|all>")
|
|
47
44
|
2
|
|
48
45
|
end
|
|
49
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
|
+
|
|
50
54
|
def run_all
|
|
51
55
|
gems = LockfileParser.parse(@lockfile_path)
|
|
52
56
|
projects = gems.filter_map do |gem|
|
|
@@ -54,7 +58,7 @@ module GemContribute
|
|
|
54
58
|
project if project.host == "github.com"
|
|
55
59
|
end
|
|
56
60
|
|
|
57
|
-
@
|
|
61
|
+
@output.info("Scanning #{projects.size} github.com gems from #{@lockfile_path}...\n")
|
|
58
62
|
|
|
59
63
|
any = false
|
|
60
64
|
projects.each do |project|
|
|
@@ -65,57 +69,46 @@ module GemContribute
|
|
|
65
69
|
print_project_issues(project, issues)
|
|
66
70
|
end
|
|
67
71
|
|
|
68
|
-
@
|
|
72
|
+
@output.info("(no good first issues found across #{projects.size} gems)") unless any
|
|
69
73
|
0
|
|
70
74
|
rescue LockfileNotFound => e
|
|
71
|
-
@
|
|
75
|
+
@output.error("gem-contribute: #{e.message}")
|
|
72
76
|
1
|
|
73
77
|
end
|
|
74
78
|
|
|
75
79
|
def fetch_issues(project)
|
|
76
80
|
@adapter.issues(project, labels: [DEFAULT_LABEL])
|
|
77
81
|
rescue AdapterError => e
|
|
78
|
-
@
|
|
82
|
+
@output.warn(" warning: #{project.gem_name}: #{e.message}")
|
|
79
83
|
[]
|
|
80
84
|
end
|
|
81
85
|
|
|
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
86
|
def list_issues(project)
|
|
100
87
|
issues = @adapter.issues(project, labels: [DEFAULT_LABEL])
|
|
101
88
|
print_project_issues(project, issues)
|
|
102
|
-
@
|
|
89
|
+
@output.info("To contribute: gem-contribute fix #{project.gem_name}/<issue#>")
|
|
103
90
|
0
|
|
104
91
|
end
|
|
105
92
|
|
|
106
93
|
def print_project_issues(project, issues)
|
|
107
94
|
repo_url = "https://github.com/#{project.owner}/#{project.repo}"
|
|
108
|
-
@
|
|
95
|
+
@output.info("#{project.gem_name} — #{issues.size} open \"#{DEFAULT_LABEL}\" issues (#{repo_url})")
|
|
109
96
|
|
|
110
97
|
if issues.empty?
|
|
111
|
-
@
|
|
98
|
+
@output.info(" (none — browse #{repo_url}/issues directly)")
|
|
112
99
|
else
|
|
113
|
-
@
|
|
114
|
-
issues
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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("")
|
|
119
112
|
end
|
|
120
113
|
end
|
|
121
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
|
|
@@ -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
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemContribute
|
|
4
|
+
module CLI
|
|
5
|
+
# Prints a one-line GitHub rate-limit footer after `scan` or `issues`
|
|
6
|
+
# finishes its main output, when the adapter has rate-limit data.
|
|
7
|
+
#
|
|
8
|
+
# Format: "GitHub rate limit: 4,587 / 5,000 remaining · resets at 14:32 UTC"
|
|
9
|
+
#
|
|
10
|
+
# When `adapter.rate_limit` is nil (e.g. every call was served from cache),
|
|
11
|
+
# nothing is printed — see #4 acceptance criteria.
|
|
12
|
+
module RateLimitFooter
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param adapter [GemContribute::HostAdapters::GitHubAdapter]
|
|
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)
|
|
20
|
+
rate_limit = adapter.respond_to?(:rate_limit) ? adapter.rate_limit : nil
|
|
21
|
+
return if rate_limit.nil?
|
|
22
|
+
|
|
23
|
+
remaining = format_with_separators(rate_limit.remaining)
|
|
24
|
+
limit = format_with_separators(rate_limit.limit)
|
|
25
|
+
reset = rate_limit.reset_at.utc.strftime("%H:%M")
|
|
26
|
+
output.info("GitHub rate limit: #{remaining} / #{limit} remaining · resets at #{reset} UTC")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def format_with_separators(integer)
|
|
30
|
+
integer.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|