diff --git a/Cargo.lock b/Cargo.lock index 63993b2..b8044b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,7 @@ dependencies = [ "bugzilla_query", "color-eyre", "counter", + "ignore", "include_dir", "jira_query", "log", @@ -178,6 +179,16 @@ dependencies = [ "syn", ] +[[package]] +name = "bstr" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c79ad7fb2dd38f3dabd76b09c6a5a20c038fc0213ef1e9afd30eb777f120f019" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bugzilla_query" version = "1.0.2" @@ -397,6 +408,19 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" +[[package]] +name = "globset" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759c97c1e17c55525b57192c06a267cda0ac5210b222d6b82189a2338fa1c13d" +dependencies = [ + "aho-corasick", + "bstr", + "fnv", + "log", + "regex", +] + [[package]] name = "h2" version = "0.3.21" @@ -547,6 +571,23 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "ignore" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe7873dab538a9a44ad79ede1faf5f30d49f9a5c883ddbab48bce81b64b7492" +dependencies = [ + "globset", + "lazy_static", + "log", + "memchr", + "regex", + "same-file", + "thread_local", + "walkdir", + "winapi-util", +] + [[package]] name = "include_dir" version = "0.7.3" @@ -1034,6 +1075,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.22" @@ -1267,6 +1317,16 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.3.30" @@ -1457,6 +1517,16 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "walkdir" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 024e327..9236641 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ counter = "^0.5" regex = "1.10" once_cell = "1.18" include_dir = "0.7" +ignore = "0.4" [build-dependencies] bpaf = { version = "0.9", features = ["derive", "docgen"]} diff --git a/docs/main.adoc b/docs/main.adoc index f2d9667..43d206e 100644 --- a/docs/main.adoc +++ b/docs/main.adoc @@ -18,6 +18,8 @@ include::modules/proc_building-release-notes.adoc[leveloffset=+1] include::assembly_organizing-tickets-in-your-project-using-templates.adoc[leveloffset=+1] +include::modules/proc_adding-an-explanatory-footnote-to-private-tickets.adoc[leveloffset=+1] + include::modules/ref_differences-between-acorns-and-corn-3.adoc[leveloffset=+1] diff --git a/docs/modules/proc_adding-an-explanatory-footnote-to-private-tickets.adoc b/docs/modules/proc_adding-an-explanatory-footnote-to-private-tickets.adoc new file mode 100644 index 0000000..e295c16 --- /dev/null +++ b/docs/modules/proc_adding-an-explanatory-footnote-to-private-tickets.adoc @@ -0,0 +1,59 @@ +:_newdoc-version: 2.15.0 +:_template-generated: 2023-10-30 +:_mod-docs-content-type: PROCEDURE + +:private-footnote-text: This ticket is private. + +[id="adding-an-explanatory-footnote-to-private-tickets_{context}"] += Adding an explanatory footnote to private tickets + +[role="_abstract"] +Public ticket IDs contain a clickable link to the ticket. Private ticket IDs are non-clickable. If this is confusing to the readers, you can automatically generate a footnote next to private ticket IDs that explains why the ticket link is missing. + +For example: + +* link:https://bugzilla.redhat.com/show_bug.cgi?id=2157953[Bugzilla:2157953] +* Bugzilla:2216257{blank}footnoteref:[PrivateTicketFootnote,{private-footnote-text}] + + +.Procedure + +. Prepare the text that you want to add to every private ticket ID as a footnote. For example: ++ +[source,asciidoc] +---- +This ticket is private. +---- + +. In your release notes project, enter a footnote with the `PrivateTicketFootnote` ID in a manually written AsciiDoc file. ++ +The footnote must appear before you include the first file generated by {name}, such as near the book introduction or at the start of the main file. This requirement is caused by a technical limitation in the deprecated `footnoteref` macro. ++ +You can store the footnote text in a separate attribute, so that it is more convenient to edit: ++ +.Placement of the footnote in main.adoc +==== +[source,asciidoc"] +---- +:private-footnote-text: This ticket is private. + += Release notes for {Product} {Version} + +This is the abstract paragraph. + +Release notes include links to access the original tracking tickets. Private tickets have no links and instead feature the following footnote{blank}footnoteref:[PrivateTicketFootnote,{private-footnote-text}.] + + +---- +==== + +.Verification + +. Build the release notes project. +. Open a preview. +. Compare the IDs of public and private tickets. Verify that all private tickets feature the footnote. + +[role="_additional-resources"] +.Additional resources + +* If you want to hide all links to certain Jira projects and set them as private, see `private_projects` in xref:required-and-optional-fields-in-tracker-configuration_enabling-access-to-your-ticket-trackers[]. diff --git a/src/config.rs b/src/config.rs index f34063e..9f49352 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,8 @@ use std::sync::Arc; use color_eyre::eyre::{eyre, Result, WrapErr}; use serde::Deserialize; +use crate::footnote; + /// The name of this program, as specified in Cargo.toml. Used later to access configuration files. const PROGRAM_NAME: &str = env!("CARGO_PKG_NAME"); @@ -422,6 +424,7 @@ pub struct Project { pub tickets: Vec>, pub trackers: tracker::Config, pub templates: Template, + pub private_footnote: bool, } impl Project { @@ -452,12 +455,17 @@ impl Project { let trackers = parse_trackers(&trackers_path)?; let templates = parse_templates(&templates_path)?; + log::info!("Valid release notes project in {}.", abs_path.display()); + + let private_footnote = footnote::is_footnote_defined(&abs_path)?; + Ok(Self { base_dir: abs_path, generated_dir, tickets, trackers, templates, + private_footnote, }) } } diff --git a/src/convert.rs b/src/convert.rs index 918331e..b6ba175 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -13,10 +13,7 @@ use regex::Regex; use serde::Deserialize; use crate::config::{tracker::Service, KeyOrSearch}; - -/// A shared error message that displays if the static regular expressions -/// are invalid, and the regex library can't parse them. -const REGEX_ERROR: &str = "Invalid built-in regular expression."; +use crate::REGEX_ERROR; /// A regular expression that matches the Bugzilla ID format in CoRN 3. static BZ_REGEX: Lazy = Lazy::new(|| Regex::new(r"^BZ#(\d+)$").expect(REGEX_ERROR)); diff --git a/src/footnote.rs b/src/footnote.rs new file mode 100644 index 0000000..5159713 --- /dev/null +++ b/src/footnote.rs @@ -0,0 +1,88 @@ +/* +acorns: Generate an AsciiDoc release notes document from tracking tickets. +Copyright (C) 2023 Marek Suchánek + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +//! This module provides functionality that connects to the signature format in the release note, +//! based on an optional footnote found in the manual AsciiDoc files in the docs repo. +//! +//! If any manual AsciiDoc file defines the `PrivateTicketFootnote` footnote, private tickets +//! will add the footnote to the non-clickable ticket signature. + +use std::fs; +use std::path::Path; + +use color_eyre::{Result, eyre::WrapErr}; +use ignore::Walk; +use once_cell::sync::Lazy; +use regex::Regex; + +use crate::REGEX_ERROR; + +/// This regex looks for a footnote definition with the `PrivateTicketFootnote` ID. +static FOOTNOTE_ATTR_REGEX: Lazy = + Lazy::new(|| Regex::new(r"footnoteref:\[PrivateTicketFootnote,.+\]").expect(REGEX_ERROR)); + + +/// Search the AsciiDoc files in the RN project and see if any of them defines +/// the `PrivateTicketFootnote` footnote. Return `true` if the footnote is defined. +#[must_use] +pub fn is_footnote_defined(project: &Path) -> Result { + for result in Walk::new(project) { + // Each item yielded by the iterator is either a directory entry or an error. + let dir_entry = result?; + + let file_path = dir_entry.path(); + + if is_file_adoc(file_path) && file_contains_footnote(file_path)? { + log::info!("The private ticket footnote is defined."); + return Ok(true); + } + } + + Ok(false) +} + +/// Estimate if the given file is an AsciiDoc file. +fn is_file_adoc(path: &Path) -> bool { + let adoc_extensions = ["adoc", "asciidoc"]; + + let file_ext = path.extension().and_then(|ext| ext.to_str()); + + if let Some(ext) = file_ext { + if adoc_extensions.contains(&ext) { + return true; + } + } + + false +} + +/// Return `true` if the given file contains the footnote defined +/// in the `FOOTNOTE_ATTR_REGEX` regular expression. +fn file_contains_footnote(path: &Path) -> Result { + let text = fs::read_to_string(path) + .wrap_err("Cannot read AsciiDoc file in the project repository.")?; + + let found_attr = text.lines().any(|line| { + // Detect and reject basic line comments. + !line.starts_with("//") && + // If any line contains the footnote attribute definition, return `true`. + FOOTNOTE_ATTR_REGEX.is_match(line) + }); + + Ok(found_attr) +} diff --git a/src/lib.rs b/src/lib.rs index a1b979b..59e510f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,7 @@ pub mod cli; mod config; mod convert; mod extra_fields; +mod footnote; mod init; mod logging; mod note; @@ -55,6 +56,10 @@ use templating::{DocumentVariant, Module}; use crate::config::Project; pub use crate::ticket_abstraction::AbstractTicket; +/// A shared error message that displays if the static regular expressions +/// are invalid, and the regex library can't parse them. +pub const REGEX_ERROR: &str = "Invalid built-in regular expression."; + /// Run the subcommand that the user picked on the command line. pub fn run(cli: &Cli) -> Result<()> { // Initialize the logging system based on the set verbosity @@ -116,7 +121,7 @@ fn build_rn_project(project_dir: &Path) -> Result<()> { // TODO: Recognize the optional paths to different config files. let project = Project::new(project_dir)?; - log::info!("Building release notes in {}", &project.base_dir.display()); + log::info!("Building the release notes project."); let document = Document::new(&project)?; @@ -152,11 +157,13 @@ impl Document { &tickets_for_internal, &project.templates, DocumentVariant::Internal, + project.private_footnote, ); let external_modules = templating::format_document( &tickets_for_external, &project.templates, DocumentVariant::External, + project.private_footnote, ); let (status_table, json_status) = status_report::analyze_status(&abstract_tickets)?; diff --git a/src/note.rs b/src/note.rs index 15d3faa..68af8be 100644 --- a/src/note.rs +++ b/src/note.rs @@ -22,7 +22,7 @@ use crate::ticket_abstraction::AbstractTicket; impl AbstractTicket { /// Compose a release note from an abstract ticket. #[must_use] - pub fn release_note(&self, variant: DocumentVariant) -> String { + pub fn release_note(&self, variant: DocumentVariant, with_priv_footnote: bool) -> String { let anchor = self.anchor_declaration(); // This debug information line appears at empty release notes @@ -51,7 +51,7 @@ impl AbstractTicket { "{}\n{}\n\n{} {}", anchor, doc_text_unix, - self.all_signatures(), + self.all_signatures(with_priv_footnote), // In the internal variant, add the debug information line. if variant == DocumentVariant::Internal { &debug_info @@ -67,32 +67,30 @@ impl AbstractTicket { /// /// For example, `link:https://...bugzilla...12345[BZ#12345]`. #[must_use] - pub fn signature(&self) -> String { + pub fn signature(&self, with_priv_footnote: bool) -> String { let id = &self.id; if self.public { // If the ticket is public, add a clickable link. format!("link:{}[{}]", &self.url, id) } else { - // If the ticket is private, add a footnote that explains - // why some links aren't clickable. - // The shared ID of the private footnote is arbitrary and large to avoid clashes: - // 255 is the u8 max value. - // The `{private-footnote}` attribute is defined in the reference template, - // and the user can override it in their AsciiDoc files. - // - // TODO: This works with asciidoctor, but the footnote doesn't render - // at all with Pantheon. Disabling for now. - // format!("{id}{{fn-private}}") - id.to_string() + // If the ticket is private, and the project configures a dedicated footnote, + // add a footnote that explains why the link isn't clickable. + // This uses the deprecated AsciiDoc `footnoteref` syntax + // so that you can build the document with very outdated asciidoctor. + if with_priv_footnote { + format!("{id}footnoteref:[PrivateTicketFootnote]") + } else { + id.to_string() + } } } /// Prepare a list with signatures to this ticket and all its optional references. /// The result is a comma-separated list of signatures, enclosed in parentheses. #[must_use] - fn all_signatures(&self) -> String { - let mut signatures = vec![self.signature()]; + fn all_signatures(&self, with_priv_footnote: bool) -> String { + let mut signatures = vec![self.signature(with_priv_footnote)]; if let Some(references) = self.references.as_ref() { signatures.append(&mut references.clone()); diff --git a/src/references.rs b/src/references.rs index c14da9e..842bf8a 100644 --- a/src/references.rs +++ b/src/references.rs @@ -82,8 +82,10 @@ impl ReferenceSignatures { let ticket = issue.into_abstract(None, config)?; signatures .entry(query) - .and_modify(|e| e.push(ticket.signature())) - .or_insert_with(|| vec![ticket.signature()]); + // In reference IDs, never display the private ticket footnote, + // even when it's defined in the project. Too much clutter on one line. + .and_modify(|e| e.push(ticket.signature(false))) + .or_insert_with(|| vec![ticket.signature(false)]); } Ok(()) diff --git a/src/templating.rs b/src/templating.rs index 7976c65..a7eea21 100644 --- a/src/templating.rs +++ b/src/templating.rs @@ -161,6 +161,7 @@ impl config::Section { id: &str, tickets: &[&AbstractTicket], variant: DocumentVariant, + with_priv_footnote: bool, ticket_stats: &mut HashMap, u32>, ) -> Option { let matching_tickets: Vec<_> = tickets.iter().filter(|t| self.matches_ticket(t)).collect(); @@ -178,7 +179,7 @@ impl config::Section { } else { let release_notes: Vec<_> = matching_tickets .iter() - .map(|t| t.release_note(variant)) + .map(|t| t.release_note(variant, with_priv_footnote)) .collect(); let template = Leaf { @@ -206,6 +207,7 @@ impl config::Section { tickets: &[&AbstractTicket], prefix: Option<&str>, variant: DocumentVariant, + with_priv_footnote: bool, ticket_stats: &mut HashMap, u32>, ) -> Option { let matching_tickets: Vec<&AbstractTicket> = tickets @@ -227,7 +229,7 @@ impl config::Section { let included_modules: Vec = sections .iter() .filter_map(|s| { - s.modules(&matching_tickets, Some(&module_id), variant, ticket_stats) + s.modules(&matching_tickets, Some(&module_id), variant, with_priv_footnote, ticket_stats) }) .collect(); // If the assembly receives no modules, because all its modules are empty, return None. @@ -261,7 +263,7 @@ impl config::Section { } else { // If the module receives no release notes and its body is empty, return None. // Otherwise, return the module formatted with its release notes. - self.render(&module_id, tickets, variant, ticket_stats) + self.render(&module_id, tickets, variant, with_priv_footnote, ticket_stats) .map(|text| Module { file_name: format!("ref_{module_id}.adoc"), text, @@ -335,6 +337,7 @@ pub fn format_document( tickets: &[&AbstractTicket], template: &config::Template, variant: DocumentVariant, + with_priv_footnote: bool, ) -> Vec { // Prepare a container for ticket usage statistics. let mut ticket_stats = HashMap::new(); @@ -354,7 +357,7 @@ pub fn format_document( let chapters: Vec<_> = template .chapters .iter() - .filter_map(|section| section.modules(tickets, None, variant, &mut ticket_stats)) + .filter_map(|section| section.modules(tickets, None, variant, with_priv_footnote, &mut ticket_stats)) .collect(); log::debug!("Chapters: {:#?}", chapters);