gem-contribute 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/ISSUE_TEMPLATE/workshop-issue.md +29 -0
- data/.github/workflows/auto-merge-kicked-tires.yml +88 -0
- data/CHANGELOG.md +24 -0
- data/CLAUDE.md +47 -0
- data/CONTRIBUTING.md +46 -0
- data/KICKED_THE_TIRES.yml +22 -0
- data/LICENSE +21 -0
- data/MAINTAINER.md +92 -0
- data/README.md +89 -0
- data/Rakefile +10 -0
- data/docs/_config.yml +30 -0
- data/docs/adr/0001-just-in-time-auth.md +44 -0
- data/docs/adr/0002-bundler-lockfile-parser.md +35 -0
- data/docs/adr/0003-issue-tracker-preference.md +33 -0
- data/docs/adr/0004-device-flow-auth.md +36 -0
- data/docs/adr/0005-render-labels-verbatim.md +46 -0
- data/docs/adr/0006-standalone-gem-not-plugin.md +31 -0
- data/docs/adr/0007-display-contributing-verbatim.md +39 -0
- data/docs/adr/0008-rooibos-tui-framework.md +62 -0
- data/docs/adr/0009-top-level-namespace.md +37 -0
- data/docs/adr/README.md +21 -0
- data/docs/claude-code-prompt.md +40 -0
- data/docs/design.md +234 -0
- data/docs/index.md +102 -0
- data/docs/prep-plan.md +165 -0
- data/docs/workshop.md +60 -0
- data/exe/gem-contribute +7 -0
- data/lib/gem_contribute/auth.rb +161 -0
- data/lib/gem_contribute/cache.rb +98 -0
- data/lib/gem_contribute/cli/auth.rb +164 -0
- data/lib/gem_contribute/cli/config.rb +87 -0
- data/lib/gem_contribute/cli/fork_clone_branch.rb +197 -0
- data/lib/gem_contribute/cli/issues.rb +123 -0
- data/lib/gem_contribute/cli/scan.rb +117 -0
- data/lib/gem_contribute/cli/submit.rb +155 -0
- data/lib/gem_contribute/cli.rb +104 -0
- data/lib/gem_contribute/config.rb +60 -0
- data/lib/gem_contribute/errors.rb +32 -0
- data/lib/gem_contribute/host_adapter.rb +40 -0
- data/lib/gem_contribute/host_adapters/github_adapter.rb +215 -0
- data/lib/gem_contribute/locked_gem.rb +26 -0
- data/lib/gem_contribute/lockfile_parser.rb +61 -0
- data/lib/gem_contribute/project.rb +21 -0
- data/lib/gem_contribute/resolver.rb +131 -0
- data/lib/gem_contribute/token_store.rb +86 -0
- data/lib/gem_contribute/version.rb +5 -0
- data/lib/gem_contribute.rb +32 -0
- data/script/lint-kicked-tires.rb +76 -0
- data/sig/gem_contribute.rbs +3 -0
- metadata +114 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module GemContribute
|
|
7
|
+
module CLI
|
|
8
|
+
# `gem-contribute submit` — push the current branch to the user's fork and
|
|
9
|
+
# open a pre-filled PR compare page in the browser.
|
|
10
|
+
#
|
|
11
|
+
# Run from inside a clone created by `gem-contribute fix`. Reads:
|
|
12
|
+
# - origin remote → fork owner/repo (where the branch is pushed)
|
|
13
|
+
# - upstream remote → canonical owner/repo (where the PR is filed)
|
|
14
|
+
# - current branch → must match `gem-contribute/issue-<N>`
|
|
15
|
+
#
|
|
16
|
+
# The PR itself is NOT opened via API. We push, then open GitHub's compare
|
|
17
|
+
# page in the browser with title and body pre-filled. This mirrors the
|
|
18
|
+
# `auth login` UX (browser handles the human step) and means the user
|
|
19
|
+
# always reviews the PR text before submitting.
|
|
20
|
+
class Submit
|
|
21
|
+
BRANCH_REGEX = %r{\Agem-contribute/issue-(\d+)\z}
|
|
22
|
+
|
|
23
|
+
def initialize(stdout: $stdout, stderr: $stderr,
|
|
24
|
+
git: Git.new,
|
|
25
|
+
adapter_factory: ->(token:) { HostAdapters::GitHubAdapter.new(token: token) },
|
|
26
|
+
store: TokenStore.new,
|
|
27
|
+
browser_opener: nil,
|
|
28
|
+
working_dir: Dir.pwd)
|
|
29
|
+
@stdout = stdout
|
|
30
|
+
@stderr = stderr
|
|
31
|
+
@git = git
|
|
32
|
+
@adapter_factory = adapter_factory
|
|
33
|
+
@store = store
|
|
34
|
+
@browser_opener = browser_opener || method(:default_browser_opener)
|
|
35
|
+
@working_dir = working_dir
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run(_argv)
|
|
39
|
+
branch = current_branch
|
|
40
|
+
issue_number = parse_issue_number(branch)
|
|
41
|
+
return 1 if issue_number.nil?
|
|
42
|
+
|
|
43
|
+
origin = parse_remote("origin", required: true)
|
|
44
|
+
return 1 if origin.nil?
|
|
45
|
+
|
|
46
|
+
# When the user owns the upstream (e.g. self-dogfooding) there's no
|
|
47
|
+
# separate fork and no `upstream` remote — fall back to origin and
|
|
48
|
+
# build a same-repo PR.
|
|
49
|
+
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
|
|
56
|
+
rescue AdapterError => e
|
|
57
|
+
@stderr.puts "submit failed: #{e.message}"
|
|
58
|
+
1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def current_branch
|
|
64
|
+
# symbolic-ref works even on a fresh branch with no commits;
|
|
65
|
+
# rev-parse --abbrev-ref doesn't.
|
|
66
|
+
out, _err, status = Open3.capture3("git", "-C", @working_dir, "symbolic-ref", "--short", "HEAD")
|
|
67
|
+
raise AdapterError, "not inside a git repository (or HEAD is detached)" unless status.success?
|
|
68
|
+
|
|
69
|
+
out.strip
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_issue_number(branch)
|
|
73
|
+
match = BRANCH_REGEX.match(branch)
|
|
74
|
+
return match[1].to_i if match
|
|
75
|
+
|
|
76
|
+
@stderr.puts "submit: branch #{branch.inspect} doesn't match #{BRANCH_REGEX.source}."
|
|
77
|
+
@stderr.puts "Run `gem-contribute fix <gem>/<issue#>` first to set up the branch."
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parse_remote(name, required:)
|
|
82
|
+
out, _err, status = Open3.capture3("git", "-C", @working_dir, "remote", "get-url", name)
|
|
83
|
+
unless status.success?
|
|
84
|
+
if required
|
|
85
|
+
@stderr.puts "submit: no `#{name}` remote configured. " \
|
|
86
|
+
"Are you inside a git clone?"
|
|
87
|
+
end
|
|
88
|
+
return nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
owner_repo_from_url(out.strip)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Accepts both https://github.com/owner/repo(.git) and git@github.com:owner/repo.git
|
|
95
|
+
def owner_repo_from_url(url)
|
|
96
|
+
if (m = url.match(%r{github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?\z}))
|
|
97
|
+
{ owner: m[1], repo: m[2] }
|
|
98
|
+
else
|
|
99
|
+
@stderr.puts "submit: can't parse GitHub owner/repo from #{url.inspect}"
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def fetch_issue_title(upstream, number)
|
|
105
|
+
token = @store.token_for("github.com")
|
|
106
|
+
adapter = @adapter_factory.call(token: token)
|
|
107
|
+
adapter.issue(upstream[:owner], upstream[:repo], number).fetch("title", nil)
|
|
108
|
+
rescue AdapterError => e
|
|
109
|
+
@stderr.puts "submit: couldn't fetch issue title (#{e.message}). Continuing without it."
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def push_branch(branch)
|
|
114
|
+
@stdout.puts "Pushing #{branch} to origin..."
|
|
115
|
+
@git.push(@working_dir, "origin", branch)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def compare_url(upstream, origin, branch, issue_number, title)
|
|
119
|
+
# GitHub compare URL forms:
|
|
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)}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def open_and_print(url)
|
|
138
|
+
opened = @browser_opener.call(url)
|
|
139
|
+
@stdout.puts opened ? "Opened browser to:" : "Open this URL to file the PR:"
|
|
140
|
+
@stdout.puts " #{url}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def default_browser_opener(uri)
|
|
144
|
+
cmd = case RbConfig::CONFIG["host_os"]
|
|
145
|
+
when /darwin/ then "open"
|
|
146
|
+
when /linux/ then "xdg-open"
|
|
147
|
+
when /mswin|mingw|cygwin/ then "start"
|
|
148
|
+
end
|
|
149
|
+
cmd && Kernel.system(cmd, uri)
|
|
150
|
+
rescue StandardError
|
|
151
|
+
false
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module GemContribute
|
|
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
|
+
USAGE = <<~USAGE
|
|
15
|
+
Usage: gem-contribute <command> [options]
|
|
16
|
+
|
|
17
|
+
Commands:
|
|
18
|
+
scan [path] Summarize the contributable surface of a Gemfile.lock.
|
|
19
|
+
Path defaults to ./Gemfile.lock.
|
|
20
|
+
issues <gem|all> List open "good first issue" issues for a gem (or all gems).
|
|
21
|
+
config set <key> <val> Persist a configuration value.
|
|
22
|
+
config get <key> Print a configuration value.
|
|
23
|
+
config list Print all configuration values.
|
|
24
|
+
auth login Authenticate with GitHub via OAuth device flow.
|
|
25
|
+
auth status Show whether you're authenticated.
|
|
26
|
+
auth logout Remove the cached token for github.com.
|
|
27
|
+
fix <gem>/<issue#> Fork the gem's repo, clone the fork, branch from main.
|
|
28
|
+
(alias: fork-clone-branch)
|
|
29
|
+
submit Push the current branch and open a pre-filled
|
|
30
|
+
PR compare page in the browser. Run from inside
|
|
31
|
+
a clone created by `fix`.
|
|
32
|
+
|
|
33
|
+
Global options:
|
|
34
|
+
--refresh Invalidate caches before running.
|
|
35
|
+
-h, --help Show this help.
|
|
36
|
+
--version Print the version and exit.
|
|
37
|
+
USAGE
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# Entry point for exe/gem-contribute. Returns an integer exit status so the
|
|
42
|
+
# caller can `exit GemContribute::CLI.run(ARGV)`.
|
|
43
|
+
def run(argv, stdout: $stdout, stderr: $stderr)
|
|
44
|
+
argv = argv.dup
|
|
45
|
+
handle_global_flags!(argv, stdout: stdout)
|
|
46
|
+
dispatch(argv.shift, argv, stdout: stdout, stderr: stderr)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def dispatch(command, argv, stdout:, stderr:)
|
|
50
|
+
builder = COMMANDS[command]
|
|
51
|
+
if builder.nil?
|
|
52
|
+
return print_help(stdout) if [nil, "help", "-h", "--help"].include?(command)
|
|
53
|
+
|
|
54
|
+
return unknown_command(command, stderr)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
builder.call(stdout, stderr).run(argv)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
COMMANDS = {
|
|
61
|
+
"scan" => ->(o, e) { Scan.new(stdout: o, stderr: e, adapter: github_adapter) },
|
|
62
|
+
"issues" => ->(o, e) { Issues.new(stdout: o, stderr: e, adapter: github_adapter) },
|
|
63
|
+
"config" => ->(o, e) { Config.new(stdout: o, stderr: e) },
|
|
64
|
+
"auth" => ->(o, e) { Auth.new(stdout: o, stderr: e) },
|
|
65
|
+
"fix" => lambda { |o, e|
|
|
66
|
+
ForkCloneBranch.new(stdout: o, stderr: e,
|
|
67
|
+
clone_root: GemContribute::Config.new.clone_root)
|
|
68
|
+
},
|
|
69
|
+
"fork-clone-branch" => lambda { |o, e|
|
|
70
|
+
ForkCloneBranch.new(stdout: o, stderr: e,
|
|
71
|
+
clone_root: GemContribute::Config.new.clone_root)
|
|
72
|
+
},
|
|
73
|
+
"submit" => ->(o, e) { Submit.new(stdout: o, stderr: e) }
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
def print_help(stdout)
|
|
77
|
+
stdout.puts USAGE
|
|
78
|
+
0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def unknown_command(command, stderr)
|
|
82
|
+
stderr.puts "gem-contribute: unknown command #{command.inspect}"
|
|
83
|
+
stderr.puts USAGE
|
|
84
|
+
2
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def github_adapter
|
|
88
|
+
token = TokenStore.new.token_for("github.com")
|
|
89
|
+
HostAdapters::GitHubAdapter.new(token: token)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def handle_global_flags!(argv, stdout:)
|
|
93
|
+
if argv.include?("--version")
|
|
94
|
+
stdout.puts "gem-contribute #{GemContribute::VERSION}"
|
|
95
|
+
exit 0
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
return unless argv.delete("--refresh")
|
|
99
|
+
|
|
100
|
+
Cache.new.clear!
|
|
101
|
+
stdout.puts "Cache cleared at #{Cache.default_root}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module GemContribute
|
|
7
|
+
# Reads and writes ~/.config/gem-contribute/config.yml.
|
|
8
|
+
# Honors XDG_CONFIG_HOME so tests stay hermetic and unusual layouts work.
|
|
9
|
+
# Missing or corrupt files are treated as an empty config (no crash).
|
|
10
|
+
class Config
|
|
11
|
+
DEFAULT_CLONE_ROOT = File.expand_path("~/code/oss")
|
|
12
|
+
|
|
13
|
+
KNOWN_KEYS = %w[clone_root].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(path: self.class.default_path)
|
|
16
|
+
@path = path
|
|
17
|
+
@data = load_file
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def clone_root
|
|
21
|
+
raw = @data["clone_root"]
|
|
22
|
+
raw ? File.expand_path(raw) : DEFAULT_CLONE_ROOT
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def set(key, value)
|
|
26
|
+
raise ArgumentError, "unknown config key #{key.inspect}. Known keys: #{KNOWN_KEYS.join(", ")}" \
|
|
27
|
+
unless KNOWN_KEYS.include?(key)
|
|
28
|
+
|
|
29
|
+
@data[key] = value
|
|
30
|
+
write_file
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
@data.dup
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.default_path
|
|
38
|
+
base = ENV.fetch("XDG_CONFIG_HOME", File.expand_path("~/.config"))
|
|
39
|
+
File.join(base, "gem-contribute", "config.yml")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def load_file
|
|
45
|
+
return {} unless File.exist?(@path)
|
|
46
|
+
|
|
47
|
+
parsed = YAML.safe_load(File.read(@path, encoding: "UTF-8"))
|
|
48
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
49
|
+
rescue Psych::Exception, Errno::EACCES
|
|
50
|
+
{}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def write_file
|
|
54
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
55
|
+
tmp = "#{@path}.tmp"
|
|
56
|
+
File.write(tmp, YAML.dump(@data), encoding: "UTF-8")
|
|
57
|
+
File.rename(tmp, @path)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemContribute
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class LockfileNotFound < Error; end
|
|
7
|
+
|
|
8
|
+
class LockfileParseError < Error; end
|
|
9
|
+
|
|
10
|
+
class ResolveError < Error
|
|
11
|
+
attr_reader :gem_name
|
|
12
|
+
|
|
13
|
+
def initialize(gem_name, message)
|
|
14
|
+
@gem_name = gem_name
|
|
15
|
+
super("#{gem_name}: #{message}")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class AdapterError < Error; end
|
|
20
|
+
|
|
21
|
+
# Raised by host adapters when an authenticated call is attempted without a
|
|
22
|
+
# cached token for that host. The TUI catches this and triggers device flow;
|
|
23
|
+
# CLI callers print a "run auth login" hint. See ADR-0001.
|
|
24
|
+
class AuthRequired < Error
|
|
25
|
+
attr_reader :host
|
|
26
|
+
|
|
27
|
+
def initialize(host)
|
|
28
|
+
@host = host
|
|
29
|
+
super("authentication required for #{host}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemContribute
|
|
4
|
+
# Abstract host adapter. Concrete implementations (GitHubAdapter, future
|
|
5
|
+
# GitLabAdapter, future CodebergAdapter) conform to this interface so the
|
|
6
|
+
# TUI doesn't have to special-case anything beyond looking up the right
|
|
7
|
+
# adapter for a project's host.
|
|
8
|
+
#
|
|
9
|
+
# Public-API methods (no auth needed):
|
|
10
|
+
# issues(project, labels:)
|
|
11
|
+
# community_profile(project)
|
|
12
|
+
# file_contents(project, path)
|
|
13
|
+
#
|
|
14
|
+
# Auth-required methods (raise AuthRequired without a cached token):
|
|
15
|
+
# fork(project)
|
|
16
|
+
# already_forked?(project)
|
|
17
|
+
#
|
|
18
|
+
# See ADR-0001 for the JIT auth contract.
|
|
19
|
+
class HostAdapter
|
|
20
|
+
def issues(_project, labels: nil)
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def community_profile(_project)
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def file_contents(_project, _path)
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fork(_project)
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def already_forked?(_project)
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module GemContribute
|
|
8
|
+
module HostAdapters
|
|
9
|
+
# GitHub adapter. v0.1 implements the unauthenticated read methods
|
|
10
|
+
# (issues, community_profile, file_contents). The auth-required methods
|
|
11
|
+
# raise AuthRequired so the calling layer (CLI in Stage 2, TUI in Stage 3)
|
|
12
|
+
# can trigger device flow. See ADR-0001 and ADR-0004.
|
|
13
|
+
#
|
|
14
|
+
# `token` is optional and reserved for Stage 2; when present it's sent as
|
|
15
|
+
# `Authorization: Bearer …` to lift the rate limit and unlock fork/etc.
|
|
16
|
+
class GitHubAdapter < HostAdapter
|
|
17
|
+
API_BASE = "https://api.github.com"
|
|
18
|
+
ACCEPT = "application/vnd.github+json"
|
|
19
|
+
API_VERSION = "2022-11-28"
|
|
20
|
+
MAX_REDIRECTS = 3
|
|
21
|
+
|
|
22
|
+
RateLimit = Data.define(:limit, :remaining, :reset_at)
|
|
23
|
+
|
|
24
|
+
attr_reader :rate_limit
|
|
25
|
+
|
|
26
|
+
def initialize(cache: Cache.new, http: Net::HTTP, token: nil)
|
|
27
|
+
super()
|
|
28
|
+
@cache = cache
|
|
29
|
+
@http = http
|
|
30
|
+
@token = token
|
|
31
|
+
@rate_limit = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Hash] a single issue's full payload (uncached — submit only).
|
|
35
|
+
def issue(owner, repo, number)
|
|
36
|
+
ensure_known_host!(Project.new(gem_name: repo, host: "github.com",
|
|
37
|
+
owner: owner, repo: repo, metadata: {}))
|
|
38
|
+
get_json("/repos/#{owner}/#{repo}/issues/#{number}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Array<Hash>] open issues filtered to the given labels (if any)
|
|
42
|
+
def issues(project, labels: nil)
|
|
43
|
+
ensure_known_host!(project)
|
|
44
|
+
|
|
45
|
+
cache_key = issue_cache_key(project, labels)
|
|
46
|
+
cached = @cache.fetch("issues", cache_key)
|
|
47
|
+
return cached if cached
|
|
48
|
+
|
|
49
|
+
params = { state: "open", per_page: 50 }
|
|
50
|
+
params[:labels] = Array(labels).join(",") if labels && !Array(labels).empty?
|
|
51
|
+
body = get_json("/repos/#{project.owner}/#{project.repo}/issues", params)
|
|
52
|
+
|
|
53
|
+
# GitHub's /issues endpoint mixes pull requests in. PRs have a
|
|
54
|
+
# `pull_request` key; filter those out so callers see issues only.
|
|
55
|
+
only_issues = body.reject { |i| i.key?("pull_request") }
|
|
56
|
+
@cache.write("issues", cache_key, only_issues)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def community_profile(project)
|
|
60
|
+
ensure_known_host!(project)
|
|
61
|
+
cache_key = "#{project.owner}/#{project.repo}"
|
|
62
|
+
cached = @cache.fetch("repos", cache_key)
|
|
63
|
+
return cached if cached
|
|
64
|
+
|
|
65
|
+
body = get_json("/repos/#{project.owner}/#{project.repo}/community/profile")
|
|
66
|
+
@cache.write("repos", cache_key, body)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def file_contents(project, path)
|
|
70
|
+
ensure_known_host!(project)
|
|
71
|
+
cache_key = "#{project.owner}/#{project.repo}:#{path}"
|
|
72
|
+
cached = @cache.fetch("files", cache_key)
|
|
73
|
+
return cached if cached
|
|
74
|
+
|
|
75
|
+
body = get_json("/repos/#{project.owner}/#{project.repo}/contents/#{path}")
|
|
76
|
+
@cache.write("files", cache_key, body)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# POST /repos/:owner/:repo/forks. Returns the fork's parsed body
|
|
80
|
+
# (clone_url, owner.login, name, etc.). GitHub responds 202 (accepted)
|
|
81
|
+
# immediately even if the fork is still propagating; callers that need
|
|
82
|
+
# to clone right after may want to poll readiness — see
|
|
83
|
+
# `fork_ready?` below.
|
|
84
|
+
def fork(project)
|
|
85
|
+
raise AuthRequired, "github.com" unless @token
|
|
86
|
+
|
|
87
|
+
ensure_known_host!(project)
|
|
88
|
+
post_json("/repos/#{project.owner}/#{project.repo}/forks")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# GET /repos/:viewer/:repo. True iff the viewer already owns a fork of
|
|
92
|
+
# the upstream repo at the same name.
|
|
93
|
+
def already_forked?(project)
|
|
94
|
+
raise AuthRequired, "github.com" unless @token
|
|
95
|
+
|
|
96
|
+
ensure_known_host!(project)
|
|
97
|
+
viewer = viewer_login
|
|
98
|
+
get_json("/repos/#{viewer}/#{project.repo}")
|
|
99
|
+
true
|
|
100
|
+
rescue AdapterError => e
|
|
101
|
+
return false if e.message.include?("404")
|
|
102
|
+
|
|
103
|
+
raise
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# GET /user. Used by `auth status` and `already_forked?`. Returns the
|
|
107
|
+
# authenticated user's login string (e.g. "cdhagmann").
|
|
108
|
+
def viewer_login
|
|
109
|
+
raise AuthRequired, "github.com" unless @token
|
|
110
|
+
|
|
111
|
+
body = get_json("/user")
|
|
112
|
+
body.fetch("login")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# GET /repos/:viewer/:repo, returning true once GitHub has finished
|
|
116
|
+
# provisioning the fork. The fork endpoint returns 202 immediately;
|
|
117
|
+
# the resource may 404 for a few seconds before becoming live.
|
|
118
|
+
def fork_ready?(viewer, repo_name)
|
|
119
|
+
raise AuthRequired, "github.com" unless @token
|
|
120
|
+
|
|
121
|
+
get_json("/repos/#{viewer}/#{repo_name}")
|
|
122
|
+
true
|
|
123
|
+
rescue AdapterError => e
|
|
124
|
+
return false if e.message.include?("404")
|
|
125
|
+
|
|
126
|
+
raise
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def issue_cache_key(project, labels)
|
|
132
|
+
label_segment = Array(labels).sort.join(",")
|
|
133
|
+
"#{project.owner}/#{project.repo}?labels=#{label_segment}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def ensure_known_host!(project)
|
|
137
|
+
return if project.host == "github.com"
|
|
138
|
+
|
|
139
|
+
raise AdapterError, "GitHubAdapter cannot serve project on host #{project.host.inspect}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def get_json(path, params = {})
|
|
143
|
+
response = http_get(path, params)
|
|
144
|
+
record_rate_limit(response)
|
|
145
|
+
decode_response(response, path)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def post_json(path, body = nil)
|
|
149
|
+
response = http_post(path, body)
|
|
150
|
+
record_rate_limit(response)
|
|
151
|
+
decode_response(response, path)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def http_get(path, params, redirects_remaining: MAX_REDIRECTS)
|
|
155
|
+
url = URI("#{API_BASE}#{path}")
|
|
156
|
+
url.query = URI.encode_www_form(params) unless params.empty?
|
|
157
|
+
response = @http.start(url.host, url.port, use_ssl: true) do |conn|
|
|
158
|
+
conn.get(url.request_uri, request_headers)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if response.is_a?(Net::HTTPMovedPermanently) && redirects_remaining.positive?
|
|
162
|
+
new_path = URI(response["Location"]).path
|
|
163
|
+
return http_get(new_path, params, redirects_remaining: redirects_remaining - 1)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
response
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def http_post(path, body)
|
|
170
|
+
url = URI("#{API_BASE}#{path}")
|
|
171
|
+
@http.start(url.host, url.port, use_ssl: true) do |conn|
|
|
172
|
+
request = Net::HTTP::Post.new(url.request_uri, request_headers.merge("Content-Type" => "application/json"))
|
|
173
|
+
request.body = JSON.dump(body) if body
|
|
174
|
+
conn.request(request)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def decode_response(response, path)
|
|
179
|
+
case response
|
|
180
|
+
when Net::HTTPNoContent then nil
|
|
181
|
+
when Net::HTTPSuccess then JSON.parse(response.body)
|
|
182
|
+
when Net::HTTPUnauthorized, Net::HTTPForbidden
|
|
183
|
+
raise AuthRequired, "github.com" if @token.nil?
|
|
184
|
+
|
|
185
|
+
raise AdapterError, "GitHub returned #{response.code}: #{response.body}"
|
|
186
|
+
else
|
|
187
|
+
raise AdapterError, "GitHub returned #{response.code} for #{path}"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def request_headers
|
|
192
|
+
headers = {
|
|
193
|
+
"Accept" => ACCEPT,
|
|
194
|
+
"User-Agent" => "gem-contribute/#{GemContribute::VERSION}",
|
|
195
|
+
"X-GitHub-Api-Version" => API_VERSION
|
|
196
|
+
}
|
|
197
|
+
headers["Authorization"] = "Bearer #{@token}" if @token
|
|
198
|
+
headers
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def record_rate_limit(response)
|
|
202
|
+
limit = response["X-RateLimit-Limit"]
|
|
203
|
+
remaining = response["X-RateLimit-Remaining"]
|
|
204
|
+
reset = response["X-RateLimit-Reset"]
|
|
205
|
+
return if [limit, remaining, reset].any?(&:nil?)
|
|
206
|
+
|
|
207
|
+
@rate_limit = RateLimit.new(
|
|
208
|
+
limit: limit.to_i,
|
|
209
|
+
remaining: remaining.to_i,
|
|
210
|
+
reset_at: Time.at(reset.to_i)
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemContribute
|
|
4
|
+
# A single dependency parsed from Gemfile.lock.
|
|
5
|
+
#
|
|
6
|
+
# The design doc calls this "a Gem"; the in-code name is LockedGem to avoid
|
|
7
|
+
# shadowing Ruby's stdlib ::Gem inside the GemContribute namespace.
|
|
8
|
+
# See ADR-0009.
|
|
9
|
+
#
|
|
10
|
+
# `source_type` is one of:
|
|
11
|
+
# :rubygems — published to a RubyGems-compatible index
|
|
12
|
+
# :git — `gem 'foo', git: '…'`
|
|
13
|
+
# :path — `gem 'foo', path: '…'`
|
|
14
|
+
# :bundler — Bundler itself (only present in lockfiles via DEPENDENCIES)
|
|
15
|
+
LockedGem = Data.define(:name, :version, :source_type, :source_uri) do
|
|
16
|
+
def rubygems?
|
|
17
|
+
source_type == :rubygems
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resolvable?
|
|
21
|
+
# We can only ask the RubyGems API about things we got from RubyGems.
|
|
22
|
+
# See ADR-0003 for what we do with the answer.
|
|
23
|
+
rubygems?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
|
|
5
|
+
module GemContribute
|
|
6
|
+
# Wraps Bundler::LockfileParser. See ADR-0002.
|
|
7
|
+
#
|
|
8
|
+
# Input: a path to a Gemfile.lock.
|
|
9
|
+
# Output: an Array of LockedGem.
|
|
10
|
+
module LockfileParser
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @param path [String, Pathname] path to a Gemfile.lock
|
|
14
|
+
# @return [Array<LockedGem>]
|
|
15
|
+
def parse(path)
|
|
16
|
+
contents = read_lockfile(path)
|
|
17
|
+
parser = Bundler::LockfileParser.new(contents)
|
|
18
|
+
|
|
19
|
+
parser.specs.map { |spec| build_locked_gem(spec) }
|
|
20
|
+
rescue Bundler::LockfileError => e
|
|
21
|
+
raise LockfileParseError, "could not parse #{path}: #{e.message}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def read_lockfile(path)
|
|
25
|
+
File.read(path)
|
|
26
|
+
rescue Errno::ENOENT
|
|
27
|
+
raise LockfileNotFound, "no Gemfile.lock at #{path}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build_locked_gem(spec)
|
|
31
|
+
LockedGem.new(
|
|
32
|
+
name: spec.name,
|
|
33
|
+
version: spec.version.to_s,
|
|
34
|
+
source_type: classify_source(spec.source),
|
|
35
|
+
source_uri: source_uri(spec.source)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def classify_source(source)
|
|
40
|
+
case source
|
|
41
|
+
when Bundler::Source::Rubygems then :rubygems
|
|
42
|
+
when Bundler::Source::Git then :git
|
|
43
|
+
when Bundler::Source::Path then :path
|
|
44
|
+
else :unknown
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def source_uri(source)
|
|
49
|
+
case source
|
|
50
|
+
when Bundler::Source::Rubygems
|
|
51
|
+
# Bundler::Source::Rubygems can have multiple remotes; pick the first.
|
|
52
|
+
# In practice this is rubygems.org for almost every gem.
|
|
53
|
+
source.remotes.first&.to_s
|
|
54
|
+
when Bundler::Source::Git
|
|
55
|
+
source.uri
|
|
56
|
+
when Bundler::Source::Path
|
|
57
|
+
source.path.to_s
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|