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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8638586debf06bfeee9e4acaafb5e12eece6a740a10e8e1963101f2cf1fc81d1
4
+ data.tar.gz: ef35f60ebbec7c25836f71484f97adf16f591a383718a66ce7a33d488ba278e1
5
+ SHA512:
6
+ metadata.gz: 1821c0185b0aceea981e75a229a3d62f404261de70998474ccca5b4444cd3f7554e68e011d981fece8a39a1f33a17f44c99fa3c22487f6010d3d1953dc9c00b2
7
+ data.tar.gz: a063a259865d0f5b942522c886bd5fba628039829b7e31305e716691bde1b5bfb97bbd9cdbf18193fe92e223e059accfdad1ec9425db2f36754ba8e9370e733b
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nate Berkopec
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # puma-release
2
+
3
+ Automate Puma releases from a local Puma checkout.
4
+
5
+ `puma-release` handles the repeatable parts of the Puma release process: checking repo state, proposing the version bump, updating release files, opening the release PR, building gems, and publishing the GitHub release. It follows the upstream [`Release.md`](https://github.com/puma/puma/blob/main/Release.md) workflow.
6
+
7
+ ## Requirements
8
+
9
+ - `git`, `gh`, `bundle`
10
+ - GPG signing configured for commits and tags
11
+ - An AI agent set via `AGENT_CMD` (defaults to `claude`) for changelog and version bump generation
12
+ - [communique](https://github.com/basecamp/communique) (optional) — if available with `ANTHROPIC_API_KEY` set, it is used instead of the AI agent for changelog generation
13
+
14
+ ```sh
15
+ printf "source 'https://rubygems.org'\ngem 'puma-release'\n" > Gemfile && bundle install
16
+ ```
17
+
18
+ ## Quickstart
19
+
20
+ ```sh
21
+ exe/puma-release --repo-dir /path/to/puma run
22
+ ```
23
+
24
+ `run` detects the current release stage and executes the right step. If nothing needs doing, it says so.
25
+
26
+ ## Common workflows
27
+
28
+ **Prepare against your fork:**
29
+
30
+ ```sh
31
+ exe/puma-release --repo-dir /path/to/puma --release-repo yourname/puma run
32
+ ```
33
+
34
+ **Real release to `puma/puma`:**
35
+
36
+ ```sh
37
+ exe/puma-release --repo-dir /path/to/puma --live --release-repo puma/puma run
38
+ ```
39
+
40
+ **Stable branch (patch) release:**
41
+
42
+ Check out the stable branch in your Puma clone first, then:
43
+
44
+ ```sh
45
+ exe/puma-release --repo-dir /path/to/puma --live --release-repo puma/puma run
46
+ ```
47
+
48
+ `puma-release` auto-detects the base branch from your current git branch. Pass `--base-branch` to override:
49
+
50
+ ```sh
51
+ exe/puma-release --repo-dir /path/to/puma --base-branch 6-1-stable --live --release-repo puma/puma run
52
+ ```
53
+
54
+ **Skip CI during prepare:**
55
+
56
+ ```sh
57
+ exe/puma-release --repo-dir /path/to/puma --skip-ci-check prepare
58
+ ```
59
+
60
+ ## Commands
61
+
62
+ ```sh
63
+ puma-release [options] [command]
64
+ ```
65
+
66
+ | Command | What it does |
67
+ |---------|-------------|
68
+ | `prepare` | Verifies checkout, recommends version, updates `History.md` and `lib/puma/const.rb`, opens release PR, creates draft GitHub release on a `vX.Y.Z-proposal` tag |
69
+ | `build` | Creates and pushes the final `vX.Y.Z` tag, builds MRI and JRuby gems |
70
+ | `github` | Promotes draft release to final, uploads gem artifacts, publishes |
71
+ | `run` | Detects current stage and runs the right command |
72
+
73
+ ## Options
74
+
75
+ | Flag | Description |
76
+ |------|-------------|
77
+ | `--repo-dir PATH` | Path to the Puma checkout |
78
+ | `--base-branch BRANCH` | Base branch for the release (default: current git branch) |
79
+ | `--release-repo OWNER/REPO` | Repo for writes (branches, tags, PRs, releases) |
80
+ | `--metadata-repo OWNER/REPO` | Repo for CI and commit metadata. Defaults to `puma/puma` |
81
+ | `--live` | Allow writes to the metadata repo for a real release |
82
+ | `--skip-ci-check` | Skip CI check during `prepare` |
83
+ | `--allow-unknown-ci` | Continue when GitHub can't report CI state for `HEAD` |
84
+ | `--changelog-backend auto\|agent\|communique` | Changelog generation backend |
85
+ | `--codename NAME` | Set the release codename directly |
86
+ | `-y`, `--yes` | Skip interactive confirmations |
87
+ | `--debug` | Enable debug logging |
88
+
89
+ ## Environment
90
+
91
+ | Variable | Description |
92
+ |----------|-------------|
93
+ | `AGENT_CMD` | AI agent command. Defaults to `claude`. Set to `pi` to use pi with `--thinking xhigh` |
94
+
95
+ ## Safety model
96
+
97
+ Writes are fork-first by default:
98
+
99
+ - `metadata_repo` is read-only (CI checks, commit links, PR metadata).
100
+ - `release_repo` is where writes go (branches, tags, PRs, releases).
101
+ - Without `--live`, `puma-release` prefers your authenticated fork, then a non-upstream `origin`. If it can't find a plausible fork, it refuses writes unless you pass `--release-repo` or `--live`.
102
+ - Writing to `puma/puma` requires `--live`.
103
+ - In live mode, every mutating git command and GitHub write shows the exact command and asks for confirmation unless you pass `--yes`.
104
+ - `prepare` uses a `vX.Y.Z-proposal` tag for the draft; the real `vX.Y.Z` tag is only created during `build`.
105
+
106
+ ## Development
107
+
108
+ ```sh
109
+ bundle exec rake test
110
+ ```
111
+
112
+ ## License
113
+
114
+ MIT. See [LICENSE](LICENSE).
data/exe/puma-release ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "puma_release"
6
+
7
+ PumaRelease::CLI.new(ARGV, env: ENV).run
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module PumaRelease
6
+ class AgentClient
7
+ attr_reader :context, :last_model_name
8
+
9
+ def initialize(context)
10
+ @context = context
11
+ end
12
+
13
+ def ask_for_json(prompt, system_prompt:, schema:)
14
+ @last_model_name = nil
15
+ payload = if pi?
16
+ ask_pi_for_json(prompt, system_prompt:, schema:)
17
+ else
18
+ ask_claude_for_json(prompt, system_prompt:, schema:)
19
+ end
20
+ payload = JSON.parse(payload) if payload.is_a?(String)
21
+ payload
22
+ rescue JSON::ParserError => e
23
+ raise Error, "#{context.agent_cmd} returned invalid JSON: #{e.message}"
24
+ end
25
+
26
+ def ask_for_text(prompt, system_prompt:)
27
+ return context.shell.stream_output(*pi_command(prompt, system_prompt:)).strip if pi?
28
+
29
+ context.shell.stream_output(*claude_command(system_prompt:), stdin_data: prompt).strip
30
+ end
31
+
32
+ private
33
+
34
+ def pi?
35
+ File.basename(context.shell.split(context.agent_cmd).first.to_s) == "pi"
36
+ end
37
+
38
+ def ask_pi_for_json(prompt, system_prompt:, schema:)
39
+ payload = nil
40
+ progress = {ticks: 0, shown: false}
41
+
42
+ context.shell.stream_json_events(*pi_command(json_prompt(prompt, schema), system_prompt:, mode: "json")) do |event|
43
+ case event["type"]
44
+ when "message_update", "tool_execution_update"
45
+ tick_structured_progress(progress)
46
+ when "message_end"
47
+ next unless event.dig("message", "role") == "assistant"
48
+
49
+ @last_model_name ||= extract_model_name(event.fetch("message"))
50
+ payload = extract_text_from_message(event.fetch("message"))
51
+ end
52
+ end
53
+
54
+ payload || raise(Error, "#{context.agent_cmd} returned no JSON payload")
55
+ ensure
56
+ finish_structured_progress(progress)
57
+ end
58
+
59
+ def pi_command(prompt, system_prompt:, mode: nil)
60
+ command = context.shell.split(context.agent_cmd) + [
61
+ "-p",
62
+ "--thinking", "xhigh",
63
+ "--tools", "read,bash",
64
+ "--no-extensions",
65
+ "--extension", pi_guard_extension_path,
66
+ "--no-skills",
67
+ "--no-prompt-templates",
68
+ "--no-themes",
69
+ "--system-prompt", system_prompt
70
+ ]
71
+ command += ["--mode", mode] if mode
72
+ command + [prompt]
73
+ end
74
+
75
+ def json_prompt(prompt, schema)
76
+ <<~PROMPT
77
+ #{prompt}
78
+
79
+ Return only valid JSON matching this schema:
80
+ #{JSON.pretty_generate(schema)}
81
+ PROMPT
82
+ end
83
+
84
+ def pi_guard_extension_path
85
+ File.expand_path("../../config/pi-agent-guard.ts", __dir__)
86
+ end
87
+
88
+ def extract_text_from_message(message)
89
+ text_content = Array(message.fetch("content", [])).select { |content| content["type"] == "text" }
90
+ final_answer = text_content.select { |content| text_signature_phase(content["textSignature"]) == "final_answer" }
91
+ payload = if final_answer.empty?
92
+ text_content.rfind { |content| !content["text"].to_s.strip.empty? }.to_h["text"]
93
+ else
94
+ final_answer.map { |content| content["text"] }.join
95
+ end
96
+ payload.to_s
97
+ end
98
+
99
+ def text_signature_phase(signature)
100
+ return nil if signature.to_s.empty?
101
+
102
+ JSON.parse(signature).fetch("phase", nil)
103
+ rescue JSON::ParserError
104
+ nil
105
+ end
106
+
107
+ def extract_model_name(payload)
108
+ return context.comment_author_model_name unless payload
109
+
110
+ model = payload["model"].to_s.strip
111
+ provider = payload["provider"].to_s.strip
112
+ return "#{provider}/#{model}" unless provider.empty? || model.empty?
113
+ return model unless model.empty?
114
+
115
+ context.comment_author_model_name
116
+ end
117
+
118
+ def tick_structured_progress(progress)
119
+ progress[:ticks] += 1
120
+ return unless progress[:ticks] == 1 || (progress[:ticks] % 25).zero?
121
+
122
+ $stdout.print(".")
123
+ $stdout.flush
124
+ progress[:shown] = true
125
+ end
126
+
127
+ def finish_structured_progress(progress)
128
+ return unless progress[:shown]
129
+
130
+ $stdout.puts
131
+ end
132
+
133
+ def ask_claude_for_json(prompt, system_prompt:, schema:)
134
+ payload = nil
135
+ context.shell.stream_json_events(*claude_command(system_prompt:, schema:), stdin_data: prompt) do |event|
136
+ @last_model_name ||= extract_model_name(event["message"] || event)
137
+ case event["type"]
138
+ when "assistant"
139
+ Array(event.dig("message", "content")).each do |content|
140
+ next unless content["type"] == "text"
141
+ $stdout.print(content["text"])
142
+ $stdout.flush
143
+ end
144
+ when "result"
145
+ payload = event["structured_output"] || JSON.parse(event.fetch("result"))
146
+ end
147
+ end
148
+ payload
149
+ end
150
+
151
+ def claude_command(system_prompt:, schema: nil)
152
+ command = context.shell.split(context.agent_cmd) + [
153
+ "-p",
154
+ "--allowedTools", "",
155
+ "--permission-mode", "bypassPermissions",
156
+ "--system-prompt", system_prompt
157
+ ]
158
+ return command unless schema
159
+
160
+ command + ["--output-format", "stream-json", "--json-schema", JSON.generate(schema)]
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class BuildSupport
5
+ attr_reader :context
6
+
7
+ def initialize(context)
8
+ @context = context
9
+ end
10
+
11
+ def build_jruby_gem(version)
12
+ return build_with_mise(version) if context.shell.available?("mise")
13
+ return build_with_local_jruby(version) if context.shell.available?("jruby")
14
+
15
+ false
16
+ end
17
+
18
+ private
19
+
20
+ def build_with_mise(version)
21
+ jruby_version = latest_jruby_version
22
+ return build_with_local_jruby(version) if jruby_version.nil? && context.shell.available?("jruby")
23
+ return false if jruby_version.nil?
24
+
25
+ context.ui.info("Building JRuby gem with mise and jruby@#{jruby_version}...")
26
+ context.shell.run("mise", "exec", "jruby@#{jruby_version}", "--", "bundle", "exec", "rake", "java", "gem")
27
+ context.ui.info("Built: pkg/puma-#{version}-java.gem")
28
+ true
29
+ end
30
+
31
+ def build_with_local_jruby(version)
32
+ context.ui.info("Building JRuby gem with local jruby...")
33
+ context.shell.run("jruby", "-S", "bundle", "exec", "rake", "java", "gem")
34
+ context.ui.info("Built: pkg/puma-#{version}-java.gem")
35
+ true
36
+ end
37
+
38
+ def latest_jruby_version
39
+ result = context.shell.run("mise", "latest", "jruby", allow_failure: true)
40
+ return result.stdout.strip if result.success? && !result.stdout.strip.empty?
41
+
42
+ context.ui.warn("mise could not determine a JRuby version.")
43
+ nil
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class ChangelogGenerator
5
+ SYSTEM_PROMPT = <<~PROMPT.strip
6
+ Draft a Puma release changelog as structured data.
7
+ Use only these categories: Features, Bugfixes, Performance, Refactor, Docs, CI,
8
+ Breaking changes.
9
+ Rules:
10
+ - Only include categories that have entries.
11
+ - Every entry must have at least one PR number.
12
+ - Keep descriptions concise and user-facing.
13
+ - Omit purely internal noise unless it represents meaningful test infrastructure work.
14
+ - Prefer combining closely related PRs into one entry when appropriate.
15
+ PROMPT
16
+
17
+ SCHEMA = {
18
+ type: "object",
19
+ required: ["entries"],
20
+ additionalProperties: false,
21
+ properties: {
22
+ entries: {
23
+ type: "array",
24
+ minItems: 1,
25
+ items: {
26
+ type: "object",
27
+ required: %w[category description pr_numbers],
28
+ additionalProperties: false,
29
+ properties: {
30
+ category: {
31
+ type: "string",
32
+ enum: ["Features", "Bugfixes", "Performance", "Refactor", "Docs", "CI", "Breaking changes"]
33
+ },
34
+ description: {type: "string", minLength: 1},
35
+ pr_numbers: {
36
+ type: "array",
37
+ minItems: 1,
38
+ items: {type: "integer", minimum: 1}
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }.freeze
45
+
46
+ attr_reader :context, :release_range, :new_tag, :last_tag
47
+
48
+ def initialize(context, release_range, new_tag:, last_tag:)
49
+ @context = context
50
+ @release_range = release_range
51
+ @new_tag = new_tag
52
+ @last_tag = last_tag
53
+ end
54
+
55
+ def call
56
+ resolved = backend
57
+ context.ui.info("Changelog backend: #{resolved.class.name.split("::").last.delete_suffix("Backend").downcase}")
58
+ 5.times do |index|
59
+ context.ui.info("Generating changelog (attempt #{index + 1}/5)...")
60
+ changelog = resolved.call.strip
61
+ errors = validator.validate(changelog)
62
+ return changelog if errors.empty?
63
+
64
+ context.ui.warn("Generated changelog did not match the required format:")
65
+ errors.each { |message| context.ui.warn(message) }
66
+ end
67
+ raise Error, "Could not generate a valid changelog after 5 attempts."
68
+ end
69
+
70
+ private
71
+
72
+ def backend
73
+ preferred = context.changelog_backend
74
+ return CommuniqueBackend.new(context, new_tag, last_tag) if preferred == "communique"
75
+ return AgentBackend.new(context, release_range, new_tag, last_tag) if preferred == "agent"
76
+ return CommuniqueBackend.new(context, new_tag, last_tag) if communique_available?
77
+
78
+ AgentBackend.new(context, release_range, new_tag, last_tag)
79
+ end
80
+
81
+ def communique_available?
82
+ context.shell.available?("communique") && !context.env.fetch("ANTHROPIC_API_KEY", "").empty?
83
+ end
84
+
85
+ def validator = @validator ||= ChangelogValidator.new
86
+
87
+ class AgentBackend
88
+ def initialize(context, release_range, new_tag, last_tag)
89
+ @context = context
90
+ @release_range = release_range
91
+ @new_tag = new_tag
92
+ @last_tag = last_tag
93
+ end
94
+
95
+ def call
96
+ context.ui.info("Asking #{context.agent_cmd} to draft changelog entries...")
97
+ payload = agent.ask_for_json(prompt, system_prompt: SYSTEM_PROMPT, schema: SCHEMA)
98
+ context.ui.info("Rendering changelog...")
99
+ render(payload.fetch("entries"))
100
+ end
101
+
102
+ private
103
+
104
+ CATEGORY_ORDER = ["Features", "Bugfixes", "Performance", "Refactor", "Docs", "CI", "Breaking changes"].freeze
105
+
106
+ attr_reader :context, :release_range, :new_tag, :last_tag
107
+
108
+ def prompt
109
+ <<~PROMPT
110
+ Draft the changelog entries for #{new_tag} from #{last_tag}..HEAD.
111
+
112
+ Return JSON only.
113
+ Each entry must include:
114
+ - category: one of the allowed categories
115
+ - description: concise release-note text with no PR refs in the text
116
+ - pr_numbers: an array of GitHub PR numbers supporting the entry
117
+
118
+ #{release_range.to_prompt_context}
119
+ PROMPT
120
+ end
121
+
122
+ def render(entries)
123
+ grouped = entries.group_by { |entry| entry.fetch("category") }
124
+ CATEGORY_ORDER.filter_map do |category|
125
+ next if grouped[category].to_a.empty?
126
+
127
+ (["* #{category}"] + grouped.fetch(category).map { |entry| render_entry(entry) }).join("\n")
128
+ end.join("\n\n")
129
+ end
130
+
131
+ def render_entry(entry)
132
+ pr_refs = entry.fetch("pr_numbers").uniq.map { |number| "[#" + number.to_s + "]" }
133
+ description = entry.fetch("description").strip.gsub(/\s+/, " ").sub(/[.。]\z/, "")
134
+ " * #{description} (#{pr_refs.join(", ")})"
135
+ end
136
+
137
+ def agent = @agent ||= AgentClient.new(context)
138
+ end
139
+
140
+ class CommuniqueBackend
141
+ CONFIG = File.expand_path("../../config/communique.toml", __dir__)
142
+
143
+ def initialize(context, new_tag, last_tag)
144
+ @context = context
145
+ @new_tag = new_tag
146
+ @last_tag = last_tag
147
+ end
148
+
149
+ def call
150
+ result = context.shell.run(
151
+ "communique", "generate", new_tag, last_tag,
152
+ "--concise", "--dry-run", "--config", CONFIG,
153
+ env_overrides: github_env,
154
+ allow_failure: true
155
+ )
156
+ raise Error, "communique failed. Is ANTHROPIC_API_KEY set?" unless result.success?
157
+
158
+ result.stdout
159
+ end
160
+
161
+ private
162
+
163
+ attr_reader :context, :new_tag, :last_tag
164
+
165
+ def github_env
166
+ token = context.github_token
167
+ token.empty? ? {} : {"GITHUB_TOKEN" => token}
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class ChangelogValidator
5
+ CATEGORY_ORDER = {
6
+ "Features" => 1,
7
+ "Bugfixes" => 2,
8
+ "Performance" => 3,
9
+ "Refactor" => 4,
10
+ "Docs" => 5,
11
+ "CI" => 6,
12
+ "Breaking changes" => 7
13
+ }.freeze
14
+
15
+ CATEGORY_REGEX = /^\* (#{Regexp.union(CATEGORY_ORDER.keys).source})$/
16
+ ITEM_REGEX = /^ \* .+ \(\[#(\d+)\](, \[#(\d+)\])*\)$/
17
+ INLINE_LINK_REGEX = /\[[^\]]+\]\(/
18
+
19
+ def validate(changelog)
20
+ state = :category
21
+ current_category = nil
22
+ last_order = 0
23
+ counts = {categories: 0, items: 0}
24
+ seen = {}
25
+ errors = []
26
+
27
+ changelog.each_line(chomp: true).with_index(1) do |raw_line, line_number|
28
+ line = raw_line.delete_suffix("\r")
29
+ handle_line(line, line_number, state, current_category, last_order, counts, seen, errors)
30
+ state, current_category, last_order = transition(line, state, current_category, last_order, seen)
31
+ end
32
+
33
+ errors << "Line #{changelog.lines.count}: category '* #{current_category}' must contain at least one item." if state == :item
34
+ errors << "Changelog must contain at least one category." if counts.fetch(:categories).zero?
35
+ errors << "Changelog must contain at least one changelog item." if counts.fetch(:items).zero?
36
+ errors
37
+ end
38
+
39
+ private
40
+
41
+ def handle_line(line, line_number, state, current_category, last_order, counts, seen, errors)
42
+ if line.empty?
43
+ errors << "Line #{line_number}: category '* #{current_category}' must contain at least one item." if state == :item
44
+ return
45
+ end
46
+ return errors << "Line #{line_number}: headings are not allowed in the changelog body." if line.start_with?("#")
47
+ return handle_category(line, line_number, state, current_category, last_order, counts, seen, errors) if line.match?(CATEGORY_REGEX)
48
+
49
+ errors << "Line #{line_number}: inline markdown links are not allowed; use reference-style PR refs like ([#123])." if line.match?(INLINE_LINK_REGEX)
50
+ return handle_item(line, line_number, state, counts, errors) if line.match?(ITEM_REGEX)
51
+
52
+ errors << if line.start_with?("*")
53
+ "Line #{line_number}: unsupported category. Allowed categories: #{CATEGORY_ORDER.keys.join(", ")}."
54
+ else
55
+ "Line #{line_number}: unexpected content. Expected a category heading or an item like ' * Description ([#123])'."
56
+ end
57
+ end
58
+
59
+ def handle_category(line, line_number, state, current_category, last_order, counts, seen, errors)
60
+ category = line.match(CATEGORY_REGEX)[1]
61
+ order = CATEGORY_ORDER.fetch(category)
62
+ errors << "Line #{line_number}: category '* #{current_category}' must contain at least one item before the next category." if state == :item
63
+ errors << "Line #{line_number}: blank line required between categories." if state == :item_or_blank
64
+ errors << "Line #{line_number}: categories must appear in this order: #{CATEGORY_ORDER.keys.join(", ")}." if order < last_order
65
+ errors << "Line #{line_number}: duplicate category '* #{category}'." if seen[category]
66
+ counts[:categories] += 1
67
+ end
68
+
69
+ def handle_item(line, line_number, state, counts, errors)
70
+ errors << "Line #{line_number}: changelog items must appear under a category heading." unless %i[item item_or_blank].include?(state)
71
+ counts[:items] += 1
72
+ end
73
+
74
+ def transition(line, state, current_category, last_order, seen)
75
+ return [:category, current_category, last_order] if line.empty? && %i[item item_or_blank].include?(state)
76
+ return [state, current_category, last_order] if line.empty? || line.start_with?("#")
77
+ return [:item_or_blank, current_category, last_order] if line.match?(ITEM_REGEX)
78
+ return [state, current_category, last_order] unless (match = line.match(CATEGORY_REGEX))
79
+
80
+ current_category = match[1]
81
+ seen[current_category] = true
82
+ [:item, current_category, CATEGORY_ORDER.fetch(current_category)]
83
+ end
84
+ end
85
+ end