pack command
This commit is contained in:
@@ -8,7 +8,6 @@ use colored::Colorize;
|
||||
/// Perform the action of adding a new log entry
|
||||
pub(crate) fn cl_log(ctx: AppContext) -> anyhow::Result<()> {
|
||||
let store = Store::new(&ctx, false)?;
|
||||
store.ensure_internal_subdirs_exist()?;
|
||||
|
||||
let branch = get_branch_name(&ctx);
|
||||
let issue = branch
|
||||
|
||||
+81
-5
@@ -1,16 +1,92 @@
|
||||
use crate::AppContext;
|
||||
use crate::git::get_branch_name;
|
||||
use crate::store::{Release, Store};
|
||||
use anyhow::bail;
|
||||
use colored::Colorize;
|
||||
|
||||
/// Perform the action of packing changelog entries for a release
|
||||
pub(crate) fn cl_pack(ctx: AppContext, manual_channel: Option<&str>) -> anyhow::Result<()> {
|
||||
pub(crate) fn cl_pack(ctx: AppContext) -> anyhow::Result<()> {
|
||||
let mut store = Store::new(&ctx, false)?;
|
||||
let branch = get_branch_name(&ctx);
|
||||
let channel = branch.as_ref().map(|b| b.parse_channel(&ctx)).transpose()?.flatten();
|
||||
let version = branch.as_ref().map(|b| b.parse_version(&ctx)).transpose()?.flatten();
|
||||
|
||||
let channel_detected = branch
|
||||
.as_ref()
|
||||
.map(|b| b.parse_channel(&ctx))
|
||||
.transpose()?
|
||||
.flatten();
|
||||
|
||||
// If the branch is named rel/3.40, this can extract 3.40.
|
||||
// TODO try to get something better from git!
|
||||
let version_base = branch
|
||||
.as_ref()
|
||||
.map(|b| b.parse_version(&ctx))
|
||||
.transpose()?
|
||||
.flatten();
|
||||
|
||||
// TODO detect version from git query?
|
||||
|
||||
// TODO remove this
|
||||
eprintln!(
|
||||
"Branch name: {:?}, channel: {:?}, version: {:?}",
|
||||
branch, channel, version
|
||||
branch, channel_detected, version_base
|
||||
);
|
||||
|
||||
todo!();
|
||||
// Ask for the channel
|
||||
let channel = if ctx.config.channels.len() > 1 {
|
||||
let channels = ctx.config.channels.values().collect::<Vec<_>>();
|
||||
let mut starting_index = None;
|
||||
if let Some(channel) = channel_detected {
|
||||
starting_index = channels.iter().position(|ch| *ch == &channel);
|
||||
}
|
||||
let mut query = inquire::Select::new("Release channel?", channels);
|
||||
if let Some(index) = starting_index {
|
||||
query = query.with_starting_cursor(index);
|
||||
}
|
||||
query.prompt()?.to_string()
|
||||
} else {
|
||||
// Just one channel, so use that
|
||||
ctx.config.default_channel.clone()
|
||||
};
|
||||
println!("Channel: {}", channel.green().bold());
|
||||
|
||||
let unreleased = store.find_unreleased_changes(&channel)?;
|
||||
|
||||
if unreleased.is_empty() {
|
||||
eprintln!("No unreleased changes.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("Changes waiting for release:\n");
|
||||
for entry in &unreleased {
|
||||
println!("+ {entry}\n");
|
||||
}
|
||||
println!();
|
||||
|
||||
// Ask for the version
|
||||
let mut version = version_base.unwrap_or_default();
|
||||
loop {
|
||||
// Ask for full version
|
||||
version = inquire::Text::new("Version:")
|
||||
.with_initial_value(&version)
|
||||
.prompt()?;
|
||||
|
||||
if version.is_empty() {
|
||||
bail!("Cancelled");
|
||||
}
|
||||
|
||||
if store.version_exists(&version) {
|
||||
println!("{}", "Version already exists, try again or cancel.".red());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
store.create_release(
|
||||
channel,
|
||||
Release {
|
||||
version,
|
||||
entries: unreleased,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+13
-1
@@ -2,6 +2,15 @@ use serde::{Deserialize, Serialize};
|
||||
use smart_default::SmartDefault;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// e.g. default, stable, eap
|
||||
pub type ChannelName = String;
|
||||
|
||||
/// e.g. 1.2.3
|
||||
pub type VersionName = String;
|
||||
|
||||
/// e.g. SW-1234-stuff-is-broken (without .md)
|
||||
pub type EntryName = String;
|
||||
|
||||
/// Main app configuration file
|
||||
#[derive(Debug, Serialize, Deserialize, SmartDefault)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
@@ -28,6 +37,9 @@ pub struct Config {
|
||||
/// - `{channel}`, `{Channel}`, `{CHANNEL}` - Channel ID in the respective capitalization
|
||||
#[default = "CHANGELOG-{CHANNEL}.md"]
|
||||
pub changelog_file_channel: String,
|
||||
|
||||
#[default = "# Changelog\n\n"]
|
||||
pub changelog_header: String,
|
||||
|
||||
/// Changelog sections suggested when creating a new entry.
|
||||
///
|
||||
@@ -57,7 +69,7 @@ pub struct Config {
|
||||
#[default(HashMap::from([
|
||||
("default".to_string(), "/^(?:main|master)$/".to_string())
|
||||
]))]
|
||||
pub channels: HashMap<String, String>,
|
||||
pub channels: HashMap<ChannelName, String>,
|
||||
|
||||
/// Regex pattern to extract issue number from a branch name.
|
||||
/// There should be one capture group that is the number.
|
||||
|
||||
+4
-10
@@ -54,13 +54,7 @@ fn main_try() -> anyhow::Result<()> {
|
||||
.subcommand(
|
||||
clap::Command::new("pack")
|
||||
.visible_alias("release")
|
||||
.about("Create a release changelog entry for the current channel")
|
||||
.arg(
|
||||
clap::Arg::new("CHANNEL")
|
||||
.help("Channel ID, possible values depend on project config. None for main channel.")
|
||||
.value_parser(NonEmptyStringValueParser::new())
|
||||
.required(false),
|
||||
),
|
||||
.about("Pack changelog entries to a changelog section"),
|
||||
)
|
||||
.subcommand(clap::Command::new("add")
|
||||
.visible_alias("log")
|
||||
@@ -128,11 +122,11 @@ fn main_try() -> anyhow::Result<()> {
|
||||
// eprintln!("AppCtx: {:?}", ctx);
|
||||
|
||||
match args.subcommand() {
|
||||
Some(("pack", subargs)) => {
|
||||
let manual_channel = subargs.get_one::<String>("CHANNEL");
|
||||
cl_pack(ctx, manual_channel.map(String::as_str))?;
|
||||
Some(("pack", _)) => {
|
||||
cl_pack(ctx)?;
|
||||
}
|
||||
None | Some(("add", _)) => cl_log(ctx)?,
|
||||
// TODO: status, flush
|
||||
Some((other, _)) => {
|
||||
bail!("Subcommand {other} is not implemented yet");
|
||||
}
|
||||
|
||||
+317
-9
@@ -1,14 +1,26 @@
|
||||
use crate::AppContext;
|
||||
use crate::config::{ChannelName, EntryName, VersionName};
|
||||
use anyhow::bail;
|
||||
use colored::Colorize;
|
||||
use faccess::PathExt;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{OpenOptions, read_to_string};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DIR_ENTRIES: &str = "entries";
|
||||
const DIR_CHANNELS: &str = "channels";
|
||||
|
||||
/// Changelog store struct
|
||||
pub struct Store<'a> {
|
||||
/// App context, including config
|
||||
ctx: &'a AppContext,
|
||||
|
||||
/// Path to the changelog directory
|
||||
store_path: PathBuf,
|
||||
/// Loaded version history for all channels
|
||||
versions: HashMap<ChannelName, ChannelReleaseStore>,
|
||||
}
|
||||
|
||||
impl<'a> Store<'a> {
|
||||
@@ -36,9 +48,14 @@ impl<'a> Store<'a> {
|
||||
);
|
||||
}
|
||||
|
||||
let store = Self { store_path, ctx };
|
||||
let mut store = Self {
|
||||
store_path,
|
||||
ctx,
|
||||
versions: HashMap::new(),
|
||||
};
|
||||
|
||||
store.ensure_internal_subdirs_exist()?;
|
||||
store.load_versions()?;
|
||||
|
||||
Ok(store)
|
||||
}
|
||||
@@ -47,7 +64,7 @@ impl<'a> Store<'a> {
|
||||
/// This is a file in the entries storage
|
||||
fn make_entry_path(&self, filename: &str) -> PathBuf {
|
||||
self.store_path
|
||||
.join("entries")
|
||||
.join(DIR_ENTRIES)
|
||||
.join(format!("{filename}.md"))
|
||||
}
|
||||
|
||||
@@ -58,9 +75,25 @@ impl<'a> Store<'a> {
|
||||
path.exists()
|
||||
}
|
||||
|
||||
/// Load release lists for all channels
|
||||
fn load_versions(&mut self) -> anyhow::Result<()> {
|
||||
let channels_dir = self.store_path.join(DIR_CHANNELS);
|
||||
|
||||
for ch in self.ctx.config.channels.keys() {
|
||||
let channel_file = channels_dir.join(format!("{}.json", ch));
|
||||
self.versions.insert(
|
||||
ch.clone(),
|
||||
ChannelReleaseStore::load(channel_file, ch.clone())?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check and create internal subdirs for the clpack system
|
||||
pub fn ensure_internal_subdirs_exist(&self) -> anyhow::Result<()> {
|
||||
self.ensure_subdir_exists("entries")?;
|
||||
self.ensure_subdir_exists(DIR_ENTRIES)?;
|
||||
self.ensure_subdir_exists(DIR_CHANNELS)?;
|
||||
// TODO
|
||||
|
||||
Ok(())
|
||||
@@ -93,13 +126,288 @@ impl<'a> Store<'a> {
|
||||
}
|
||||
|
||||
/// Create a changelog entry file and write content to it
|
||||
pub fn create_entry(&self, name: String, content: String) -> anyhow::Result<()> {
|
||||
pub fn create_entry(&self, name: EntryName, content: String) -> anyhow::Result<()> {
|
||||
let path = self.make_entry_path(name.as_str());
|
||||
let mut file = OpenOptions::new().write(true).create(true).open(&path)?;
|
||||
|
||||
eprintln!("Writing to file: {}", path.display());
|
||||
eprintln!("Writing changelog entry to file: {}", path.display());
|
||||
|
||||
file.write_all(content.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a version was already released (on any channel) - prevents the user from making a mistake in version naming
|
||||
pub fn version_exists(&self, version: &str) -> bool {
|
||||
for v in self.versions.values() {
|
||||
if v.version_exists(version) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find unreleased changelog entries on a channel
|
||||
pub fn find_unreleased_changes(&self, channel: &ChannelName) -> anyhow::Result<Vec<EntryName>> {
|
||||
let Some(store) = self.versions.get(channel) else {
|
||||
bail!("Channel {channel} does not exist.");
|
||||
};
|
||||
|
||||
store.find_unreleased_entries(self.store_path.join(DIR_ENTRIES))
|
||||
}
|
||||
|
||||
/// Create a release entry, write it to the releases buffer and to the file.
|
||||
pub fn create_release(&mut self, channel: ChannelName, release: Release) -> anyhow::Result<()> {
|
||||
let Some(store) = self.versions.get_mut(&channel) else {
|
||||
bail!("Channel {channel} does not exist.");
|
||||
};
|
||||
|
||||
let config = &self.ctx.config;
|
||||
let rendered = release.render(self.store_path.join(DIR_ENTRIES), &config.sections)?;
|
||||
|
||||
let changelog_file = self.ctx.root.join(
|
||||
if channel == config.default_channel {
|
||||
Cow::Borrowed(config.changelog_file_default.as_str())
|
||||
} else {
|
||||
Cow::Owned(
|
||||
config
|
||||
.changelog_file_channel
|
||||
.replace("{channel}", &channel.to_lowercase())
|
||||
.replace("{CHANNEL}", &channel.to_uppercase())
|
||||
.replace("{Channel}", &ucfirst(&channel)),
|
||||
)
|
||||
}
|
||||
.as_ref(),
|
||||
);
|
||||
|
||||
if changelog_file.exists() {
|
||||
let changelog_file_content = read_to_string(&changelog_file)?;
|
||||
let old_content = changelog_file_content
|
||||
.strip_prefix(&config.changelog_header)
|
||||
.unwrap_or(&changelog_file_content);
|
||||
|
||||
let mut outfile = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(changelog_file)?;
|
||||
|
||||
outfile.write_all(
|
||||
format!("{}{}{}", config.changelog_header, rendered, old_content).as_bytes(),
|
||||
)?;
|
||||
} else {
|
||||
let mut outfile = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(changelog_file)?;
|
||||
|
||||
outfile.write_all(format!("{}{}", config.changelog_header, rendered).as_bytes())?;
|
||||
}
|
||||
|
||||
store.add_version(release)?;
|
||||
// Write to the changelog file for this channel
|
||||
store.write_to_file()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Uppercase first char of a string
|
||||
fn ucfirst(input: &str) -> String {
|
||||
let mut c = input.chars();
|
||||
match c.next() {
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of a release
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Release {
|
||||
/// Name of the version
|
||||
pub version: VersionName,
|
||||
/// List of entries included in this version
|
||||
pub entries: Vec<EntryName>,
|
||||
}
|
||||
|
||||
impl Release {
|
||||
/// Render the entry into a Markdown fragment, using h2 (##) as the title.
|
||||
pub fn render(
|
||||
&self,
|
||||
entries_dir: impl AsRef<Path>,
|
||||
predefined_sections: &[String],
|
||||
) -> anyhow::Result<String> {
|
||||
let mut entries_per_section = HashMap::<String, String>::new();
|
||||
let entries_dir = entries_dir.as_ref();
|
||||
let unnamed = "".to_string();
|
||||
|
||||
for entry in &self.entries {
|
||||
let entry_file = entries_dir.join(&format!("{entry}.md"));
|
||||
|
||||
if !entry_file.exists() || !entry_file.readable() {
|
||||
bail!(
|
||||
"Changelog entry file missing or not readable: {}",
|
||||
entry_file.display()
|
||||
);
|
||||
}
|
||||
|
||||
let file = OpenOptions::new().read(true).open(&entry_file)?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut current_section = unnamed.clone();
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
if line.trim().starts_with('#') {
|
||||
// It is a section name
|
||||
let section = line.trim_matches(|c| c == '#' || c == ' ');
|
||||
current_section = section.to_string();
|
||||
} else {
|
||||
if let Some(buffer) = entries_per_section.get_mut(¤t_section) {
|
||||
buffer.push('\n');
|
||||
buffer.push_str(&line);
|
||||
} else {
|
||||
entries_per_section.insert(current_section.clone(), line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut reordered_sections = Vec::<(String, String)>::new();
|
||||
|
||||
// First the unlabelled section (this is probably junk, but it was entered by the user, so keep it)
|
||||
if let Some(unlabelled) = entries_per_section.remove("") {
|
||||
reordered_sections.push(("".to_string(), unlabelled));
|
||||
}
|
||||
|
||||
for section_name in [unnamed].iter().chain(predefined_sections.iter()) {
|
||||
if let Some(content) = entries_per_section.remove(section_name) {
|
||||
reordered_sections.push((section_name.clone(), content));
|
||||
}
|
||||
}
|
||||
// Leftovers (names authors invented when writing changelog)
|
||||
for (section_name, content) in entries_per_section {
|
||||
reordered_sections.push((section_name, content));
|
||||
}
|
||||
|
||||
let mut buffer = String::new();
|
||||
|
||||
for (section_name, content) in reordered_sections {
|
||||
if !section_name.is_empty() {
|
||||
buffer.push_str(&format!("## {}\n", section_name));
|
||||
}
|
||||
buffer.push_str(&content);
|
||||
buffer.push('\n');
|
||||
}
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
/// List of releases, deserialized from a file
|
||||
type ReleaseList = Vec<Release>;
|
||||
|
||||
/// Versions store for one channel
|
||||
struct ChannelReleaseStore {
|
||||
/// File where the list of versions is stored
|
||||
backing_file: PathBuf,
|
||||
/// Name of the channel, for error messages
|
||||
channel_name: ChannelName,
|
||||
/// List of releases, load from the file
|
||||
releases: ReleaseList,
|
||||
}
|
||||
|
||||
impl ChannelReleaseStore {
|
||||
/// Load from a versions file
|
||||
fn load(releases_file: PathBuf, channel_name: ChannelName) -> anyhow::Result<Self> {
|
||||
let releases = if !releases_file.exists() {
|
||||
// File did not exist yet, create it
|
||||
let mut f = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(&releases_file)?;
|
||||
f.write_all("{}".as_bytes())?;
|
||||
Default::default()
|
||||
} else {
|
||||
let channel_json = read_to_string(&releases_file)?;
|
||||
serde_json::from_str::<ReleaseList>(&channel_json)?
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
backing_file: releases_file,
|
||||
channel_name,
|
||||
releases,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a version is included in a release
|
||||
fn version_exists(&self, version: &str) -> bool {
|
||||
self.releases.iter().any(|rel| rel.version == version)
|
||||
}
|
||||
|
||||
/// Add a version to the channel buffer
|
||||
/// The release entry, borrowed, is returned for further use
|
||||
fn add_version(&mut self, release: Release) -> anyhow::Result<()> {
|
||||
if self.version_exists(&release.version) {
|
||||
bail!(
|
||||
"Version {} already exists on channel {}",
|
||||
release.version,
|
||||
self.channel_name
|
||||
);
|
||||
}
|
||||
self.releases.push(release);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the versions list contained in this store into the backing file.
|
||||
fn write_to_file(&self) -> anyhow::Result<()> {
|
||||
let encoded = serde_json::to_string_pretty(&self.releases)?;
|
||||
let mut f = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(&self.backing_file)?;
|
||||
f.write_all(encoded.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find entries not yet included in this release channel
|
||||
fn find_unreleased_entries(
|
||||
&self,
|
||||
entries_dir: impl AsRef<Path>,
|
||||
) -> anyhow::Result<Vec<EntryName>> {
|
||||
let mut found = vec![];
|
||||
|
||||
for entry in entries_dir.as_ref().read_dir()? {
|
||||
let entry = entry?;
|
||||
|
||||
let fname_os = entry.file_name();
|
||||
let fname = fname_os.into_string().map_err(|_| {
|
||||
anyhow::anyhow!("Failed to parse file name: {}", entry.path().display())
|
||||
})?;
|
||||
|
||||
if !entry.metadata()?.is_file() || !fname.ends_with(".md") {
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"Unexpected item in changelog entries dir: {}",
|
||||
entry.path().display()
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let basename = fname.strip_suffix(".md").unwrap();
|
||||
|
||||
if !self
|
||||
.releases
|
||||
.iter()
|
||||
.map(|rel| &rel.entries)
|
||||
.flatten()
|
||||
.any(|entryname| entryname == basename)
|
||||
{
|
||||
found.push(fname);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user