Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions jim.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Gem::Specification.new do |spec|
spec.license = "MIT"
spec.required_ruby_version = ">= 3.2.0"

spec.metadata["github_repo"] = "duckinator/jim"

spec.metadata["allowed_push_host"] = "https://rubygems.pkg.github.com/duckinator"
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = "https://github.com/duckinator/jim"
Expand Down
121 changes: 102 additions & 19 deletions lib/jim/cli.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "fileutils"
require "pathname"
require_relative "build"
require_relative "client"
require_relative "config"
Expand All @@ -10,7 +11,7 @@ module Jim
module Cli
extend Jim::Console

METHODS = %w[signin signout build clean gemspec help pack]
METHODS = %w[signin signout build clean pack release help]

def self.run
ARGV[0] = "help" if %w[--help -h].include?(ARGV[0])
Expand All @@ -24,11 +25,20 @@ def self.run
end

# Pack a gem into a single file.
def self.pack
spec_file, *rest = Dir.glob("*.gemspec")
abort "Found multiple gemspecs: #{spec_file}, #{rest.join(',')}" unless rest.empty?
def self.pack(*args)
opts = SimpleOpts.new(
banner: "Usage: jim pack [--quiet]",
)

spec = Jim.load_spec(spec_file)
opts.simple("--quiet",
"Don't print anything on success",
:quiet)

options, args = opts.parse_with_args(args)

return puts opts if options[:help] || !args.empty?

spec = load_spec_or_abort!

unless spec.executables.length == 1
abort "error: expected only one executable specified in #{spec_file}, found:\n- #{spec.executables.join("\n- ")}"
Expand All @@ -40,7 +50,9 @@ def self.pack
FileUtils.mkdir_p("build/pack")
File.write(filename, contents)

puts filename
puts filename unless options[:quiet]

filename
end

# def self.config(command, setting, value=nil)
Expand Down Expand Up @@ -104,10 +116,14 @@ def self.signout
# Builds a Gem from the provided gemspec.
def self.build(*args)
opts = SimpleOpts.new(
banner: "Usage: jim build [-C PATH] GEMSPEC",
banner: "Usage: jim build [--quiet] [-C PATH] GEMSPEC",
defaults: { path: "." },
)

opts.simple("--quiet",
"Don't print anything on success",
:quiet)

opts.simple("-C PATH",
"Change the current working directory to PATH before building",
:path)
Expand All @@ -120,29 +136,87 @@ def self.build(*args)

return puts opts if options[:help] || args.length > 1

spec = args.shift
if spec.nil?
spec, *rest = Dir.glob("*.gemspec")
abort "Found multiple gemspecs: #{spec}, #{rest.join(',')}" unless rest.empty?
end

spec = Jim.load_spec(spec)
spec = load_spec_or_abort!(args.shift)

out_file = Dir.chdir(options[:path]) { Jim::Build.build(spec) }

puts "Name: #{spec.name}"
puts "Version: #{spec.version}"
puts
puts "Output: #{out_file}"
unless options[:quiet]
puts "Name: #{spec.name}"
puts "Version: #{spec.version}"
puts
puts "Output: #{out_file}"
end

out_file
end

# Clean up build artifacts
# Clean up build/pack artifacts.
def self.clean
FileUtils.rm_r(Jim::Build::BUILD_DIR) if File.exist?(Jim::Build::BUILD_DIR)
end

# Build and release a gem.
def self.release(*args)
opts = SimpleOpts.new(
banner: "Usage: jim release [--pack] [--github] [--host HOST]",
)

opts.simple("--pack",
"When releasing to GitHub, include the packed version.",
:pack)

opts.simple("--github",
"Release to Github.",
:github)

opts.simple("--host HOST",
"Gem host to push to.",
:host)

opts.simple("-h", "--help",
"Show this help message and exit",
:help)

options, args = opts.parse_with_args(args)

return puts opts if options[:help] || !args.empty?

spec = load_spec_or_abort!

packed_file = self.pack("--quiet") if options[:pack]
gem_file = self.build("--quiet")

gh_release =
if options[:github]
token = ENV["JIM_GITHUB_TOKEN"]
abort "error: Expected JIM_GITHUB_TOKEN to be defined" if token.nil? || token.emtpy?
github_repo = spec.metadata["github_repo"]
abort "error: Expected spec.metadata[\"github_repo\"] to be defined in gemspec" if github_repo.nil?

owner, repo = github_repo.split("/")
if repo.nil?
abort "error: Expected spec.metadata[\"github_repo\"] to be of the format \"owner/repo\", got #{github_repo.inspect}"
end

assets = []

assets << packed_file if packed_file
assets << gem_file

assets = assets.map { |f| [f, Pathname(f).basename] }.to_h

gh = Jim::GithubApi.new(owner, repo, spec.name, token.strip)
gh.create_release(spec.version, assets: assets)
end

puts "FIXME: Actually publish #{gem_file}"

if options[:github]
puts "Publishing GitHub release."
gh_release.publish!
end
end

# Print information about the gemspec in the current directory.
def self.gemspec(spec=nil)
if spec.nil?
Expand Down Expand Up @@ -199,5 +273,14 @@ def self.subcommand_summaries(prefix, methods)

comments.map {|name, comment| "#{name.ljust(width)} #{comment}" }
end

def self.load_spec_or_abort!(spec=nil)
if spec.nil?
spec, *rest = Dir.glob("*.gemspec")
abort "Found multiple gemspecs: #{spec}, #{rest.join(',')}" unless rest.empty?
end

Jim.load_spec(spec)
end
end
end
158 changes: 158 additions & 0 deletions lib/jim/github_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
require_relative "http"
require 'json'
require 'open3'
require 'uri'

module Jim
module GithubApiHelpers
def api_req(endpoint, data, headers, server, method)
headers ||= {}
server ||= "https://api.github.com"

headers["Authorization"] = "token #{token}"
headers["Accept"] = "application/vnd.github.v3+json"

kwargs = {
headers: headers,
}

unless data.nil?
kwargs[:data] = data
kwargs[:data] = JSON.dump(data) if data.is_a?(Array) || data.is_a?(Hash)
end

url = URI.join(server, endpoint)

HTTP.send(method, url, **kwargs).or_raise!.from_json
end

def api_post(endpoint, data, headers=nil, server=nil)
api_req(endpoint, data, headers, server, :post)
end

def api_get(endpoint, headers=nil, server=nil)
api_req(endpoint, nil, headers, server, :get)
end

def api_patch(endpoint, data, headers=nil, server=nil)
api_req(endpoint, data, headers, server, :patch)
end
end


class GithubApi < Struct.new("GithubApi", :owner, :repo, :project_name, :token)
include GithubApiHelpers
# This is a fairly direct Python-to-Ruby port of bork.github_api:
# https://github.com/duckinator/bork/blob/main/bork/github_api.py

class Release < Struct.new("Release", :release, :token)
include GithubApiHelpers

def publish!
url = "/" + release["url"].split("/", 4).last
api_patch(url, {"draft" => false})
end
end

# `tag_name` is the name of the tag.
# `commitish` is a commit hash, branch, tag, etc.
# `body` is the body of the commit.
# `draft` indicates whether it should be a draft release or not.
# `prerelease` indicates whether it should be a prerelease or not.
# `assets` is a Hash mapping local file paths to the uploaded asset name.
def create_release(tag_name, name: nil, commitish: nil, body: nil, draft: true,
prerelease: nil, assets: nil)
name ||= "%{project_name} %{tag}"
commitish ||= run("git", "rev-parse", "HEAD")
body ||= "%{repo} %{tag}"
name ||= "%{project_name} %{tag}"

draft_indicator = draft ? ' as a draft' : ''

puts "Creating GitHub release #{tag_name}#{draft_indicator} (commit=#{commitish})"

prerelease ||= !!(tag_name =~ /[a-zA-Z]/)

format_hash = {
project_name: project_name,
owner: owner,
repo: repo,
tag: tag_name,
tag_name: tag_name,
}

# Don't fetch changelog info unless needed.
if body.include?('{changelog}')
format_dict['changelog'] = changelog
end

request = {
"tag_name" => tag_name,
"target_commitish" => commitish,
"name" => name % format_hash,
"body" => body % format_hash,
"draft" => draft,
"prerelease" => prerelease,
}
p request
url = "/repos/#{owner}/#{repo}/releases"
response = api_post(url, request)

upload_url = response["upload_url"].split("{?").first

if assets
assets.each { |local_file, asset_name|
add_release_asset(upload_url, local_file, asset_name)
}
end

Release.new(response, token)
end

private

def changelog
prs = api_get("/repos/#{owner}/#{repo}/pulls?state=closed")
summaries = prs
.filter(&method(:relevant_to_changelog))
.map(&method(:format_for_changelog))

summaries.join("\n")
end

def format_for_changelog(pr)
"* #{pr['title']} (#{pr['number']}) by @#{pr['user']['login']}"
end

def relevant_to_changelog(pr)
return false if pr.nil? || !pr.has_key?("merged_at")

return !last_release || (pr["merged_at"] > last_release["created_at"])
end

def last_release
@last_release ||= api_get("/repos/#{owner}/#{repo}/releases")[0]
end

def add_release_asset(upload_url, local_file, name)
puts "Adding asset #{name} to release (original file: #{local_file})"

data = File.open(local_file, 'rb') { |f| f.read }

headers = { "Content-Type" => "application/octet-stream" }

url = "#{upload_url}?name=#{name}"
api_post(url, data, headers=headers, server="")
end

def run(*command)
status, out, err = Open3.popen3(*command) { |i, o, e, w| [w.value, o.read, e.read] }

unless status.success?
abort "error: #{command.first} exited with status #{status.exitstatus}: #{err}"
end

out.strip
end
end
end
9 changes: 9 additions & 0 deletions lib/jim/http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ def self.post(url, data: nil, form_data: nil, headers: {}, basic_auth: nil)
send_request(:Post, url, nil, body, headers, basic_auth)
end

# Make an HTTP PATCH request.
#
# @param url [String] The URL to request.
# @param data [String] Raw data to for the body of the request.
def self.patch(url, data: nil, headers: {}, basic_auth: nil)
send_request(:Patch, url, nil, data, headers, basic_auth)
end


# rubocop:disable Metrics/AbcSize

# Helper method for actually creating a request.
Expand Down
2 changes: 1 addition & 1 deletion lib/jim/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Jim
VERSION = "0.2.0"
VERSION = "1.0.0b1"
end
Loading