changelog-builder 1.0.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/.gitignore +23 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/.ruby-version +1 -0
- data/.yardopts +3 -0
- data/CHANGELOG.md +122 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +70 -0
- data/LICENSE.txt +21 -0
- data/README.md +204 -0
- data/Rakefile +21 -0
- data/bin/console +15 -0
- data/bin/setup +21 -0
- data/changelogger.gemspec +37 -0
- data/docs/demo.gif +0 -0
- data/exe/changelogger +5 -0
- data/lib/changelogger/branches_window.rb +704 -0
- data/lib/changelogger/changelog_generator.rb +70 -0
- data/lib/changelogger/cli.rb +247 -0
- data/lib/changelogger/git.rb +45 -0
- data/lib/changelogger/header.rb +45 -0
- data/lib/changelogger/main.rb +29 -0
- data/lib/changelogger/preview_window.rb +130 -0
- data/lib/changelogger/repo_info.rb +78 -0
- data/lib/changelogger/tui.rb +39 -0
- data/lib/changelogger/version.rb +5 -0
- data/lib/changelogger/versioner.rb +68 -0
- data/lib/changelogger.rb +9 -0
- metadata +77 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Changelogger
|
|
4
|
+
# Changelog generator.
|
|
5
|
+
class ChangelogGenerator
|
|
6
|
+
class << self
|
|
7
|
+
# +Changelogger::ChangelogGenerator.build_sections+ -> String
|
|
8
|
+
#
|
|
9
|
+
# Build sections from versioned commits.
|
|
10
|
+
#
|
|
11
|
+
# @param [Array<(Integer, Changelogger::Commit, String)>] versioned
|
|
12
|
+
# @return [String]
|
|
13
|
+
def build_sections(versioned)
|
|
14
|
+
versioned.map do |(_i, c, v)|
|
|
15
|
+
lines = []
|
|
16
|
+
lines << "## [#{v}] - #{c.date}"
|
|
17
|
+
lines << ""
|
|
18
|
+
lines << "- #{c.subject} (#{c.short})"
|
|
19
|
+
c.body.split("\n").each { |b| lines << " #{b}" } unless c.body.nil? || c.body.empty?
|
|
20
|
+
lines << ""
|
|
21
|
+
lines.join("\n")
|
|
22
|
+
end.join("\n")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# +Changelogger::ChangelogGenerator.render+ -> String
|
|
26
|
+
#
|
|
27
|
+
# Render the changelog content.
|
|
28
|
+
#
|
|
29
|
+
# @param [Array<Changelogger::Commit>] commits
|
|
30
|
+
# @param [Array<String>] anchor_shas
|
|
31
|
+
# @param [Integer] major
|
|
32
|
+
# @param [Integer] minor_start
|
|
33
|
+
# @param [Integer] base_patch
|
|
34
|
+
# @return [String]
|
|
35
|
+
def render(commits, anchor_shas, major: 0, minor_start: 1, base_patch: 10)
|
|
36
|
+
sha_to_idx = commits.map(&:sha)
|
|
37
|
+
short_to_idx = commits.map(&:short)
|
|
38
|
+
|
|
39
|
+
anchor_indices = anchor_shas.filter_map do |sha|
|
|
40
|
+
full_idx = sha_to_idx.index(sha)
|
|
41
|
+
full_idx || short_to_idx.index(sha[0, 7])
|
|
42
|
+
end
|
|
43
|
+
raise "Need at least 2 valid commits selected" if anchor_indices.size < 2
|
|
44
|
+
|
|
45
|
+
versioned = Versioner.assign(commits, anchor_indices, major: major, minor_start: minor_start,
|
|
46
|
+
base_patch: base_patch)
|
|
47
|
+
header = "## [Unreleased]\n\n"
|
|
48
|
+
sections = build_sections(versioned)
|
|
49
|
+
[header, sections].join("\n")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# +Changelogger::ChangelogGenerator.generate+ -> String
|
|
53
|
+
#
|
|
54
|
+
# Generate the changelog file.
|
|
55
|
+
#
|
|
56
|
+
# @param [Array<Changelogger::Commit>] commits
|
|
57
|
+
# @param [Array<String>] anchor_shas
|
|
58
|
+
# @param [String] path
|
|
59
|
+
# @param [Integer] major
|
|
60
|
+
# @param [Integer] minor_start
|
|
61
|
+
# @param [Integer] base_patch
|
|
62
|
+
# @return [String] path
|
|
63
|
+
def generate(commits, anchor_shas, path: "CHANGELOG.md", major: 0, minor_start: 1, base_patch: 10)
|
|
64
|
+
content = render(commits, anchor_shas, major: major, minor_start: minor_start, base_patch: base_patch)
|
|
65
|
+
File.write(path, content)
|
|
66
|
+
path
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
require_relative "version"
|
|
8
|
+
require_relative "git"
|
|
9
|
+
require_relative "changelog_generator"
|
|
10
|
+
|
|
11
|
+
module Changelogger
|
|
12
|
+
# +Changelogger::CLI+ provides both TUI and non-interactive CLI entrypoints.
|
|
13
|
+
class CLI
|
|
14
|
+
# +Changelogger::CLI.start+ -> Integer
|
|
15
|
+
#
|
|
16
|
+
# Parses CLI arguments and runs either interactive TUI or headless generation.
|
|
17
|
+
# Returns an exit code (0 on success).
|
|
18
|
+
#
|
|
19
|
+
# @param [Array<String>] argv command line arguments (default: ARGV)
|
|
20
|
+
# @return [Integer] exit status code
|
|
21
|
+
def self.start(argv = ARGV)
|
|
22
|
+
new.start(argv)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# +Changelogger::CLI#start+ -> Integer
|
|
26
|
+
#
|
|
27
|
+
# @param [Array<String>] argv
|
|
28
|
+
# @return [Integer] exit code
|
|
29
|
+
def start(argv)
|
|
30
|
+
mode = :tui
|
|
31
|
+
anchors = []
|
|
32
|
+
output = "CHANGELOG.md"
|
|
33
|
+
dry_run = false
|
|
34
|
+
major = 0
|
|
35
|
+
minor_start = 1
|
|
36
|
+
base_patch = 10
|
|
37
|
+
|
|
38
|
+
parser = OptionParser.new do |o|
|
|
39
|
+
o.banner = "Usage: changelogger [options] [REPO]"
|
|
40
|
+
o.separator ""
|
|
41
|
+
o.separator "REPO can be:"
|
|
42
|
+
o.separator " - local path (/path/to/repo)"
|
|
43
|
+
o.separator " - GitHub slug (owner/repo)"
|
|
44
|
+
o.separator " - git URL (https://... or git@...)"
|
|
45
|
+
o.separator ""
|
|
46
|
+
o.on("-g", "--generate", "Non-interactive: generate CHANGELOG from anchors") { mode = :generate }
|
|
47
|
+
o.on("-a", "--anchors x,y,z", Array, "Anchors (SHA/tag/branch), 2+ required in chronological order") do |v|
|
|
48
|
+
anchors = v || []
|
|
49
|
+
end
|
|
50
|
+
o.on("-o", "--output PATH", "Output file (default: CHANGELOG.md)") { |v| output = v }
|
|
51
|
+
o.on("--major N", Integer, "Major version (default: 0)") { |v| major = v }
|
|
52
|
+
o.on("--minor-start N", Integer, "Minor start index (default: 1)") { |v| minor_start = v }
|
|
53
|
+
o.on("--base-patch N", Integer, "Patch spacing base (default: 10)") { |v| base_patch = v }
|
|
54
|
+
o.on("--dry-run", "Print to stdout (do not write file)") { dry_run = true }
|
|
55
|
+
o.on("--tui", "Force interactive TUI (default if no --generate)") { mode = :tui }
|
|
56
|
+
o.on("-v", "--version", "Print version") do
|
|
57
|
+
puts Changelogger::VERSION
|
|
58
|
+
return 0
|
|
59
|
+
end
|
|
60
|
+
o.on("-h", "--help", "Show help") do
|
|
61
|
+
puts o
|
|
62
|
+
return 0
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
repo_spec = nil
|
|
67
|
+
begin
|
|
68
|
+
parser.order!(argv) { |arg| repo_spec ||= arg }
|
|
69
|
+
rescue OptionParser::ParseError => e
|
|
70
|
+
warn e.message
|
|
71
|
+
warn parser
|
|
72
|
+
return 2
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
with_repo(repo_spec) do
|
|
76
|
+
case mode
|
|
77
|
+
when :generate
|
|
78
|
+
return run_generate(anchors, output, dry_run, major, minor_start, base_patch)
|
|
79
|
+
else
|
|
80
|
+
return run_tui(output, major, minor_start, base_patch)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# +Changelogger::CLI#run_tui+ -> Integer
|
|
88
|
+
#
|
|
89
|
+
# Launches TUI and writes the CHANGELOG if the user selected 2+ anchors.
|
|
90
|
+
# @param [String] output output path
|
|
91
|
+
# @param [Integer] major
|
|
92
|
+
# @param [Integer] minor_start
|
|
93
|
+
# @param [Integer] base_patch
|
|
94
|
+
# @return [Integer] exit code
|
|
95
|
+
def run_tui(output, major, minor_start, base_patch)
|
|
96
|
+
require_relative "tui"
|
|
97
|
+
selected = Changelogger::TUI.run
|
|
98
|
+
return 0 if selected.nil? # cancelled
|
|
99
|
+
|
|
100
|
+
if selected.size >= 2
|
|
101
|
+
commits = Changelogger::Git.commits
|
|
102
|
+
path = Changelogger::ChangelogGenerator.generate(
|
|
103
|
+
commits,
|
|
104
|
+
selected,
|
|
105
|
+
path: output,
|
|
106
|
+
major: major,
|
|
107
|
+
minor_start: minor_start,
|
|
108
|
+
base_patch: base_patch
|
|
109
|
+
)
|
|
110
|
+
puts "Wrote #{path} ✅"
|
|
111
|
+
0
|
|
112
|
+
else
|
|
113
|
+
puts "No CHANGELOG generated (need at least 2 commits)."
|
|
114
|
+
1
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# +Changelogger::CLI#run_generate+ -> Integer
|
|
119
|
+
#
|
|
120
|
+
# Headless mode: resolves anchors, renders or writes the CHANGELOG.
|
|
121
|
+
# @param [Array<String>] anchor_tokens tokens (sha/tag/branch)
|
|
122
|
+
# @param [String] output path
|
|
123
|
+
# @param [Boolean] dry_run if true, print to stdout
|
|
124
|
+
# @param [Integer] major
|
|
125
|
+
# @param [Integer] minor_start
|
|
126
|
+
# @param [Integer] base_patch
|
|
127
|
+
# @return [Integer] exit code
|
|
128
|
+
def run_generate(anchor_tokens, output, dry_run, major, minor_start, base_patch)
|
|
129
|
+
if anchor_tokens.size < 2
|
|
130
|
+
warn "Error: --generate requires at least 2 --anchors (SHA/tag/branch)."
|
|
131
|
+
return 2
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
resolved = anchor_tokens.filter_map { |t| resolve_commit(t) }
|
|
135
|
+
if resolved.size < 2
|
|
136
|
+
warn "Error: could not resolve at least two anchors: #{anchor_tokens.inspect}"
|
|
137
|
+
return 2
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
commits = Changelogger::Git.commits
|
|
141
|
+
if dry_run
|
|
142
|
+
puts Changelogger::ChangelogGenerator.render(
|
|
143
|
+
commits,
|
|
144
|
+
resolved,
|
|
145
|
+
major: major,
|
|
146
|
+
minor_start: minor_start,
|
|
147
|
+
base_patch: base_patch
|
|
148
|
+
)
|
|
149
|
+
else
|
|
150
|
+
path = Changelogger::ChangelogGenerator.generate(
|
|
151
|
+
commits,
|
|
152
|
+
resolved,
|
|
153
|
+
path: output,
|
|
154
|
+
major: major,
|
|
155
|
+
minor_start: minor_start,
|
|
156
|
+
base_patch: base_patch
|
|
157
|
+
)
|
|
158
|
+
puts "Wrote #{path} ✅"
|
|
159
|
+
end
|
|
160
|
+
0
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ---------- repo resolution ----------
|
|
164
|
+
|
|
165
|
+
# +Changelogger::CLI#with_repo+ -> Integer
|
|
166
|
+
#
|
|
167
|
+
# Changes directory into a target repo (path/slug/url) for the duration of the block.
|
|
168
|
+
# Clones remotes into a temporary directory and cleans it up afterwards.
|
|
169
|
+
#
|
|
170
|
+
# @param [String, nil] repo_spec path, GitHub slug (owner/repo), or git URL
|
|
171
|
+
# @yield [] block to execute inside the repo
|
|
172
|
+
# @return [Integer] 0
|
|
173
|
+
def with_repo(repo_spec)
|
|
174
|
+
orig_dir = Dir.pwd
|
|
175
|
+
tmp_dir = nil
|
|
176
|
+
|
|
177
|
+
if repo_spec && !repo_spec.empty?
|
|
178
|
+
if File.directory?(repo_spec)
|
|
179
|
+
Dir.chdir(File.expand_path(repo_spec))
|
|
180
|
+
unless inside_git_repo?
|
|
181
|
+
warn "#{repo_spec.inspect} is not a git repository. Continuing, but output may be empty."
|
|
182
|
+
end
|
|
183
|
+
else
|
|
184
|
+
url = looks_like_url?(repo_spec) ? repo_spec : github_slug_to_url(repo_spec)
|
|
185
|
+
if url
|
|
186
|
+
tmp_dir = Dir.mktmpdir("changelogger-")
|
|
187
|
+
clone_ok = system("git", "clone", "--no-checkout", "--filter=blob:none", "--depth=1000", url, tmp_dir,
|
|
188
|
+
out: File::NULL, err: File::NULL)
|
|
189
|
+
clone_ok ||= system("git", "clone", url, tmp_dir)
|
|
190
|
+
if clone_ok
|
|
191
|
+
Dir.chdir(tmp_dir)
|
|
192
|
+
else
|
|
193
|
+
warn "Failed to clone #{url}. Running in current directory."
|
|
194
|
+
end
|
|
195
|
+
else
|
|
196
|
+
warn "Unrecognized repo argument: #{repo_spec.inspect}. Expected a directory, GitHub slug (owner/repo), or git URL."
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
yield
|
|
202
|
+
0
|
|
203
|
+
ensure
|
|
204
|
+
begin
|
|
205
|
+
Dir.chdir(orig_dir)
|
|
206
|
+
rescue StandardError
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
FileUtils.remove_entry(tmp_dir) if tmp_dir && File.directory?(tmp_dir)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# +Changelogger::CLI#inside_git_repo?+ -> Bool
|
|
213
|
+
# @return [Bool] true if inside a git work tree
|
|
214
|
+
def inside_git_repo?
|
|
215
|
+
system("git", "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# +Changelogger::CLI#looks_like_url?+ -> Bool
|
|
219
|
+
# @param [String] s
|
|
220
|
+
# @return [Bool]
|
|
221
|
+
def looks_like_url?(s)
|
|
222
|
+
s =~ %r{\Ahttps?://} || s.start_with?("git@")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# +Changelogger::CLI#github_slug_to_url+ -> String, nil
|
|
226
|
+
# Converts "owner/repo" or "github.com/owner/repo" into an https URL.
|
|
227
|
+
# @param [String] s
|
|
228
|
+
# @return [String, nil]
|
|
229
|
+
def github_slug_to_url(s)
|
|
230
|
+
if s =~ %r{\Ahttps?://(?:www\.)?github\.com/([\w.-]+/[\w.-]+)(?:\.git)?\z}i
|
|
231
|
+
"https://github.com/#{::Regexp.last_match(1)}.git"
|
|
232
|
+
elsif s =~ %r{\A(?:github\.com/)?([\w.-]+/[\w.-]+)(?:\.git)?\z}i
|
|
233
|
+
"https://github.com/#{::Regexp.last_match(1)}.git"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# +Changelogger::CLI#resolve_commit+ -> String, nil
|
|
238
|
+
#
|
|
239
|
+
# Resolves a token (sha/tag/branch) to a 40-char commit SHA.
|
|
240
|
+
# @param [String] token
|
|
241
|
+
# @return [String, nil]
|
|
242
|
+
def resolve_commit(token)
|
|
243
|
+
full = `git rev-parse -q --verify #{token}^{commit} 2>/dev/null`.strip
|
|
244
|
+
$CHILD_STATUS.success? && full.match?(/\A[0-9a-f]{40}\z/i) ? full : nil
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Changelogger
|
|
4
|
+
# +Changelogger::Commit+ is a plain struct representing a git commit.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [rw] sha
|
|
7
|
+
# @return [String] Full 40-char SHA
|
|
8
|
+
# @!attribute [rw] short
|
|
9
|
+
# @return [String] Abbreviated SHA
|
|
10
|
+
# @!attribute [rw] date
|
|
11
|
+
# @return [String] Commit date (YYYY-MM-DD)
|
|
12
|
+
# @!attribute [rw] subject
|
|
13
|
+
# @return [String] First line message
|
|
14
|
+
# @!attribute [rw] body
|
|
15
|
+
# @return [String] Remaining message body (could be empty)
|
|
16
|
+
Commit = Struct.new(:sha, :short, :date, :subject, :body, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
# +Changelogger::Git+ wraps read-only git queries used by this gem.
|
|
19
|
+
class Git
|
|
20
|
+
# Separator used for pretty-format parsing
|
|
21
|
+
SEP = "\x01"
|
|
22
|
+
|
|
23
|
+
# +Changelogger::Git.commits+ -> Array<Changelogger::Commit>
|
|
24
|
+
#
|
|
25
|
+
# Returns repository commits in chronological order (oldest-first).
|
|
26
|
+
# Uses: git log --date=short --reverse --pretty=format:'...'
|
|
27
|
+
#
|
|
28
|
+
# @return [Array<Commit>]
|
|
29
|
+
def self.commits # rubocop:disable Metrics/MethodLength]
|
|
30
|
+
format = "%H#{SEP}%h#{SEP}%ad#{SEP}%s#{SEP}%b"
|
|
31
|
+
cmd = "git log --date=short --reverse --pretty=format:'#{format}'"
|
|
32
|
+
out = `#{cmd}`
|
|
33
|
+
out.split("\n").map do |line|
|
|
34
|
+
sha, short, date, subject, body = line.split(SEP, 5)
|
|
35
|
+
Commit.new(
|
|
36
|
+
sha: sha,
|
|
37
|
+
short: short,
|
|
38
|
+
date: date,
|
|
39
|
+
subject: (subject || "").strip,
|
|
40
|
+
body: (body || "").strip
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Changelogger
|
|
4
|
+
# +Changelogger::Header+ draws the top header (title/version) of the TUI.
|
|
5
|
+
class Header
|
|
6
|
+
# +Changelogger::Header.new+ -> Changelogger::Header
|
|
7
|
+
#
|
|
8
|
+
# Draws a single header line with the gem name and version.
|
|
9
|
+
#
|
|
10
|
+
# @param [Integer] height window height
|
|
11
|
+
# @param [Integer] width window width
|
|
12
|
+
# @param [Integer] top top position
|
|
13
|
+
# @param [Integer] left left position
|
|
14
|
+
# @return [Header]
|
|
15
|
+
def initialize(height: 0, width: Curses.cols, top: 0, left: 0)
|
|
16
|
+
@height = height
|
|
17
|
+
@width = width
|
|
18
|
+
@top = top
|
|
19
|
+
@left = left
|
|
20
|
+
header_win
|
|
21
|
+
line
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# +Changelogger::Header.header_win+ -> void
|
|
27
|
+
# @private
|
|
28
|
+
# Initializes the header frame.
|
|
29
|
+
# @return [void]
|
|
30
|
+
def header_win
|
|
31
|
+
@header_win = Curses::Window.new(@height, @width, @top, @left)
|
|
32
|
+
@header_win.box(" ", " ", " ")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# +Changelogger::Header.line+ -> void
|
|
36
|
+
# @private
|
|
37
|
+
# Renders the header text.
|
|
38
|
+
# @return [void]
|
|
39
|
+
def line
|
|
40
|
+
line = @header_win.subwin(@height, @width, @top, @left)
|
|
41
|
+
line.addstr(" Changelogger #{Changelogger::VERSION} ".center(@width, "="))
|
|
42
|
+
line.refresh
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: false
|
|
2
|
+
|
|
3
|
+
require "curses"
|
|
4
|
+
require_relative "header"
|
|
5
|
+
require_relative "branches_window"
|
|
6
|
+
require_relative "git"
|
|
7
|
+
require_relative "versioner"
|
|
8
|
+
require_relative "changelog_generator"
|
|
9
|
+
require_relative "preview_window"
|
|
10
|
+
|
|
11
|
+
Curses.init_screen
|
|
12
|
+
Curses.cbreak
|
|
13
|
+
Curses.curs_set(0)
|
|
14
|
+
Curses.noecho
|
|
15
|
+
|
|
16
|
+
Changelogger::Header.new
|
|
17
|
+
|
|
18
|
+
win = Changelogger::BranchWindow.new
|
|
19
|
+
selected = win.select_commits # closes screen on exit
|
|
20
|
+
|
|
21
|
+
if selected.nil?
|
|
22
|
+
# ESC/q
|
|
23
|
+
elsif selected.size >= 2
|
|
24
|
+
commits = Changelogger::Git.commits
|
|
25
|
+
path = Changelogger::ChangelogGenerator.generate(commits, selected, path: "CHANGELOG.md")
|
|
26
|
+
puts "Wrote #{path} ✅"
|
|
27
|
+
else
|
|
28
|
+
puts "No CHANGELOG generated (need at least 2 commits)."
|
|
29
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "curses"
|
|
4
|
+
|
|
5
|
+
module Changelogger
|
|
6
|
+
# +Changelogger::PreviewWindow+ shows scrollable text in a framed window.
|
|
7
|
+
class PreviewWindow
|
|
8
|
+
# +Changelogger::PreviewWindow.new+ -> PreviewWindow
|
|
9
|
+
#
|
|
10
|
+
# @param [String] title window title
|
|
11
|
+
# @param [String] content initial text
|
|
12
|
+
# @param [Integer] top top row position
|
|
13
|
+
# @param [Integer] left left column position
|
|
14
|
+
# @param [Integer, nil] height window height or computed from screen
|
|
15
|
+
# @param [Integer, nil] width window width or computed from screen
|
|
16
|
+
def initialize(title: "Preview", content: "", top: 1, left: 0, height: nil, width: nil)
|
|
17
|
+
@title = title
|
|
18
|
+
screen_h = Curses.lines
|
|
19
|
+
screen_w = Curses.cols
|
|
20
|
+
|
|
21
|
+
@height = height || [screen_h - top, 3].max
|
|
22
|
+
@width = width || [screen_w - left, 10].max
|
|
23
|
+
@top = top
|
|
24
|
+
@left = left
|
|
25
|
+
|
|
26
|
+
@sub_height = @height - 2
|
|
27
|
+
@sub_width = @width - 2
|
|
28
|
+
@sub_top = @top + 1
|
|
29
|
+
@sub_left = @left + 1
|
|
30
|
+
|
|
31
|
+
@offset = 0
|
|
32
|
+
@lines = (content || "").split("\n")
|
|
33
|
+
|
|
34
|
+
build_windows
|
|
35
|
+
redraw
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# +Changelogger::PreviewWindow#update_content+ -> void
|
|
39
|
+
#
|
|
40
|
+
# Replace content and reset scroll to top.
|
|
41
|
+
# @param [String] text new content
|
|
42
|
+
# @return [void]
|
|
43
|
+
def update_content(text)
|
|
44
|
+
@lines = (text || "").split("\n")
|
|
45
|
+
@offset = 0
|
|
46
|
+
redraw
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# +Changelogger::PreviewWindow#run+ -> void
|
|
50
|
+
#
|
|
51
|
+
# Enters the input loop. Returns when user closes the preview.
|
|
52
|
+
# @return [void]
|
|
53
|
+
def run
|
|
54
|
+
loop do
|
|
55
|
+
case @sub.getch
|
|
56
|
+
when Curses::Key::UP, "k"
|
|
57
|
+
@offset = [@offset - 1, 0].max
|
|
58
|
+
redraw
|
|
59
|
+
when Curses::Key::DOWN, "j"
|
|
60
|
+
max_off = [@lines.length - @sub_height, 0].max
|
|
61
|
+
@offset = [@offset + 1, max_off].min
|
|
62
|
+
redraw
|
|
63
|
+
when Curses::Key::PPAGE
|
|
64
|
+
@offset = [@offset - @sub_height, 0].max
|
|
65
|
+
redraw
|
|
66
|
+
when Curses::Key::NPAGE
|
|
67
|
+
max_off = [@lines.length - @sub_height, 0].max
|
|
68
|
+
@offset = [@offset + @sub_height, max_off].min
|
|
69
|
+
redraw
|
|
70
|
+
when "g"
|
|
71
|
+
@offset = 0
|
|
72
|
+
redraw
|
|
73
|
+
when "G"
|
|
74
|
+
@offset = [@lines.length - @sub_height, 0].max
|
|
75
|
+
redraw
|
|
76
|
+
when "q", 27
|
|
77
|
+
break
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
ensure
|
|
81
|
+
destroy
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# @!visibility private
|
|
87
|
+
|
|
88
|
+
def build_windows
|
|
89
|
+
@frame = Curses::Window.new(@height, @width, @top, @left)
|
|
90
|
+
@frame.box
|
|
91
|
+
draw_title
|
|
92
|
+
@frame.refresh
|
|
93
|
+
|
|
94
|
+
@sub = @frame.subwin(@sub_height, @sub_width, @sub_top, @sub_left)
|
|
95
|
+
@sub.keypad(true)
|
|
96
|
+
@sub.scrollok(false)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def draw_title
|
|
100
|
+
title = " #{@title} "
|
|
101
|
+
bar = title.center(@width - 2, "─")
|
|
102
|
+
@frame.setpos(0, 1)
|
|
103
|
+
@frame.addstr(bar[0, @width - 2])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def redraw
|
|
107
|
+
@sub.erase
|
|
108
|
+
|
|
109
|
+
visible = @lines[@offset, @sub_height] || []
|
|
110
|
+
visible.each_with_index do |line, i|
|
|
111
|
+
@sub.setpos(i, 0)
|
|
112
|
+
@sub.addstr(line.ljust(@sub_width, " ")[0, @sub_width])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
(visible.length...@sub_height).each do |i|
|
|
116
|
+
@sub.setpos(i, 0)
|
|
117
|
+
@sub.addstr(" " * @sub_width)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@sub.refresh
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def destroy
|
|
124
|
+
@sub&.close
|
|
125
|
+
@frame&.close
|
|
126
|
+
rescue StandardError
|
|
127
|
+
# ignore
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Changelogger
|
|
4
|
+
# +Changelogger::RepoInfo+ holds metadata about the current repository.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [rw] name
|
|
7
|
+
# @return [String] repo name (directory name)
|
|
8
|
+
# @!attribute [rw] path
|
|
9
|
+
# @return [String] absolute repo root path
|
|
10
|
+
# @!attribute [rw] branch
|
|
11
|
+
# @return [String] HEAD branch or "(detached)"
|
|
12
|
+
# @!attribute [rw] head_short
|
|
13
|
+
# @return [String] abbreviated HEAD sha
|
|
14
|
+
# @!attribute [rw] remote
|
|
15
|
+
# @return [String] remote.origin.url (may be empty)
|
|
16
|
+
# @!attribute [rw] remote_slug
|
|
17
|
+
# @return [String, nil] "owner/repo" for GitHub remotes, otherwise nil
|
|
18
|
+
# @!attribute [rw] dirty
|
|
19
|
+
# @return [Boolean] true if there are uncommitted changes
|
|
20
|
+
RepoInfo = Struct.new(:name, :path, :branch, :head_short, :remote, :remote_slug, :dirty, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
# +Changelogger::Repo+ reads basic repository info for display.
|
|
23
|
+
class Repo
|
|
24
|
+
class << self
|
|
25
|
+
# +Changelogger::Repo.info+ -> Changelogger::RepoInfo
|
|
26
|
+
#
|
|
27
|
+
# Reads repo root, branch, HEAD short sha, origin url, and dirty flag.
|
|
28
|
+
# @return [RepoInfo]
|
|
29
|
+
def info
|
|
30
|
+
path = cmd("git rev-parse --show-toplevel").strip
|
|
31
|
+
name = path.empty? ? File.basename(Dir.pwd) : File.basename(path)
|
|
32
|
+
branch = cmd("git rev-parse --abbrev-ref HEAD").strip
|
|
33
|
+
branch = "(detached)" if branch.empty? || branch == "HEAD"
|
|
34
|
+
head_short = cmd("git rev-parse --short HEAD").strip
|
|
35
|
+
remote = cmd("git config --get remote.origin.url").strip
|
|
36
|
+
dirty = !cmd("git status --porcelain").strip.empty?
|
|
37
|
+
|
|
38
|
+
RepoInfo.new(
|
|
39
|
+
name: name,
|
|
40
|
+
path: path.empty? ? Dir.pwd : path,
|
|
41
|
+
branch: branch,
|
|
42
|
+
head_short: head_short,
|
|
43
|
+
remote: remote,
|
|
44
|
+
remote_slug: to_slug(remote),
|
|
45
|
+
dirty: dirty
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# +Changelogger::Repo.cmd+ -> String
|
|
52
|
+
# @private
|
|
53
|
+
# Runs a shell command and returns its stdout (or empty string on error).
|
|
54
|
+
# @param [String] s shell command
|
|
55
|
+
# @return [String]
|
|
56
|
+
def cmd(s)
|
|
57
|
+
`#{s} 2>/dev/null`
|
|
58
|
+
rescue StandardError
|
|
59
|
+
""
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# +Changelogger::Repo.to_slug+ -> String, nil
|
|
63
|
+
# @private
|
|
64
|
+
# Extracts owner/repo from GitHub remotes or returns nil for others.
|
|
65
|
+
# @param [String] url git remote URL
|
|
66
|
+
# @return [String, nil]
|
|
67
|
+
def to_slug(url)
|
|
68
|
+
return nil if url.to_s.empty?
|
|
69
|
+
|
|
70
|
+
if url =~ %r{\Agit@github\.com:([\w.-]+/[\w.-]+)(?:\.git)?\z}i
|
|
71
|
+
Regexp.last_match(1)
|
|
72
|
+
elsif url =~ %r{\Ahttps?://(?:www\.)?github\.com/([\w.-]+/[\w.-]+)(?:\.git)?\z}i
|
|
73
|
+
Regexp.last_match(1)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "curses"
|
|
4
|
+
require_relative "header"
|
|
5
|
+
require_relative "branches_window"
|
|
6
|
+
|
|
7
|
+
module Changelogger
|
|
8
|
+
# +Changelogger::TUI+ wraps curses lifecycle and runs the side-by-side UI.
|
|
9
|
+
class TUI
|
|
10
|
+
# +Changelogger::TUI.run+ -> Array<String>, nil
|
|
11
|
+
#
|
|
12
|
+
# Starts curses, draws the header and graph/preview panes, and returns the
|
|
13
|
+
# selected anchor SHAs when the user presses Enter.
|
|
14
|
+
#
|
|
15
|
+
# @return [Array<String>, nil] array of SHAs (2+) or nil if cancelled (q/ESC)
|
|
16
|
+
def self.run
|
|
17
|
+
Curses.init_screen
|
|
18
|
+
Curses.cbreak
|
|
19
|
+
Curses.noecho
|
|
20
|
+
Curses.curs_set(0)
|
|
21
|
+
begin
|
|
22
|
+
begin
|
|
23
|
+
Curses.start_color
|
|
24
|
+
Curses.use_default_colors if Curses.respond_to?(:use_default_colors)
|
|
25
|
+
rescue StandardError
|
|
26
|
+
end
|
|
27
|
+
Changelogger::Header.new
|
|
28
|
+
win = Changelogger::BranchWindow.new
|
|
29
|
+
win.select_commits
|
|
30
|
+
ensure
|
|
31
|
+
begin
|
|
32
|
+
Curses.close_screen
|
|
33
|
+
rescue StandardError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|