MVP "log" action added
This commit is contained in:
@@ -1,2 +1,6 @@
|
||||
/target
|
||||
.idea/
|
||||
|
||||
# Remove when the lib is stable enough and useful to keep our own changelog...
|
||||
changelog
|
||||
clpack.toml
|
||||
|
||||
Generated
+121
-1
@@ -61,6 +61,12 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.99"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -122,7 +128,10 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
|
||||
name = "clpack"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"colored",
|
||||
"faccess",
|
||||
"inquire",
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -138,6 +147,15 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.25.0"
|
||||
@@ -175,6 +193,33 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "faccess"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ae66425802d6a903e268ae1a08b8c38ba143520f227a205edf4e9c7e3e26d5"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fuzzy-matcher"
|
||||
version = "0.3.7"
|
||||
@@ -193,6 +238,18 @@ dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasi 0.14.5+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -222,6 +279,7 @@ dependencies = [
|
||||
"fxhash",
|
||||
"newline-converter",
|
||||
"once_cell",
|
||||
"tempfile",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
@@ -244,6 +302,12 @@ version = "0.2.175"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.13"
|
||||
@@ -274,7 +338,7 @@ checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
@@ -340,6 +404,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.17"
|
||||
@@ -378,6 +448,19 @@ version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.20"
|
||||
@@ -495,6 +578,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.16"
|
||||
@@ -593,6 +689,24 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.5+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4494f6290a82f5fe584817a676a34b9d6763e8d9d18204009fb31dceca98fd4"
|
||||
dependencies = [
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.0+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03fa2761397e5bd52002cd7e73110c71af2109aca4e521a9f40473fe685b0a24"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -830,3 +944,9 @@ name = "winnow"
|
||||
version = "0.7.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.45.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36"
|
||||
|
||||
+5
-2
@@ -6,15 +6,18 @@ authors = ["Ondřej Hruška <ondra@ondrovo.com>"]
|
||||
description = "Manage changelog across multiple release channels"
|
||||
|
||||
[dependencies]
|
||||
clap = "4.5"
|
||||
clap = { version = "4.5", features = ["string"] }
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.9"
|
||||
smart-default = "0.7"
|
||||
regex = "1"
|
||||
anyhow = "1"
|
||||
colored = "3"
|
||||
faccess = "0.2"
|
||||
|
||||
# input
|
||||
|
||||
# sadly this looks mostly abandoned. Alternative is "dialoguer"
|
||||
inquire = "0.7.5"
|
||||
inquire = { version = "0.7.5", features = ["editor"] }
|
||||
|
||||
+98
-4
@@ -1,11 +1,105 @@
|
||||
use crate::AppContext;
|
||||
use crate::git::BranchOpt;
|
||||
use crate::git::get_branch_name;
|
||||
use crate::store::Store;
|
||||
use anyhow::bail;
|
||||
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()?;
|
||||
|
||||
pub(crate) fn cl_log(ctx: AppContext) {
|
||||
let branch = get_branch_name(&ctx);
|
||||
let issue = branch.as_ref().map(|b| b.parse_issue(&ctx)).flatten();
|
||||
let issue = branch
|
||||
.as_ref()
|
||||
.map(|b| b.parse_issue(&ctx))
|
||||
.transpose()?
|
||||
.flatten();
|
||||
|
||||
eprintln!("Branch name: {:?}, issue: {:?}", branch, issue);
|
||||
if let Some(num) = &issue {
|
||||
println!("{}", format!("Issue # parsed from branch: {num}").green());
|
||||
} else {
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"Issue not recognized from branch name! (\"{}\")",
|
||||
branch.as_str_or_default()
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
|
||||
todo!();
|
||||
let mut entry_name = branch.as_str_or_default().to_string();
|
||||
|
||||
// Space
|
||||
println!();
|
||||
|
||||
loop {
|
||||
// Ask for filename
|
||||
let mut query = inquire::Text::new("Log entry name:")
|
||||
.with_help_message("Used as a filename, without extension");
|
||||
if issue.is_some() {
|
||||
query = query.with_initial_value(&entry_name);
|
||||
}
|
||||
entry_name = query.prompt()?;
|
||||
|
||||
if entry_name.is_empty() {
|
||||
bail!("Cancelled");
|
||||
}
|
||||
|
||||
if store.entry_exists(&entry_name) {
|
||||
println!("{}", "Entry already exists, try different name.".red());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Space
|
||||
println!();
|
||||
|
||||
// Ask for sections
|
||||
let sections = inquire::MultiSelect::new(
|
||||
"Choose changelog sections to pre-generate (at least one)",
|
||||
ctx.config.sections.clone(),
|
||||
)
|
||||
.prompt()?;
|
||||
|
||||
if sections.is_empty() {
|
||||
bail!("Cancelled");
|
||||
}
|
||||
|
||||
let mut prefill_text = String::new();
|
||||
|
||||
for section in sections {
|
||||
if !prefill_text.is_empty() {
|
||||
prefill_text.push('\n');
|
||||
}
|
||||
prefill_text.push_str(&format!("# {section}\n"));
|
||||
if let Some(num) = &issue {
|
||||
prefill_text.push_str(&format!("- (#{num})\n"));
|
||||
} else {
|
||||
prefill_text.push_str("- \n");
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"\nPreview of changelog entry \"{entry_name}\" (not yet saved)\n\n{}\n",
|
||||
prefill_text
|
||||
);
|
||||
|
||||
// Edit the file
|
||||
let mut text = inquire::Editor::new("Edit as needed, then confirm")
|
||||
.with_predefined_text(&prefill_text)
|
||||
.with_file_extension("md")
|
||||
.prompt()?;
|
||||
|
||||
if text.is_empty() {
|
||||
text = prefill_text;
|
||||
}
|
||||
|
||||
store.create_entry(entry_name, text)?;
|
||||
|
||||
println!("{}", "Done.".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,10 +1,11 @@
|
||||
use crate::AppContext;
|
||||
use crate::git::get_branch_name;
|
||||
|
||||
pub(crate) fn cl_pack(ctx: AppContext, manual_channel: Option<&str>) {
|
||||
/// Perform the action of packing changelog entries for a release
|
||||
pub(crate) fn cl_pack(ctx: AppContext, manual_channel: Option<&str>) -> anyhow::Result<()> {
|
||||
let branch = get_branch_name(&ctx);
|
||||
let channel = branch.as_ref().map(|b| b.parse_channel(&ctx)).flatten();
|
||||
let version = branch.as_ref().map(|b| b.parse_version(&ctx)).flatten();
|
||||
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();
|
||||
|
||||
eprintln!(
|
||||
"Branch name: {:?}, channel: {:?}, version: {:?}",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smart_default::SmartDefault;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Main app configuration file
|
||||
#[derive(Debug, Serialize, Deserialize, SmartDefault)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Config {
|
||||
/// Folder for data files - the tool will manage contents of this folder.
|
||||
/// Changelog entries are simple text files that may be edited manually
|
||||
/// if corrections need to be made.
|
||||
#[default = "changelog"]
|
||||
pub data_folder: String,
|
||||
|
||||
/// ID of the default channel - this only matters inside this config file
|
||||
#[default = "default"]
|
||||
pub default_channel: String,
|
||||
|
||||
/// Path or file name of the default changelog file, relative to the root of the project.
|
||||
///
|
||||
/// The name is used as-is.
|
||||
#[default = "CHANGELOG.md"]
|
||||
pub changelog_file_default: String,
|
||||
|
||||
/// Path or file of a channel-specific changelog file, relative to the root of the project.
|
||||
///
|
||||
/// Placeholders supported are:
|
||||
/// - `{channel}`, `{Channel}`, `{CHANNEL}` - Channel ID in the respective capitalization
|
||||
#[default = "CHANGELOG-{CHANNEL}.md"]
|
||||
pub changelog_file_channel: String,
|
||||
|
||||
/// Changelog sections suggested when creating a new entry.
|
||||
///
|
||||
/// Users may also specify a custom section name.
|
||||
///
|
||||
/// Changelog entries under each section will be grouped in the packed changelog.
|
||||
#[default(vec![
|
||||
"Fixes".to_string(),
|
||||
"Improvements".to_string(),
|
||||
"New features".to_string(),
|
||||
"Internal".to_string(),
|
||||
])]
|
||||
pub sections: Vec<String>,
|
||||
|
||||
/// Changelog channels - how to identify them from git branch names
|
||||
///
|
||||
/// - Key - changelog ID; this can be used in the channel file name. Examples: default, eap, beta
|
||||
/// - Value - git branch name to recognize the channel. This is a regex pattern.
|
||||
///
|
||||
/// At least one channel must be defined, with the name defined in `default_channel`
|
||||
///
|
||||
/// # Value format
|
||||
/// For simple branch names without special symbols that do not change, e.g. `main`, `master`, `test`, you can just use the name as is.
|
||||
/// To specify a regex, enclose it in slashes, e.g. /rel\/foo/
|
||||
///
|
||||
/// If you have a naming schema like e.g. `beta/1.0` where only the prefix stays the same, you may use e.g. `^beta/.*`
|
||||
#[default(HashMap::from([
|
||||
("default".to_string(), "/^(?:main|master)$/".to_string())
|
||||
]))]
|
||||
pub channels: HashMap<String, String>,
|
||||
|
||||
/// Regex pattern to extract issue number from a branch name.
|
||||
/// There should be one capture group that is the number.
|
||||
///
|
||||
/// Example: `/^(SW-\d+)-.*$/` or `/^(\d+)-.*$/`
|
||||
///
|
||||
/// If None, no branch identification will be attempted.
|
||||
#[default(Some(r"/^((?:SW-)?\d+)-.*/".to_string()))]
|
||||
pub branch_issue_pattern: Option<String>,
|
||||
|
||||
/// Regex pattern to extract release number from a branch name.
|
||||
/// There should be one capture group that is the version.
|
||||
///
|
||||
/// Example: `/^rel\/(\d+.\d+)$/`
|
||||
///
|
||||
/// If None, no branch identification will be attempted.
|
||||
///
|
||||
/// TODO attempt to parse version from package.json, composer.json, Cargo.toml and others
|
||||
#[default(Some(r"/^rel\/(\d+\.\d+)$/".to_string()))]
|
||||
pub branch_version_pattern: Option<String>,
|
||||
}
|
||||
+71
-41
@@ -1,7 +1,7 @@
|
||||
use crate::AppContext;
|
||||
use anyhow::bail;
|
||||
use std::fmt::Display;
|
||||
use std::fmt::Formatter;
|
||||
use std::process::exit;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BranchName(pub String);
|
||||
@@ -39,86 +39,86 @@ impl BranchName {
|
||||
///
|
||||
/// pat_s - the regex pattern
|
||||
/// regex_conf_name - name of the conf field the regex came from, for error messages
|
||||
fn parse_using_regex(&self, template: &str, regex_conf_name: &str) -> Option<String> {
|
||||
fn parse_using_regex(
|
||||
&self,
|
||||
template: &str,
|
||||
regex_conf_name: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let Some(pat_s) = as_regex_pattern(template) else {
|
||||
eprintln!(
|
||||
bail!(
|
||||
"Config field \"{regex_conf_name}\" must contain a regex (encased in slashes). Found: {template}"
|
||||
);
|
||||
exit(1);
|
||||
//return None;
|
||||
};
|
||||
|
||||
let pat = match regex::Regex::new(pat_s) {
|
||||
Ok(pat) => pat,
|
||||
Err(e) => {
|
||||
eprintln!("Invalid regex in \"{regex_conf_name}\": {pat_s}");
|
||||
eprintln!("Error: {e}");
|
||||
exit(1);
|
||||
//return None;
|
||||
bail!("Invalid regex in \"{regex_conf_name}\": {pat_s}\nError: {e}");
|
||||
}
|
||||
};
|
||||
|
||||
let num_captures = pat.captures_len();
|
||||
if num_captures != 2 {
|
||||
eprintln!("The pattern \"{regex_conf_name}\" is not applicable: {pat_s}");
|
||||
eprintln!(
|
||||
"There must be exactly one capturing group. Found {}",
|
||||
// capture "0" is the whole matched string
|
||||
bail!(
|
||||
"The pattern \"{regex_conf_name}\" is not applicable: {pat_s}\nThere must be exactly one capturing group. Found {}",
|
||||
num_captures - 1
|
||||
);
|
||||
exit(1);
|
||||
//return None;
|
||||
}
|
||||
|
||||
let matches = pat.captures(&self.0)?;
|
||||
Some(matches.get(1)?.as_str().to_owned())
|
||||
let Some(matches) = pat.captures(&self.0) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(amatch) = matches.get(1) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(amatch.as_str().to_owned()))
|
||||
}
|
||||
|
||||
/// Parse version from this branch name.
|
||||
///
|
||||
/// Aborts if the configured regex pattern is invalid.
|
||||
pub fn parse_version(&self, ctx: &AppContext) -> Option<String> {
|
||||
self.parse_using_regex(
|
||||
ctx.config.branch_version_pattern.as_ref()?,
|
||||
"branch_version_pattern",
|
||||
)
|
||||
pub fn parse_version(&self, ctx: &AppContext) -> anyhow::Result<Option<String>> {
|
||||
let Some(pat) = ctx.config.branch_version_pattern.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.parse_using_regex(pat, "branch_version_pattern")
|
||||
}
|
||||
|
||||
/// Parse issue number from this branch name.
|
||||
///
|
||||
/// Aborts if the configured regex pattern is invalid.
|
||||
pub fn parse_issue(&self, ctx: &AppContext) -> Option<String> {
|
||||
self.parse_using_regex(
|
||||
ctx.config.branch_issue_pattern.as_ref()?,
|
||||
"branch_issue_pattern",
|
||||
)
|
||||
pub fn parse_issue(&self, ctx: &AppContext) -> anyhow::Result<Option<String>> {
|
||||
let Some(pat) = ctx.config.branch_issue_pattern.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.parse_using_regex(pat, "branch_issue_pattern")
|
||||
}
|
||||
|
||||
/// Try to detect a release channel from this branch name (e.g. stable, EAP)
|
||||
pub fn parse_channel(&self, ctx: &AppContext) -> Option<String> {
|
||||
pub fn parse_channel(&self, ctx: &AppContext) -> anyhow::Result<Option<String>> {
|
||||
for (channel_id, template) in &ctx.config.channels {
|
||||
if let Some(pat_s) = as_regex_pattern(template) {
|
||||
let pat = match regex::Regex::new(pat_s) {
|
||||
Ok(pat) => pat,
|
||||
Err(e) => {
|
||||
eprintln!("Invalid regex for channel \"{channel_id}\": {template}");
|
||||
eprintln!("Error: {e}");
|
||||
exit(1);
|
||||
bail!("Invalid regex for channel \"{channel_id}\": {template}\nError: {e}");
|
||||
}
|
||||
};
|
||||
|
||||
if pat.is_match(&self.0) {
|
||||
return Some(channel_id.to_owned());
|
||||
return Ok(Some(channel_id.to_owned()));
|
||||
}
|
||||
} else {
|
||||
// No regex - match it verbatim
|
||||
if &self.0 == template {
|
||||
return Some(channel_id.to_owned());
|
||||
return Ok(Some(channel_id.to_owned()));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,16 @@ fn as_regex_pattern(input: &str) -> Option<&str> {
|
||||
input.strip_prefix('/')?.strip_suffix('/')
|
||||
}
|
||||
|
||||
pub trait BranchOpt {
|
||||
fn as_str_or_default(&self) -> &str;
|
||||
}
|
||||
|
||||
impl BranchOpt for Option<BranchName> {
|
||||
fn as_str_or_default(&self) -> &str {
|
||||
self.as_ref().map(|b| b.0.as_str()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
@@ -143,37 +153,52 @@ mod test {
|
||||
#[test]
|
||||
fn test_parse_version() {
|
||||
let ctx = AppContext {
|
||||
binary_name: "cl".to_string(),
|
||||
config: Default::default(),
|
||||
root: PathBuf::from("/tmp/"), // will not be used
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
BranchName("rel/3.14".to_string()).parse_version(&ctx),
|
||||
BranchName("rel/3.14".to_string())
|
||||
.parse_version(&ctx)
|
||||
.unwrap(),
|
||||
Some("3.14".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(BranchName("rel/foo".to_string()).parse_version(&ctx), None);
|
||||
assert_eq!(
|
||||
BranchName("rel/foo".to_string())
|
||||
.parse_version(&ctx)
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_issue() {
|
||||
let ctx = AppContext {
|
||||
binary_name: "cl".to_string(),
|
||||
config: Default::default(),
|
||||
root: PathBuf::from("/tmp/"), // will not be used
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
BranchName("1234-bober-kurwa".to_string()).parse_issue(&ctx),
|
||||
BranchName("1234-bober-kurwa".to_string())
|
||||
.parse_issue(&ctx)
|
||||
.unwrap(),
|
||||
Some("1234".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
BranchName("SW-778-jakie-bydłe-jebane".to_string()).parse_issue(&ctx),
|
||||
BranchName("SW-778-jakie-bydłe-jebane".to_string())
|
||||
.parse_issue(&ctx)
|
||||
.unwrap(),
|
||||
Some("SW-778".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
BranchName("nie-spierdalaj-mordo".to_string()).parse_issue(&ctx),
|
||||
BranchName("nie-spierdalaj-mordo".to_string())
|
||||
.parse_issue(&ctx)
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -181,22 +206,27 @@ mod test {
|
||||
#[test]
|
||||
fn test_parse_channel() {
|
||||
let ctx = AppContext {
|
||||
binary_name: "cl".to_string(),
|
||||
config: Default::default(),
|
||||
root: PathBuf::from("/tmp/"), // will not be used
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
BranchName("main".to_string()).parse_channel(&ctx),
|
||||
BranchName("main".to_string()).parse_channel(&ctx).unwrap(),
|
||||
Some("default".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
BranchName("master".to_string()).parse_channel(&ctx),
|
||||
BranchName("master".to_string())
|
||||
.parse_channel(&ctx)
|
||||
.unwrap(),
|
||||
Some("default".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
BranchName("my-cool-feature".to_string()).parse_version(&ctx),
|
||||
BranchName("my-cool-feature".to_string())
|
||||
.parse_version(&ctx)
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
+77
-101
@@ -1,106 +1,56 @@
|
||||
use crate::action_log::cl_log;
|
||||
use crate::action_pack::cl_pack;
|
||||
use crate::config::Config;
|
||||
use crate::store::Store;
|
||||
use anyhow::bail;
|
||||
use clap::builder::NonEmptyStringValueParser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smart_default::SmartDefault;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use colored::Colorize;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::exit;
|
||||
|
||||
mod config;
|
||||
|
||||
mod git;
|
||||
|
||||
mod action_log;
|
||||
mod action_pack;
|
||||
|
||||
mod store;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AppContext {
|
||||
config: Config,
|
||||
pub struct AppContext {
|
||||
/// Name of the cl binary
|
||||
pub binary_name: String,
|
||||
|
||||
root: PathBuf,
|
||||
}
|
||||
/// Config loaded from file or defaults
|
||||
pub config: Config,
|
||||
|
||||
/// Main app configuration file
|
||||
#[derive(Debug, Serialize, Deserialize, SmartDefault)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
struct Config {
|
||||
/// Folder for data files - the tool will manage contents of this folder.
|
||||
/// Changelog entries are simple text files that may be edited manually
|
||||
/// if corrections need to be made.
|
||||
#[default = "changelog"]
|
||||
data_folder: String,
|
||||
|
||||
/// ID of the default channel - this only matters inside this config file
|
||||
#[default = "default"]
|
||||
default_channel: String,
|
||||
|
||||
/// Path or file name of the default changelog file, relative to the root of the project.
|
||||
///
|
||||
/// The name is used as-is.
|
||||
#[default = "CHANGELOG.md"]
|
||||
changelog_file_default: String,
|
||||
|
||||
/// Path or file of a channel-specific changelog file, relative to the root of the project.
|
||||
///
|
||||
/// Placeholders supported are:
|
||||
/// - `{channel}`, `{Channel}`, `{CHANNEL}` - Channel ID in the respective capitalization
|
||||
#[default = "CHANGELOG-{CHANNEL}.md"]
|
||||
changelog_file_channel: String,
|
||||
|
||||
/// Changelog sections suggested when creating a new entry.
|
||||
///
|
||||
/// Users may also specify a custom section name.
|
||||
///
|
||||
/// Changelog entries under each section will be grouped in the packed changelog.
|
||||
#[default(vec![
|
||||
"New features".to_string(),
|
||||
"Improvements".to_string(),
|
||||
"Fixes".to_string(),
|
||||
])]
|
||||
sections: Vec<String>,
|
||||
|
||||
/// Changelog channels - how to identify them from git branch names
|
||||
///
|
||||
/// - Key - changelog ID; this can be used in the channel file name. Examples: default, eap, beta
|
||||
/// - Value - git branch name to recognize the channel. This is a regex pattern.
|
||||
///
|
||||
/// At least one channel must be defined, with the name defined in `default_channel`
|
||||
///
|
||||
/// # Value format
|
||||
/// For simple branch names without special symbols that do not change, e.g. `main`, `master`, `test`, you can just use the name as is.
|
||||
/// To specify a regex, enclose it in slashes, e.g. /rel\/foo/
|
||||
///
|
||||
/// If you have a naming schema like e.g. `beta/1.0` where only the prefix stays the same, you may use e.g. `^beta/.*`
|
||||
#[default(HashMap::from([
|
||||
("default".to_string(), "/^(?:main|master)$/".to_string())
|
||||
]))]
|
||||
channels: HashMap<String, String>,
|
||||
|
||||
/// Regex pattern to extract issue number from a branch name.
|
||||
/// There should be one capture group that is the number.
|
||||
///
|
||||
/// Example: `/^(SW-\d+)-.*$/` or `/^(\d+)-.*$/`
|
||||
///
|
||||
/// If None, no branch identification will be attempted.
|
||||
#[default(Some(r"/^((?:SW-)?\d+)-.*/".to_string()))]
|
||||
branch_issue_pattern: Option<String>,
|
||||
|
||||
/// Regex pattern to extract release number from a branch name.
|
||||
/// There should be one capture group that is the version.
|
||||
///
|
||||
/// Example: `/^rel\/(\d+.\d+)$/`
|
||||
///
|
||||
/// If None, no branch identification will be attempted.
|
||||
///
|
||||
/// TODO attempt to parse version from package.json, composer.json, Cargo.toml and others
|
||||
#[default(Some(r"/^rel\/(\d+\.\d+)$/".to_string()))]
|
||||
branch_version_pattern: Option<String>,
|
||||
/// Root of the project
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = clap::Command::new("cl")
|
||||
if let Err(e) = main_try() {
|
||||
eprintln!("{}", e.to_string().red().bold());
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn main_try() -> anyhow::Result<()> {
|
||||
let binary_name = std::env::current_exe()
|
||||
.map(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "cl".to_string());
|
||||
|
||||
let args = clap::Command::new(&binary_name)
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.author(env!("CARGO_PKG_AUTHORS"))
|
||||
.about(env!("CARGO_PKG_DESCRIPTION"))
|
||||
.subcommand(clap::Command::new("init")
|
||||
.about("Create the changelog folder and the default config file in the current working directory, if they do not exist yet."))
|
||||
.subcommand(
|
||||
clap::Command::new("pack")
|
||||
.visible_alias("release")
|
||||
@@ -130,19 +80,41 @@ fn main() {
|
||||
|
||||
let config_file_name: &str = specified_config_file.unwrap_or("clpack.toml");
|
||||
|
||||
eprintln!("Loading configuration from {}", config_file_name);
|
||||
// eprintln!("Loading configuration from {}", config_file_name);
|
||||
|
||||
let Ok(root) = std::env::current_dir() else {
|
||||
eprintln!("Failed to get current directory - is it deleted / inaccessible?");
|
||||
exit(1);
|
||||
bail!("Failed to get current directory - is it deleted / inaccessible?");
|
||||
};
|
||||
|
||||
let config_path = if config_file_name.starts_with("/") {
|
||||
// It's an absolute path
|
||||
PathBuf::from(config_file_name)
|
||||
} else {
|
||||
root.join(&config_file_name)
|
||||
};
|
||||
let config_path = root.join(&config_file_name); // if absolute, it is replaced by it
|
||||
|
||||
if let Some(("init", _)) = args.subcommand() {
|
||||
let mut default_config = Config::default();
|
||||
|
||||
if !config_path.exists() {
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(&config_path)?;
|
||||
|
||||
println!("Creating clpack config file");
|
||||
file.write_all(toml::to_string_pretty(&default_config)?.as_bytes())?;
|
||||
} else {
|
||||
println!("Loading existing config file: {}", config_path.display());
|
||||
let file_text = std::fs::read_to_string(&config_path)?;
|
||||
default_config = toml::from_str(&file_text)?;
|
||||
}
|
||||
|
||||
let ctx = AppContext {
|
||||
binary_name,
|
||||
config: default_config,
|
||||
root,
|
||||
};
|
||||
let _ = Store::new(&ctx, true)?;
|
||||
|
||||
println!("{}", "Changelog initialized.".green());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Load and parse config
|
||||
|
||||
@@ -150,34 +122,38 @@ fn main() {
|
||||
match toml::from_str(&config_file_content) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
bail!(
|
||||
"Failed to parse config file ({}): {}",
|
||||
config_path.display(),
|
||||
e
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
} else if specified_config_file.is_some() {
|
||||
// Failed to load config the user specifically asked for - make it an error
|
||||
eprintln!("Failed to load config file at {}", config_path.display());
|
||||
exit(1);
|
||||
bail!("Failed to load config file at {}", config_path.display());
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
|
||||
let ctx = AppContext { config, root };
|
||||
let ctx = AppContext {
|
||||
binary_name,
|
||||
config,
|
||||
root,
|
||||
};
|
||||
|
||||
// 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));
|
||||
cl_pack(ctx, manual_channel.map(String::as_str))?;
|
||||
}
|
||||
None | Some(("add", _)) => cl_log(ctx),
|
||||
None | Some(("add", _)) => cl_log(ctx)?,
|
||||
Some((other, _)) => {
|
||||
unimplemented!("Subcommand {other} is not implemented yet");
|
||||
bail!("Subcommand {other} is not implemented yet");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
use crate::AppContext;
|
||||
use anyhow::bail;
|
||||
use faccess::PathExt;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct Store<'a> {
|
||||
ctx: &'a AppContext,
|
||||
|
||||
store_path: PathBuf,
|
||||
}
|
||||
|
||||
impl<'a> Store<'a> {
|
||||
pub fn new(ctx: &'a AppContext, init: bool) -> anyhow::Result<Self> {
|
||||
let store_path = ctx.root.join(&ctx.config.data_folder);
|
||||
|
||||
if !store_path.is_dir() {
|
||||
if init {
|
||||
// Try to create it
|
||||
eprintln!("Creating changelog dir: {}", store_path.display());
|
||||
std::fs::create_dir_all(&store_path)?;
|
||||
} else {
|
||||
bail!(
|
||||
"Changelog directory does not exist: {}. Use `{} init` to create it.",
|
||||
ctx.binary_name,
|
||||
store_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !store_path.writable() {
|
||||
bail!(
|
||||
"Changelog directory is not writable: {}",
|
||||
store_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let store = Self { store_path, ctx };
|
||||
|
||||
store.ensure_internal_subdirs_exist()?;
|
||||
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Build a log entry file path.
|
||||
/// This is a file in the entries storage
|
||||
fn make_entry_path(&self, filename: &str) -> PathBuf {
|
||||
self.store_path
|
||||
.join("entries")
|
||||
.join(format!("{filename}.md"))
|
||||
}
|
||||
|
||||
/// Check if a changelog entry exists. Filename is passed without extension.
|
||||
/// This only checks within the current epoch as older files are no longer present.
|
||||
pub fn entry_exists(&self, name: &str) -> bool {
|
||||
let path = self.make_entry_path(name);
|
||||
path.exists()
|
||||
}
|
||||
|
||||
/// Check and create internal subdirs for the clpack system
|
||||
pub fn ensure_internal_subdirs_exist(&self) -> anyhow::Result<()> {
|
||||
self.ensure_subdir_exists("entries")?;
|
||||
// TODO
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// make sure a subdir exists, creating if needed.
|
||||
///
|
||||
/// Note there is no lock so there can be a TOCTOU bug later if someone deletes the path - must be checked and handled.
|
||||
fn ensure_subdir_exists(&self, name: &str) -> anyhow::Result<()> {
|
||||
let subdir = self.store_path.join(name);
|
||||
|
||||
if !subdir.is_dir() {
|
||||
if subdir.exists() {
|
||||
bail!(
|
||||
"Changelog subdir path is clobbered, must be a writable directory or not exist (will be crated): {}",
|
||||
subdir.display()
|
||||
);
|
||||
}
|
||||
eprintln!("Creating changelog subdir: {}", subdir.display());
|
||||
std::fs::create_dir_all(&subdir)?;
|
||||
}
|
||||
|
||||
if !subdir.writable() {
|
||||
bail!("Changelog subdir is not writable: {}", subdir.display());
|
||||
}
|
||||
|
||||
std::fs::File::create(subdir.join(".gitkeep"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a changelog entry file and write content to it
|
||||
pub fn create_entry(&self, name: String, 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());
|
||||
|
||||
file.write_all(content.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user