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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +114 -0
- data/exe/puma-release +7 -0
- data/lib/puma_release/agent_client.rb +163 -0
- data/lib/puma_release/build_support.rb +46 -0
- data/lib/puma_release/changelog_generator.rb +171 -0
- data/lib/puma_release/changelog_validator.rb +85 -0
- data/lib/puma_release/ci_checker.rb +108 -0
- data/lib/puma_release/cli.rb +52 -0
- data/lib/puma_release/commands/build.rb +68 -0
- data/lib/puma_release/commands/github.rb +76 -0
- data/lib/puma_release/commands/prepare.rb +178 -0
- data/lib/puma_release/commands/run.rb +51 -0
- data/lib/puma_release/context.rb +167 -0
- data/lib/puma_release/contributor_resolver.rb +52 -0
- data/lib/puma_release/error.rb +5 -0
- data/lib/puma_release/events.rb +18 -0
- data/lib/puma_release/git_repo.rb +169 -0
- data/lib/puma_release/github_client.rb +163 -0
- data/lib/puma_release/link_reference_builder.rb +49 -0
- data/lib/puma_release/options.rb +47 -0
- data/lib/puma_release/release_range.rb +69 -0
- data/lib/puma_release/repo_files.rb +85 -0
- data/lib/puma_release/shell.rb +107 -0
- data/lib/puma_release/stage_detector.rb +66 -0
- data/lib/puma_release/ui.rb +36 -0
- data/lib/puma_release/upgrade_guide_writer.rb +106 -0
- data/lib/puma_release/version.rb +5 -0
- data/lib/puma_release/version_recommender.rb +151 -0
- data/lib/puma_release.rb +28 -0
- data/test/test_helper.rb +72 -0
- data/test/unit/agent_client_test.rb +116 -0
- data/test/unit/build_command_test.rb +23 -0
- data/test/unit/build_support_test.rb +6 -0
- data/test/unit/changelog_validator_test.rb +42 -0
- data/test/unit/context_test.rb +209 -0
- data/test/unit/contributor_resolver_test.rb +47 -0
- data/test/unit/git_repo_test.rb +169 -0
- data/test/unit/github_client_test.rb +90 -0
- data/test/unit/github_command_test.rb +153 -0
- data/test/unit/options_test.rb +17 -0
- data/test/unit/prepare_test.rb +136 -0
- data/test/unit/repo_files_test.rb +119 -0
- data/test/unit/run_test.rb +32 -0
- data/test/unit/shell_test.rb +29 -0
- data/test/unit/stage_detector_test.rb +72 -0
- 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,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
|