puma-release 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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +114 -0
  4. data/exe/puma-release +7 -0
  5. data/lib/puma_release/agent_client.rb +163 -0
  6. data/lib/puma_release/build_support.rb +46 -0
  7. data/lib/puma_release/changelog_generator.rb +171 -0
  8. data/lib/puma_release/changelog_validator.rb +85 -0
  9. data/lib/puma_release/ci_checker.rb +108 -0
  10. data/lib/puma_release/cli.rb +52 -0
  11. data/lib/puma_release/commands/build.rb +68 -0
  12. data/lib/puma_release/commands/github.rb +76 -0
  13. data/lib/puma_release/commands/prepare.rb +178 -0
  14. data/lib/puma_release/commands/run.rb +51 -0
  15. data/lib/puma_release/context.rb +167 -0
  16. data/lib/puma_release/contributor_resolver.rb +52 -0
  17. data/lib/puma_release/error.rb +5 -0
  18. data/lib/puma_release/events.rb +18 -0
  19. data/lib/puma_release/git_repo.rb +169 -0
  20. data/lib/puma_release/github_client.rb +163 -0
  21. data/lib/puma_release/link_reference_builder.rb +49 -0
  22. data/lib/puma_release/options.rb +47 -0
  23. data/lib/puma_release/release_range.rb +69 -0
  24. data/lib/puma_release/repo_files.rb +85 -0
  25. data/lib/puma_release/shell.rb +107 -0
  26. data/lib/puma_release/stage_detector.rb +66 -0
  27. data/lib/puma_release/ui.rb +36 -0
  28. data/lib/puma_release/upgrade_guide_writer.rb +106 -0
  29. data/lib/puma_release/version.rb +5 -0
  30. data/lib/puma_release/version_recommender.rb +151 -0
  31. data/lib/puma_release.rb +28 -0
  32. data/test/test_helper.rb +72 -0
  33. data/test/unit/agent_client_test.rb +116 -0
  34. data/test/unit/build_command_test.rb +23 -0
  35. data/test/unit/build_support_test.rb +6 -0
  36. data/test/unit/changelog_validator_test.rb +42 -0
  37. data/test/unit/context_test.rb +209 -0
  38. data/test/unit/contributor_resolver_test.rb +47 -0
  39. data/test/unit/git_repo_test.rb +169 -0
  40. data/test/unit/github_client_test.rb +90 -0
  41. data/test/unit/github_command_test.rb +153 -0
  42. data/test/unit/options_test.rb +17 -0
  43. data/test/unit/prepare_test.rb +136 -0
  44. data/test/unit/repo_files_test.rb +119 -0
  45. data/test/unit/run_test.rb +32 -0
  46. data/test/unit/shell_test.rb +29 -0
  47. data/test/unit/stage_detector_test.rb +72 -0
  48. metadata +143 -0
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "shellwords"
5
+
6
+ module PumaRelease
7
+ class Context
8
+ attr_reader :env, :options, :events, :ui, :shell
9
+
10
+ def initialize(options, env: ENV, events: Events.new, ui: UI.new)
11
+ @options = options
12
+ @env = env
13
+ @events = events
14
+ @ui = ui
15
+ @shell = Shell.new(env:, cwd: repo_dir.to_s)
16
+ end
17
+
18
+ def repo_dir = options.fetch(:repo_dir)
19
+ def metadata_repo = options.fetch(:metadata_repo)
20
+ def allow_unknown_ci? = options.fetch(:allow_unknown_ci)
21
+ def skip_ci_check? = options.fetch(:skip_ci_check, false)
22
+ def yes? = options.fetch(:yes)
23
+ def live? = options.fetch(:live, false)
24
+ def debug? = options.fetch(:debug, false) || env["DEBUG"] == "true"
25
+ def changelog_backend = env.fetch("PUMA_RELEASE_CHANGELOG_BACKEND", options.fetch(:changelog_backend))
26
+ def codename = options.fetch(:codename)
27
+
28
+ def base_branch
29
+ @base_branch ||= options[:base_branch] || shell.output("git", "rev-parse", "--abbrev-ref", "HEAD").strip
30
+ end
31
+
32
+ def agent_cmd = env.fetch("AGENT_CMD", "claude")
33
+ TOOL_URL = "https://github.com/nateberkopec/puma-release"
34
+
35
+ def version_file = repo_dir.join("lib/puma/const.rb")
36
+ def history_file = repo_dir.join("History.md")
37
+ def security_file = repo_dir.join("SECURITY.md")
38
+
39
+ def release_repo
40
+ @release_repo ||= options[:release_repo] || infer_release_repo
41
+ end
42
+
43
+ def agent_binary
44
+ shell.split(agent_cmd).first || "claude"
45
+ end
46
+
47
+ def github_token
48
+ @github_token ||= begin
49
+ token = env.fetch("GITHUB_TOKEN", "")
50
+ token.empty? ? shell.optional_output("gh", "auth", "token") : token
51
+ end
52
+ end
53
+
54
+ def check_dependencies!(*commands)
55
+ missing = commands.flatten.compact.uniq.reject { |command| shell.available?(command) }
56
+ raise Error, "Missing required dependencies: #{missing.join(" ")}" unless missing.empty?
57
+ end
58
+
59
+ def comment_author_model_name(fallback: env["AGENT_MODEL_NAME"])
60
+ value = fallback.to_s.strip
61
+ return value unless value.empty?
62
+
63
+ agent_binary
64
+ end
65
+
66
+ def comment_attribution(model_name)
67
+ "This comment was written by #{model_name} working on behalf of [puma-release](#{TOOL_URL})."
68
+ end
69
+
70
+ def announce_live_mode!
71
+ return unless live?
72
+ return if @live_mode_announced
73
+
74
+ ui.warn("LIVE MODE: writes will go to #{release_repo}")
75
+ @live_mode_announced = true
76
+ end
77
+
78
+ def ensure_release_writes_allowed!
79
+ return if live?
80
+ return unless release_repo == metadata_repo
81
+
82
+ raise Error,
83
+ "Refusing to write release state to #{release_repo} without --live. " \
84
+ "Use --release-repo OWNER/REPO to target a fork, or pass --live to operate on #{metadata_repo}."
85
+ end
86
+
87
+ def confirm_live_github_write!(description)
88
+ return true unless live?
89
+ return true if yes?
90
+ return true if ui.confirm("LIVE MODE: #{description} on GitHub for #{release_repo}. Continue?")
91
+
92
+ raise Error, "Aborted live GitHub action: #{description}"
93
+ end
94
+
95
+ def confirm_live_git_command!(*command)
96
+ return true unless live?
97
+ return true if yes?
98
+
99
+ rendered = Shellwords.join(command)
100
+ return true if ui.confirm("LIVE MODE: about to run git command: #{rendered}. Continue?")
101
+
102
+ raise Error, "Aborted live git action: #{rendered}"
103
+ end
104
+
105
+ def confirm_live_gh_command!(*command)
106
+ return true unless live?
107
+ return true if yes?
108
+
109
+ rendered = Shellwords.join(command)
110
+ return true if ui.confirm("LIVE MODE: about to run gh command: #{rendered}. Continue?")
111
+
112
+ raise Error, "Aborted live gh action: #{rendered}"
113
+ end
114
+
115
+ private
116
+
117
+ def infer_release_repo
118
+ return metadata_repo if live?
119
+
120
+ preferred_fork_repo || metadata_repo
121
+ end
122
+
123
+ def preferred_fork_repo
124
+ candidates = github_remotes.reject { |_remote, repo| repo == metadata_repo }
125
+ return candidates.first&.last if candidates.one?
126
+
127
+ login_match = candidates.find { |_remote, repo| repo_owner(repo) == github_login }
128
+ return login_match.last if login_match
129
+
130
+ origin_match = candidates.find { |remote, _repo| remote == "origin" }
131
+ origin_match&.last
132
+ end
133
+
134
+ def github_login
135
+ return @github_login if defined?(@github_login)
136
+
137
+ @github_login = fetch_github_login
138
+ end
139
+
140
+ def repo_owner(repo)
141
+ repo.split("/", 2).first
142
+ end
143
+
144
+ def fetch_github_login
145
+ return nil unless shell.available?("gh")
146
+
147
+ result = shell.run("gh", "api", "user", allow_failure: true)
148
+ return nil unless result.success?
149
+
150
+ login = JSON.parse(result.stdout).fetch("login", "").strip
151
+ login.empty? ? nil : login
152
+ rescue Errno::ENOENT, JSON::ParserError
153
+ nil
154
+ end
155
+
156
+ def github_remotes
157
+ shell.output("git", "remote").lines(chomp: true).filter_map do |remote|
158
+ repo = github_repo_from_url(shell.output("git", "remote", "get-url", remote).strip)
159
+ repo ? [remote, repo] : nil
160
+ end
161
+ end
162
+
163
+ def github_repo_from_url(url)
164
+ url[%r{github\.com[:/]([^/]+/[^/.]+?)(?:\.git)?$}, 1]
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class ContributorResolver
5
+ attr_reader :context, :git_repo, :github
6
+
7
+ def initialize(context, git_repo: GitRepo.new(context), github: GitHubClient.new(context))
8
+ @context = context
9
+ @git_repo = git_repo
10
+ @github = github
11
+ end
12
+
13
+ def codename_earner(tag)
14
+ contributor = top_contributors_since(tag).first
15
+ return nil unless contributor
16
+
17
+ contributor.merge(login: resolve_login(tag, contributor))
18
+ end
19
+
20
+ def top_contributors_since(tag)
21
+ git_repo.top_contributors_since_with_email(tag).map do |line|
22
+ count, name, email = line.match(/^\s*(\d+)\s+(.+?)\s+<([^>]+)>$/)&.captures
23
+ next unless count
24
+
25
+ {count: count.to_i, name:, email:}
26
+ end.compact
27
+ end
28
+
29
+ private
30
+
31
+ def resolve_login(tag, contributor)
32
+ logins = commits_for(tag, contributor).filter_map { |commit| github.commit_author_login(context.metadata_repo, commit.fetch(:sha)) }
33
+ return most_common(logins) unless logins.empty?
34
+
35
+ login_from_noreply_email(contributor.fetch(:email))
36
+ end
37
+
38
+ def commits_for(tag, contributor)
39
+ git_repo.commit_authors_since(tag).select do |commit|
40
+ commit.fetch(:name) == contributor.fetch(:name) && commit.fetch(:email) == contributor.fetch(:email)
41
+ end
42
+ end
43
+
44
+ def most_common(values)
45
+ values.tally.max_by { |_value, count| count }&.first
46
+ end
47
+
48
+ def login_from_noreply_email(email)
49
+ email[%r{\A(?:\d+\+)?([^@]+)@users\.noreply\.github\.com\z}, 1]
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class Events
5
+ def initialize
6
+ @subscribers = Hash.new { |hash, key| hash[key] = [] }
7
+ end
8
+
9
+ def subscribe(name = :all, &block)
10
+ @subscribers[name] << block
11
+ end
12
+
13
+ def publish(name, payload = {})
14
+ @subscribers.fetch(:all, []).each { |subscriber| subscriber.call(name, payload) }
15
+ @subscribers.fetch(name, []).each { |subscriber| subscriber.call(name, payload) }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class GitRepo
5
+ attr_reader :context
6
+
7
+ def initialize(context)
8
+ @context = context
9
+ end
10
+
11
+ def current_branch = shell.output("git", "rev-parse", "--abbrev-ref", "HEAD").strip
12
+ def clean? = shell.output("git", "status", "--porcelain").strip.empty?
13
+ def head_sha = shell.output("git", "rev-parse", "HEAD").strip
14
+ def commits_since(tag) = shell.output("git", "rev-list", "--count", "#{tag}..HEAD").strip.to_i
15
+
16
+ def ensure_clean_base!
17
+ base = context.base_branch
18
+ raise Error, "Must be on '#{base}' branch (currently on '#{current_branch}')" unless current_branch == base
19
+ raise Error, "Working directory not clean. Commit or stash first." unless clean?
20
+
21
+ run_git!("fetch", metadata_remote, "--quiet")
22
+ remote_sha = shell.output("git", "rev-parse", "#{metadata_remote}/#{base}").strip
23
+ raise Error, "Local #{base} differs from #{metadata_remote}/#{base}. Pull or push first." unless head_sha == remote_sha
24
+ end
25
+
26
+ def last_tag
27
+ tags = shell.output("git", "tag", "--sort=-v:refname", "--merged", "HEAD").lines(chomp: true)
28
+ tags.find { |tag| tag.match?(/^v\d/) } || raise(Error, "Could not determine last release tag")
29
+ end
30
+
31
+ def bump_version(version, bump_type)
32
+ major, minor, patch = version.split(".").map(&:to_i)
33
+ return "#{major + 1}.0.0" if bump_type == "major"
34
+ return "#{major}.#{minor + 1}.0" if bump_type == "minor"
35
+ return "#{major}.#{minor}.#{patch + 1}" if bump_type == "patch"
36
+
37
+ raise Error, "Unknown bump type: #{bump_type}"
38
+ end
39
+
40
+ def top_contributors_since(tag)
41
+ shell.output("git", "shortlog", "-s", "-n", "--no-merges", "#{tag}..HEAD").lines(chomp: true)
42
+ end
43
+
44
+ def top_contributors_since_with_email(tag)
45
+ shell.output("git", "shortlog", "-s", "-n", "-e", "--no-merges", "#{tag}..HEAD").lines(chomp: true)
46
+ end
47
+
48
+ def commit_authors_since(tag)
49
+ shell.output("git", "log", "--format=%H%x09%aN%x09%aE", "#{tag}..HEAD").lines(chomp: true).map do |line|
50
+ sha, name, email = line.split("\t", 3)
51
+ {sha:, name:, email:}
52
+ end
53
+ end
54
+
55
+ def release_tag(version) = "v#{version}"
56
+ def proposal_tag(version) = "#{release_tag(version)}-proposal"
57
+
58
+ def checkout_release_branch!(branch)
59
+ run_git!("checkout", "-b", branch)
60
+ end
61
+
62
+ def commit_release!(version, extra_files: [])
63
+ run_git!("add", context.version_file.to_s, context.history_file.to_s, *extra_files.map(&:to_s))
64
+ run_git!("commit", "-S", "-m", "Release v#{version}")
65
+ end
66
+
67
+ def push_branch!(branch)
68
+ command = ["push"]
69
+ command << "-u" if release_remote
70
+ command += [release_push_target, branch]
71
+ run_git!(*command)
72
+ end
73
+
74
+ def remote_tag_sha(tag, repo: context.release_repo)
75
+ refs = shell.output("git", "ls-remote", "--tags", remote_target_for(repo), "refs/tags/#{tag}", "refs/tags/#{tag}^{}").lines(chomp: true)
76
+ peeled = refs.find { |line| line.end_with?("refs/tags/#{tag}^{}") }
77
+ direct = refs.find { |line| line.end_with?("refs/tags/#{tag}") }
78
+ (peeled || direct).to_s.split.first.to_s
79
+ end
80
+
81
+ def remote_tag_object_sha(tag, repo: context.release_repo)
82
+ shell.output("git", "ls-remote", "--tags", remote_target_for(repo), "refs/tags/#{tag}").split.first.to_s
83
+ end
84
+
85
+ def local_tag_sha(tag)
86
+ shell.optional_output("git", "rev-parse", "-q", "--verify", "refs/tags/#{tag}^{commit}")
87
+ end
88
+
89
+ def local_tag_object_sha(tag)
90
+ shell.optional_output("git", "rev-parse", "-q", "--verify", "refs/tags/#{tag}")
91
+ end
92
+
93
+ def create_signed_tag!(tag, message: "Release #{tag}")
94
+ run_git!("tag", "-s", tag, "-m", message)
95
+ end
96
+
97
+ def local_tag_signed?(tag)
98
+ return false if local_tag_sha(tag).empty?
99
+
100
+ shell.output("git", "cat-file", "-p", "refs/tags/#{tag}").include?("-----BEGIN PGP SIGNATURE-----")
101
+ end
102
+
103
+ def ensure_release_tag_pushed!(tag)
104
+ head = head_sha
105
+ local = local_tag_sha(tag)
106
+ remote = remote_tag_sha(tag)
107
+
108
+ raise Error, "Remote tag #{tag} already exists at #{remote}, not HEAD #{head}." if !remote.empty? && remote != head
109
+ raise Error, "Local tag #{tag} already exists at #{local}, not HEAD #{head}." if !local.empty? && local != head
110
+ raise Error, "Local tag #{tag} exists at #{head} but is not GPG-signed." if !local.empty? && !local_tag_signed?(tag)
111
+
112
+ create_signed_tag!(tag) if local.empty?
113
+
114
+ local_object = local_tag_object_sha(tag)
115
+ remote_object = remote_tag_object_sha(tag)
116
+ return if !remote_object.empty? && remote_object == local_object
117
+
118
+ raise Error, "Remote tag #{tag} already exists but does not match the local signed tag." unless remote_object.empty?
119
+
120
+ run_git!("push", release_push_target, tag)
121
+ end
122
+
123
+ def delete_local_tag!(tag, allow_failure: false)
124
+ run_git!("tag", "-d", tag, allow_failure:)
125
+ end
126
+
127
+ private
128
+
129
+ def shell = context.shell
130
+
131
+ def run_git!(*command, **options)
132
+ context.confirm_live_git_command!("git", *command) if context.respond_to?(:confirm_live_git_command!)
133
+ shell.run("git", *command, **options)
134
+ end
135
+
136
+ def metadata_remote
137
+ remote_name_for(context.metadata_repo) || "origin"
138
+ end
139
+
140
+ def release_remote
141
+ remote_name_for(context.release_repo)
142
+ end
143
+
144
+ def release_push_target
145
+ release_remote || github_url_for(context.release_repo)
146
+ end
147
+
148
+ def remote_target_for(repo)
149
+ remote_name_for(repo) || github_url_for(repo)
150
+ end
151
+
152
+ def remote_name_for(repo)
153
+ shell.output("git", "remote").lines(chomp: true).find do |remote|
154
+ github_repo_from_url(shell.output("git", "remote", "get-url", remote).strip) == repo
155
+ end
156
+ end
157
+
158
+ def github_url_for(repo)
159
+ origin_url = shell.output("git", "remote", "get-url", "origin").strip
160
+ return "git@github.com:#{repo}.git" if origin_url.match?(%r{\A(?:git@github\.com:|ssh://git@github\.com/)})
161
+
162
+ "https://github.com/#{repo}.git"
163
+ end
164
+
165
+ def github_repo_from_url(url)
166
+ url[%r{github\.com[:/]([^/]+/[^/.]+?)(?:\.git)?$}, 1]
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tempfile"
5
+
6
+ module PumaRelease
7
+ class GitHubClient
8
+ attr_reader :context
9
+
10
+ def initialize(context)
11
+ @context = context
12
+ end
13
+
14
+ def commit_pulls(repo, sha)
15
+ json("gh", "api", "repos/#{repo}/commits/#{sha}/pulls") || []
16
+ end
17
+
18
+ def commit_author_login(repo, sha)
19
+ commit = json("gh", "api", "repos/#{repo}/commits/#{sha}")
20
+ login = commit&.dig("author", "login") || commit&.dig("committer", "login")
21
+ return login if login && !login.empty?
22
+
23
+ commit_pulls(repo, sha).first&.dig("user", "login")
24
+ end
25
+
26
+ def pr(number, repo: context.release_repo)
27
+ json("gh", "pr", "view", number.to_s, "--repo", repo, "--json", "number,title,url,state,mergedAt,author,labels,headRefName")
28
+ end
29
+
30
+ def issue(number, repo: context.metadata_repo)
31
+ json("gh", "issue", "view", number.to_s, "--repo", repo, "--json", "number,title,url,closedAt,author")
32
+ end
33
+
34
+ def user(login)
35
+ json("gh", "api", "users/#{login}")
36
+ end
37
+
38
+ def open_release_pr
39
+ owner = context.release_repo.split("/").first
40
+ prs = json(
41
+ "gh", "pr", "list", "--repo", context.release_repo,
42
+ "--state", "open",
43
+ "--search", "head:#{owner}:release-v",
44
+ "--json", "number,title,url,headRefName"
45
+ ) || []
46
+ prs.find { |pr| pr.fetch("headRefName", "").start_with?("release-v") }
47
+ end
48
+
49
+ def create_release_pr(title, branch, body: "")
50
+ output_gh!(
51
+ "pr", "create",
52
+ "--repo", context.release_repo,
53
+ "--base", context.base_branch,
54
+ "--head", branch,
55
+ "--title", title,
56
+ "--body", body
57
+ ).strip
58
+ end
59
+
60
+ def update_pr_body(pr_url, body)
61
+ run_gh!("pr", "edit", pr_url, "--body", body)
62
+ end
63
+
64
+ def comment_on_pr(pr_url, body)
65
+ run_gh!("pr", "comment", pr_url, "--body", body)
66
+ end
67
+
68
+ def release(tag)
69
+ json("gh", "release", "view", tag, "--repo", context.release_repo, "--json", "tagName,name,isDraft,body,url,assets,targetCommitish")
70
+ end
71
+
72
+ def retag_release(old_tag, new_tag, target: nil)
73
+ release_id = release_id(old_tag)
74
+ command = ["api", "-X", "PATCH", "repos/#{context.release_repo}/releases/#{release_id}", "-f", "tag_name=#{new_tag}"]
75
+ command += ["-f", "target_commitish=#{target}"] if target
76
+ run_gh!(*command)
77
+ release(new_tag)
78
+ end
79
+
80
+ def delete_release(tag, allow_failure: false)
81
+ release_id = release_id(tag, allow_failure:)
82
+ return false unless release_id
83
+
84
+ run_gh!("api", "-X", "DELETE", "repos/#{context.release_repo}/releases/#{release_id}", allow_failure:)
85
+ true
86
+ end
87
+
88
+ def delete_tag_ref(tag, allow_failure: false)
89
+ run_gh!("api", "-X", "DELETE", "repos/#{context.release_repo}/git/refs/tags/#{tag}", allow_failure:)
90
+ true
91
+ end
92
+
93
+ def create_release(tag, body, title: tag, draft: true, target: nil)
94
+ with_notes_file(body) do |path|
95
+ command = ["release", "create", tag, "--repo", context.release_repo, "--title", title, "--notes-file", path]
96
+ command += ["--target", target] if target
97
+ command << "--draft" if draft
98
+ run_gh!(*command)
99
+ end
100
+ release(tag)
101
+ end
102
+
103
+ def edit_release_notes(tag, body)
104
+ with_notes_file(body) { |path| run_gh!("release", "edit", tag, "--repo", context.release_repo, "--notes-file", path) }
105
+ release(tag)
106
+ end
107
+
108
+ def edit_release_target(tag, target)
109
+ run_gh!("release", "edit", tag, "--repo", context.release_repo, "--target", target)
110
+ release(tag)
111
+ end
112
+
113
+ def edit_release_title(tag, title)
114
+ run_gh!("release", "edit", tag, "--repo", context.release_repo, "--title", title)
115
+ release(tag)
116
+ end
117
+
118
+ def publish_release(tag)
119
+ run_gh!("release", "edit", tag, "--repo", context.release_repo, "--draft=false")
120
+ release(tag)
121
+ end
122
+
123
+ def upload_release_assets(tag, *paths)
124
+ run_gh!("release", "upload", tag, "--repo", context.release_repo, "--clobber", *paths)
125
+ end
126
+
127
+ private
128
+
129
+ def json(*command)
130
+ result = context.shell.run(*command, allow_failure: true)
131
+ return nil unless result.success?
132
+
133
+ body = result.stdout.strip
134
+ body.empty? ? {} : JSON.parse(body)
135
+ end
136
+
137
+ def run_gh!(*command, **options)
138
+ context.confirm_live_gh_command!("gh", *command) if context.respond_to?(:confirm_live_gh_command!)
139
+ context.shell.run("gh", *command, **options)
140
+ end
141
+
142
+ def output_gh!(*command, **options)
143
+ context.confirm_live_gh_command!("gh", *command) if context.respond_to?(:confirm_live_gh_command!)
144
+ context.shell.output("gh", *command, **options)
145
+ end
146
+
147
+ def release_id(tag, allow_failure: false)
148
+ payload = json("gh", "api", "repos/#{context.release_repo}/releases/tags/#{tag}")
149
+ return payload&.fetch("id", nil) if payload
150
+ return nil if allow_failure
151
+
152
+ raise Error, "Could not find release for tag #{tag}"
153
+ end
154
+
155
+ def with_notes_file(body)
156
+ Tempfile.create("puma-release-notes") do |file|
157
+ file.write(body)
158
+ file.flush
159
+ yield file.path
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class LinkReferenceBuilder
5
+ attr_reader :context
6
+
7
+ def initialize(context)
8
+ @context = context
9
+ end
10
+
11
+ def build(changelog)
12
+ numbers = changelog.scan(/\[#(\d+)\]/).flatten.map(&:to_i).uniq.sort.reverse
13
+ existing = context.history_file.read
14
+ numbers.filter_map { |number| reference_for(number, existing) }.join("\n")
15
+ end
16
+
17
+ private
18
+
19
+ def reference_for(number, existing)
20
+ return if existing.match?(/^\[##{number}\]:/)
21
+
22
+ context.ui.info(" Looking up ##{number}...")
23
+ pr = github.pr(number, repo: context.metadata_repo)
24
+ return pr_reference(number, pr) if pr
25
+
26
+ issue = github.issue(number, repo: context.metadata_repo)
27
+ return issue_reference(number, issue) if issue
28
+
29
+ context.ui.warn("Could not look up ##{number}")
30
+ nil
31
+ end
32
+
33
+ def pr_reference(number, pr)
34
+ login = pr.dig("author", "login")
35
+ author = github.user(login)&.fetch("name", nil).to_s
36
+ author = login if author.empty?
37
+ merged_at = pr.fetch("mergedAt", "").split("T").first
38
+ "[##{number}]:https://github.com/#{context.metadata_repo}/pull/#{number} \"PR by #{author}, merged #{merged_at}\""
39
+ end
40
+
41
+ def issue_reference(number, issue)
42
+ login = issue.dig("author", "login")
43
+ closed_at = issue.fetch("closedAt", "").split("T").first
44
+ "[##{number}]:https://github.com/#{context.metadata_repo}/issues/#{number} \"Issue by @#{login}, closed #{closed_at}\""
45
+ end
46
+
47
+ def github = @github ||= GitHubClient.new(context)
48
+ end
49
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module PumaRelease
6
+ class Options
7
+ DEFAULTS = {
8
+ command: "run",
9
+ repo_dir: Dir.pwd,
10
+ metadata_repo: "puma/puma",
11
+ changelog_backend: "auto",
12
+ allow_unknown_ci: false,
13
+ skip_ci_check: false,
14
+ yes: false,
15
+ live: false,
16
+ debug: false,
17
+ codename: nil,
18
+ base_branch: nil
19
+ }.freeze
20
+
21
+ def self.parse(argv)
22
+ options = DEFAULTS.dup
23
+ parser = OptionParser.new do |opts|
24
+ opts.banner = "Usage: puma-release [options] [command]"
25
+
26
+ opts.on("--repo-dir PATH", "Path to the Puma checkout") { |value| options[:repo_dir] = value }
27
+ opts.on("--release-repo OWNER/REPO", "Repo for PRs, tags, and releases") { |value| options[:release_repo] = value }
28
+ opts.on("--metadata-repo OWNER/REPO", "Repo for PR metadata and commit links") { |value| options[:metadata_repo] = value }
29
+ opts.on("--live", "Allow writes to the metadata repo for the real release") { options[:live] = true }
30
+ opts.on("--allow-unknown-ci", "Proceed when CI status cannot be determined") { options[:allow_unknown_ci] = true }
31
+ opts.on("--skip-ci-check", "Skip the CI check entirely during prepare") { options[:skip_ci_check] = true }
32
+ opts.on("--changelog-backend NAME", "auto, agent, or communique") { |value| options[:changelog_backend] = value }
33
+ opts.on("-y", "--yes", "Skip interactive confirmations") { options[:yes] = true }
34
+ opts.on("--debug", "Enable debug logging") { options[:debug] = true }
35
+ opts.on("--codename NAME", "Set the release codename directly") { |value| options[:codename] = value }
36
+ opts.on("--base-branch BRANCH", "Base branch for the release (default: current branch)") { |value| options[:base_branch] = value }
37
+ end
38
+
39
+ remaining = parser.parse(argv)
40
+ options[:command] = remaining.first if remaining.first
41
+ options[:repo_dir] = Pathname(options[:repo_dir]).expand_path
42
+ options
43
+ rescue OptionParser::ParseError => e
44
+ raise Error, e.message
45
+ end
46
+ end
47
+ end