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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "tmpdir"
5
+ require "ostruct"
6
+ require_relative "../lib/puma_release"
7
+
8
+ module TestSupport
9
+ class FakeShell
10
+ Result = Data.define(:stdout, :stderr, :success?, :exitstatus)
11
+
12
+ attr_reader :commands
13
+
14
+ def initialize(outputs = {})
15
+ @outputs = outputs
16
+ @commands = []
17
+ end
18
+
19
+ def output(*command, **_options)
20
+ commands << command
21
+ value = @outputs.fetch(command, "")
22
+ value.respond_to?(:call) ? value.call : value
23
+ end
24
+
25
+ def run(*command, allow_failure: false, **_options)
26
+ commands << command
27
+ value = @outputs.fetch(command, Result.new(stdout: "", stderr: "", success?: true, exitstatus: 0))
28
+ value = value.call if value.respond_to?(:call)
29
+ return value if value.success? || allow_failure
30
+
31
+ raise PumaRelease::Error, command.join(" ")
32
+ end
33
+
34
+ def stream_output(*command, **options)
35
+ output(*command, **options)
36
+ end
37
+
38
+ def stream_json_events(*command, **options)
39
+ output(*command, **options).each_line do |line|
40
+ next if line.strip.empty?
41
+ begin
42
+ yield JSON.parse(line)
43
+ rescue JSON::ParserError
44
+ end
45
+ end
46
+ end
47
+
48
+ def optional_output(*command)
49
+ output(*command).strip
50
+ end
51
+
52
+ def available?(_command)
53
+ true
54
+ end
55
+
56
+ def split(command)
57
+ command.split
58
+ end
59
+ end
60
+
61
+ def temp_repo
62
+ Dir.mktmpdir do |dir|
63
+ repo = Pathname(dir)
64
+ repo.join("lib/puma").mkpath
65
+ yield repo
66
+ end
67
+ end
68
+ end
69
+
70
+ class Minitest::Test
71
+ include TestSupport
72
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../test_helper"
5
+
6
+ class AgentClientTest < Minitest::Test
7
+ def test_pi_mode_parses_json_from_event_stream_without_printing_json_payload
8
+ shell = Class.new do
9
+ attr_reader :command
10
+
11
+ def stream_json_events(*command, **)
12
+ @command = command
13
+ yield({"type" => "message_update", "assistantMessageEvent" => {"type" => "text_delta", "delta" => "{"}})
14
+ yield({
15
+ "type" => "message_end",
16
+ "message" => {
17
+ "role" => "assistant",
18
+ "provider" => "openai-codex",
19
+ "model" => "gpt-5.4",
20
+ "content" => [
21
+ {"type" => "text", "text" => '{"bump_type":"patch","reasoning_markdown":"Because of [this commit](https://github.com/puma/puma/commit/abc)."}'}
22
+ ]
23
+ }
24
+ })
25
+ end
26
+
27
+ def split(command)
28
+ [command]
29
+ end
30
+ end.new
31
+
32
+ context = OpenStruct.new(agent_cmd: "/tmp/pi", shell:)
33
+ original_stdout = $stdout
34
+ $stdout = StringIO.new
35
+
36
+ client = PumaRelease::AgentClient.new(context)
37
+ result = client.ask_for_json(
38
+ "Choose a version bump",
39
+ system_prompt: "Return JSON",
40
+ schema: {
41
+ type: "object",
42
+ required: %w[bump_type reasoning_markdown],
43
+ properties: {
44
+ bump_type: {type: "string"},
45
+ reasoning_markdown: {type: "string"}
46
+ }
47
+ }
48
+ )
49
+
50
+ assert_equal "patch", result.fetch("bump_type")
51
+ assert_includes shell.command, "-p"
52
+ assert_includes shell.command, "--thinking"
53
+ assert_includes shell.command, "xhigh"
54
+ assert_includes shell.command, "--tools"
55
+ assert_includes shell.command, "read,bash"
56
+ assert_includes shell.command, "--extension"
57
+ assert_includes shell.command, File.expand_path("../../config/pi-agent-guard.ts", __dir__)
58
+ assert_includes shell.command, "--mode"
59
+ refute_includes shell.command, "--no-tools"
60
+ refute_includes shell.command, "--no-session"
61
+ assert_equal "openai-codex/gpt-5.4", client.last_model_name
62
+ assert_equal ".\n", $stdout.string
63
+ ensure
64
+ $stdout = original_stdout
65
+ end
66
+
67
+ def test_pi_mode_uses_final_answer_text_when_commentary_is_present
68
+ shell = Class.new do
69
+ def stream_json_events(*, **)
70
+ yield({"type" => "message_update", "assistantMessageEvent" => {"type" => "text_delta", "delta" => "I"}})
71
+ yield({
72
+ "type" => "message_end",
73
+ "message" => {
74
+ "role" => "assistant",
75
+ "provider" => "openai-codex",
76
+ "model" => "gpt-5.4",
77
+ "content" => [
78
+ {
79
+ "type" => "text",
80
+ "text" => "I'm thinking through the release range.",
81
+ "textSignature" => '{"v":1,"phase":"commentary"}'
82
+ },
83
+ {
84
+ "type" => "text",
85
+ "text" => '{"bump_type":"minor","reasoning_markdown":"Because of [this commit](https://github.com/puma/puma/commit/abc)."}',
86
+ "textSignature" => '{"v":1,"phase":"final_answer"}'
87
+ }
88
+ ]
89
+ }
90
+ })
91
+ end
92
+
93
+ def split(command)
94
+ [command]
95
+ end
96
+ end.new
97
+
98
+ context = OpenStruct.new(agent_cmd: "pi", shell:)
99
+
100
+ client = PumaRelease::AgentClient.new(context)
101
+ result = client.ask_for_json(
102
+ "Choose a version bump",
103
+ system_prompt: "Return JSON",
104
+ schema: {
105
+ type: "object",
106
+ required: %w[bump_type reasoning_markdown],
107
+ properties: {
108
+ bump_type: {type: "string"},
109
+ reasoning_markdown: {type: "string"}
110
+ }
111
+ }
112
+ )
113
+
114
+ assert_equal "minor", result.fetch("bump_type")
115
+ end
116
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class BuildCommandTest < Minitest::Test
6
+ def test_sync_release_target_to_tag_is_a_no_op_when_release_is_missing
7
+ git_repo = Object.new
8
+ git_repo.define_singleton_method(:local_tag_sha) { |_tag| "abc123" }
9
+
10
+ github = Object.new
11
+ calls = []
12
+ github.define_singleton_method(:release) { |_tag| nil }
13
+ github.define_singleton_method(:edit_release_target) { |_tag, _sha| calls << :edit_release_target }
14
+
15
+ command = PumaRelease::Commands::Build.allocate
16
+ command.instance_variable_set(:@git_repo, git_repo)
17
+ command.instance_variable_set(:@github, github)
18
+
19
+ command.send(:sync_release_target_to_tag, "v7.2.0")
20
+
21
+ assert_empty calls
22
+ end
23
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class BuildSupportTest < Minitest::Test
6
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class ChangelogValidatorTest < Minitest::Test
6
+ def test_valid_changelog_has_no_errors
7
+ changelog = <<~CHANGELOG
8
+ * Features
9
+ * Add a nice thing ([#10])
10
+
11
+ * Bugfixes
12
+ * Fix a rough edge ([#11], [#12])
13
+ CHANGELOG
14
+
15
+ assert_empty PumaRelease::ChangelogValidator.new.validate(changelog)
16
+ end
17
+
18
+ def test_rejects_inline_links
19
+ changelog = <<~CHANGELOG
20
+ * Features
21
+ * Add a nice thing [#10](https://example.test)
22
+ CHANGELOG
23
+
24
+ errors = PumaRelease::ChangelogValidator.new.validate(changelog)
25
+
26
+ assert_includes errors.join("\n"), "inline markdown links are not allowed"
27
+ end
28
+
29
+ def test_rejects_out_of_order_categories
30
+ changelog = <<~CHANGELOG
31
+ * Docs
32
+ * Update docs ([#10])
33
+
34
+ * Features
35
+ * Add a nice thing ([#11])
36
+ CHANGELOG
37
+
38
+ errors = PumaRelease::ChangelogValidator.new.validate(changelog)
39
+
40
+ assert_includes errors.join("\n"), "categories must appear in this order"
41
+ end
42
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class ContextTest < Minitest::Test
6
+ class FakeUI
7
+ attr_reader :warnings, :confirmations
8
+ attr_accessor :confirm_result
9
+
10
+ def initialize(confirm_result: true)
11
+ @warnings = []
12
+ @confirmations = []
13
+ @confirm_result = confirm_result
14
+ end
15
+
16
+ def warn(message)
17
+ warnings << message
18
+ end
19
+
20
+ def confirm(message, default: true)
21
+ confirmations << [message, default]
22
+ confirm_result
23
+ end
24
+ end
25
+
26
+ def test_release_repo_prefers_a_fork_remote_when_not_live
27
+ shell = FakeShell.new(
28
+ {
29
+ ["git", "remote"] => "origin\nmine\n",
30
+ ["git", "remote", "get-url", "origin"] => "https://github.com/puma/puma.git\n",
31
+ ["git", "remote", "get-url", "mine"] => "git@github.com:nateberkopec/puma.git\n"
32
+ }
33
+ )
34
+
35
+ context = build_context(shell:, live: false)
36
+
37
+ assert_equal "nateberkopec/puma", context.release_repo
38
+ end
39
+
40
+ def test_release_repo_defaults_to_metadata_repo_in_live_mode
41
+ shell = FakeShell.new(
42
+ {
43
+ ["git", "remote"] => "origin\nmine\n",
44
+ ["git", "remote", "get-url", "origin"] => "https://github.com/puma/puma.git\n",
45
+ ["git", "remote", "get-url", "mine"] => "git@github.com:nateberkopec/puma.git\n"
46
+ }
47
+ )
48
+
49
+ context = build_context(shell:, live: true)
50
+
51
+ assert_equal "puma/puma", context.release_repo
52
+ end
53
+
54
+ def test_refuses_writes_to_metadata_repo_without_live
55
+ shell = FakeShell.new
56
+ context = build_context(shell:, live: false, release_repo: "puma/puma")
57
+
58
+ error = assert_raises(PumaRelease::Error) { context.ensure_release_writes_allowed! }
59
+
60
+ assert_includes error.message, "without --live"
61
+ end
62
+
63
+ def test_release_repo_prefers_the_authenticated_users_fork_when_multiple_candidates_exist
64
+ shell = FakeShell.new(
65
+ {
66
+ ["git", "remote"] => "backup\norigin\nupstream\n",
67
+ ["git", "remote", "get-url", "backup"] => "git@github.com:someoneelse/puma.git\n",
68
+ ["git", "remote", "get-url", "origin"] => "git@github.com:nateberkopec/puma.git\n",
69
+ ["git", "remote", "get-url", "upstream"] => "https://github.com/puma/puma.git\n",
70
+ ["gh", "api", "user"] => FakeShell::Result.new(stdout: '{"login":"nateberkopec"}', stderr: "", success?: true, exitstatus: 0)
71
+ }
72
+ )
73
+
74
+ context = build_context(shell:, live: false)
75
+
76
+ assert_equal "nateberkopec/puma", context.release_repo
77
+ end
78
+
79
+ def test_release_repo_falls_back_to_metadata_repo_when_fork_is_ambiguous
80
+ shell = FakeShell.new(
81
+ {
82
+ ["git", "remote"] => "backup\nmirror\norigin\n",
83
+ ["git", "remote", "get-url", "backup"] => "git@github.com:someoneelse/puma.git\n",
84
+ ["git", "remote", "get-url", "mirror"] => "git@github.com:anotheruser/puma.git\n",
85
+ ["git", "remote", "get-url", "origin"] => "https://github.com/puma/puma.git\n",
86
+ ["gh", "api", "user"] => FakeShell::Result.new(stdout: '{"login":"nateberkopec"}', stderr: "", success?: true, exitstatus: 0)
87
+ }
88
+ )
89
+
90
+ context = build_context(shell:, live: false)
91
+
92
+ assert_equal "puma/puma", context.release_repo
93
+ end
94
+
95
+ def test_announce_live_mode_warns_once
96
+ shell = FakeShell.new
97
+ ui = FakeUI.new
98
+ context = build_context(shell:, live: true, release_repo: "puma/puma", ui:)
99
+
100
+ context.announce_live_mode!
101
+ context.announce_live_mode!
102
+
103
+ assert_equal ["LIVE MODE: writes will go to puma/puma"], ui.warnings
104
+ end
105
+
106
+ def test_confirm_live_github_write_prompts_in_live_mode
107
+ shell = FakeShell.new
108
+ ui = FakeUI.new(confirm_result: true)
109
+ context = build_context(shell:, live: true, release_repo: "puma/puma", ui:)
110
+
111
+ assert context.confirm_live_github_write!("publish release v7.3.0")
112
+ assert_equal [["LIVE MODE: publish release v7.3.0 on GitHub for puma/puma. Continue?", true]], ui.confirmations
113
+ end
114
+
115
+ def test_confirm_live_github_write_raises_when_declined
116
+ shell = FakeShell.new
117
+ ui = FakeUI.new(confirm_result: false)
118
+ context = build_context(shell:, live: true, release_repo: "puma/puma", ui:)
119
+
120
+ error = assert_raises(PumaRelease::Error) { context.confirm_live_github_write!("publish release v7.3.0") }
121
+
122
+ assert_includes error.message, "Aborted live GitHub action"
123
+ end
124
+
125
+ def test_confirm_live_github_write_skips_prompt_when_not_live
126
+ shell = FakeShell.new
127
+ ui = FakeUI.new(confirm_result: false)
128
+ context = build_context(shell:, live: false, release_repo: "nateberkopec/puma", ui:)
129
+
130
+ assert context.confirm_live_github_write!("publish release v7.3.0")
131
+ assert_empty ui.confirmations
132
+ end
133
+
134
+ def test_confirm_live_github_write_skips_prompt_when_yes_is_set
135
+ shell = FakeShell.new
136
+ ui = FakeUI.new(confirm_result: false)
137
+ context = build_context(shell:, live: true, release_repo: "puma/puma", yes: true, ui:)
138
+
139
+ assert context.confirm_live_github_write!("publish release v7.3.0")
140
+ assert_empty ui.confirmations
141
+ end
142
+
143
+ def test_confirm_live_git_command_prompts_in_live_mode_with_the_full_command
144
+ shell = FakeShell.new
145
+ ui = FakeUI.new(confirm_result: true)
146
+ context = build_context(shell:, live: true, release_repo: "puma/puma", ui:)
147
+
148
+ assert context.confirm_live_git_command!("git", "push", "origin", "main")
149
+ assert_equal [["LIVE MODE: about to run git command: git push origin main. Continue?", true]], ui.confirmations
150
+ end
151
+
152
+ def test_confirm_live_git_command_raises_when_declined
153
+ shell = FakeShell.new
154
+ ui = FakeUI.new(confirm_result: false)
155
+ context = build_context(shell:, live: true, release_repo: "puma/puma", ui:)
156
+
157
+ error = assert_raises(PumaRelease::Error) { context.confirm_live_git_command!("git", "push", "origin", "main") }
158
+
159
+ assert_includes error.message, "Aborted live git action"
160
+ end
161
+
162
+ def test_confirm_live_git_command_skips_prompt_when_yes_is_set
163
+ shell = FakeShell.new
164
+ ui = FakeUI.new(confirm_result: false)
165
+ context = build_context(shell:, live: true, release_repo: "puma/puma", yes: true, ui:)
166
+
167
+ assert context.confirm_live_git_command!("git", "push", "origin", "main")
168
+ assert_empty ui.confirmations
169
+ end
170
+
171
+ def test_confirm_live_gh_command_prompts_in_live_mode_with_the_full_command
172
+ shell = FakeShell.new
173
+ ui = FakeUI.new(confirm_result: true)
174
+ context = build_context(shell:, live: true, release_repo: "puma/puma", ui:)
175
+
176
+ assert context.confirm_live_gh_command!("gh", "release", "edit", "v7.3.0", "--repo", "puma/puma", "--draft=false")
177
+ assert_equal [["LIVE MODE: about to run gh command: gh release edit v7.3.0 --repo puma/puma --draft\\=false. Continue?", true]], ui.confirmations
178
+ end
179
+
180
+ def test_confirm_live_gh_command_raises_when_declined
181
+ shell = FakeShell.new
182
+ ui = FakeUI.new(confirm_result: false)
183
+ context = build_context(shell:, live: true, release_repo: "puma/puma", ui:)
184
+
185
+ error = assert_raises(PumaRelease::Error) { context.confirm_live_gh_command!("gh", "release", "edit", "v7.3.0", "--repo", "puma/puma", "--draft=false") }
186
+
187
+ assert_includes error.message, "Aborted live gh action"
188
+ end
189
+
190
+ private
191
+
192
+ def build_context(shell:, live:, release_repo: nil, yes: false, ui: PumaRelease::UI.new)
193
+ options = {
194
+ command: "run",
195
+ repo_dir: Pathname(Dir.pwd),
196
+ metadata_repo: "puma/puma",
197
+ release_repo:,
198
+ changelog_backend: "auto",
199
+ allow_unknown_ci: false,
200
+ yes:,
201
+ live:,
202
+ debug: false
203
+ }
204
+
205
+ context = PumaRelease::Context.new(options, env: {}, ui:)
206
+ context.instance_variable_set(:@shell, shell)
207
+ context
208
+ end
209
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class ContributorResolverTest < Minitest::Test
6
+ def test_resolves_github_login_from_commit_metadata
7
+ shell = FakeShell.new(
8
+ {
9
+ ["git", "shortlog", "-s", "-n", "-e", "--no-merges", "v7.2.0..HEAD"] => " 10\tNate Berkopec <nate.berkopec@gmail.com>\n",
10
+ ["git", "log", "--format=%H%x09%aN%x09%aE", "v7.2.0..HEAD"] => [
11
+ "abc123\tNate Berkopec\tnate.berkopec@gmail.com",
12
+ "def456\tNate Berkopec\tnate.berkopec@gmail.com"
13
+ ].join("\n")
14
+ }
15
+ )
16
+
17
+ context = OpenStruct.new(metadata_repo: "puma/puma")
18
+ git_repo = PumaRelease::GitRepo.new(OpenStruct.new(shell:))
19
+ github = Object.new
20
+ def github.commit_author_login(_repo, sha)
21
+ {"abc123" => "nateberkopec", "def456" => "nateberkopec"}.fetch(sha)
22
+ end
23
+
24
+ earner = PumaRelease::ContributorResolver.new(context, git_repo:, github:).codename_earner("v7.2.0")
25
+
26
+ assert_equal "Nate Berkopec", earner.fetch(:name)
27
+ assert_equal "nateberkopec", earner.fetch(:login)
28
+ end
29
+
30
+ def test_falls_back_to_github_noreply_email_login
31
+ shell = FakeShell.new(
32
+ {
33
+ ["git", "shortlog", "-s", "-n", "-e", "--no-merges", "v7.2.0..HEAD"] => " 1\tYuki Nishijima <386234+yuki24@users.noreply.github.com>\n",
34
+ ["git", "log", "--format=%H%x09%aN%x09%aE", "v7.2.0..HEAD"] => "abc123\tYuki Nishijima\t386234+yuki24@users.noreply.github.com"
35
+ }
36
+ )
37
+
38
+ context = OpenStruct.new(metadata_repo: "puma/puma")
39
+ git_repo = PumaRelease::GitRepo.new(OpenStruct.new(shell:))
40
+ github = Object.new
41
+ def github.commit_author_login(_repo, _sha) = nil
42
+
43
+ earner = PumaRelease::ContributorResolver.new(context, git_repo:, github:).codename_earner("v7.2.0")
44
+
45
+ assert_equal "yuki24", earner.fetch(:login)
46
+ end
47
+ end