diff --git a/Cargo.lock b/Cargo.lock index f9c7e12..20e0770 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,6 +69,8 @@ name = "github_action_doc" version = "0.1.0" dependencies = [ "clap", + "heck", + "indoc", "serde", "serde_yaml", ] @@ -104,6 +106,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adab1eaa3408fb7f0c777a73e7465fd5656136fc93b670eb6df3c88c2c1344e3" + [[package]] name = "itoa" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 47972cb..92525b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,5 +15,7 @@ path = "src/main.rs" [dependencies] clap = { version = "3.2.22", features = ["derive"] } +heck = "0.4.0" +indoc = "1.0.7" serde = { version = "1.0.145", features = ["derive"] } serde_yaml = "0.9.13" diff --git a/src/cli.rs b/src/cli.rs index 2ab2724..0a4bc94 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -16,6 +16,10 @@ pub enum Commands { Action { #[clap(required = true, value_parser)] action_file: String + }, + Workflow { + #[clap(required = true, value_parser)] + workflow_file: String } } diff --git a/src/main.rs b/src/main.rs index be03418..5d1969c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,14 @@ +extern crate core; + mod cli; mod action_doc; +mod markdown; +mod workflow_docs; use std::fs; use std::path::Path; use action_doc::GithubAction; +use crate::workflow_docs::GitHubWorkflow; fn main() -> Result<(), Box> { let args = cli::parse_args(); @@ -15,6 +20,18 @@ fn main() -> Result<(), Box> { let readme_path = Path::new(&action_file).to_path_buf().parent().unwrap().join("README.md"); fs::write(readme_path.to_str().unwrap(), gha.to_markdown()).expect("Unable to write readme"); } + cli::Commands::Workflow { workflow_file} => { + let workflow = GitHubWorkflow::parse(&workflow_file) + .expect("Unable to parse workflow"); + let wf_path = Path::new(&workflow_file).to_path_buf(); + let readme_path = wf_path + .parent() + .unwrap() + .join(&format!("{}.md", wf_path.file_stem().unwrap().to_str().unwrap())); + + println!("Writing workflow readme {:?}", &readme_path); + fs::write(readme_path.to_str().unwrap(), workflow.to_markdown()).expect("Unable to write readme"); + } } Ok(()) diff --git a/src/markdown.rs b/src/markdown.rs new file mode 100644 index 0000000..1ea93f4 --- /dev/null +++ b/src/markdown.rs @@ -0,0 +1,57 @@ +#![allow(dead_code)] +use std::fmt::{Display, Formatter}; + +pub struct Markdown { + doc: String +} + +impl Markdown { + pub fn new() -> Markdown { + Markdown { doc: String::new() } + } + + pub fn append_heading(&mut self, text: &str) { + self.doc.push_str(&format!("# {}\n\n", text)); + } + + pub fn append_new_lines(&mut self, n: u8) { + for _i in 0..n { + self.doc.push_str("\n") + } + } + + pub fn append_text(&mut self, text: &str) { + self.doc.push_str(text); + } + + pub fn append_line(&mut self, text: &str) { + self.doc.push_str(text); + self.append_new_lines(1); + } + + pub fn append_list(&mut self, items: &Vec) { + self.doc.push_str(&Self::make_list(items)) + } + + pub fn make_list(items: &Vec) -> String { + let mut m = String::new(); + + for item in items { + m.push_str(&format!("* {}\n", item)); + } + + m.push_str("\n"); + + return m; + } +} + +impl Display for Markdown { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.doc) + } +} + +pub fn backtick(s: &String) -> String { + format!("`{}`", s) +} diff --git a/src/workflow_docs.rs b/src/workflow_docs.rs new file mode 100644 index 0000000..a07c090 --- /dev/null +++ b/src/workflow_docs.rs @@ -0,0 +1,50 @@ +mod inputs; +mod jobs; +mod triggers; + +use std::collections::HashMap; +use std::fs::File; +use heck::ToSnakeCase; +use serde::{Deserialize}; +use triggers::{GithubWorkflowTrigger, GithubWorkflowTriggerPayload }; +use jobs::WorkflowJob; +use crate::markdown::Markdown; + +#[derive(Debug, Deserialize, PartialEq)] +pub struct GitHubWorkflow { + pub name: String, + pub on: HashMap, + pub jobs: HashMap +} + +impl GitHubWorkflow { + pub fn parse(path: &String) -> Result> { + let f = File::open(path).unwrap(); + let action: GitHubWorkflow = serde_yaml::from_reader(f)?; + Ok(action) + } + + pub fn to_markdown(self) -> String { + let mut doc = Markdown::new(); + + doc.append_heading(&self.name); + + // triggers + doc.append_line("## Triggers\n"); + let trigger_items = &self.on.keys().map(|t| t.to_string().to_snake_case()).collect(); + doc.append_list(trigger_items); + doc.append_new_lines(1); + + for (trigger, payload) in &self.on { + doc.append_text(&payload.to_markdown(trigger)); + } + + // jobs + doc.append_line("## Jobs\n"); + for (_name, job) in &self.jobs { + doc.append_text(&job.to_markdown()); + } + + return doc.to_string(); + } +} diff --git a/src/workflow_docs/inputs.rs b/src/workflow_docs/inputs.rs new file mode 100644 index 0000000..fe0f5ec --- /dev/null +++ b/src/workflow_docs/inputs.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize}; + +#[derive(Debug, Deserialize, PartialEq)] +pub struct GithubWorkflowInput { + pub description: String, + pub default: Option, + #[serde(default)] + pub required: bool, + #[serde(alias = "type")] + pub input_type: String +} + +#[derive(Debug, Deserialize, PartialEq)] +pub struct GithubWorkflowSecret { + pub description: String, + #[serde(default)] + pub required: bool +} diff --git a/src/workflow_docs/jobs.rs b/src/workflow_docs/jobs.rs new file mode 100644 index 0000000..3510ae0 --- /dev/null +++ b/src/workflow_docs/jobs.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize}; +use crate::markdown::Markdown; + +#[derive(Debug, Deserialize, PartialEq)] +pub struct WorkflowJob { + pub name: String, + pub uses: Option, + #[serde(default)] + pub needs: Vec, + #[serde(default)] + pub steps: Vec +} + +#[derive(Debug, Deserialize, PartialEq)] +pub struct WorkflowJobStep { + pub id: Option, + pub name: Option, + pub run: Option, + pub uses: Option +} + +impl WorkflowJob { + pub fn to_markdown(&self) -> String { + let mut d = Markdown::new(); + + d.append_text(&format!("### {} ", &self.name)); + d.append_new_lines(2); + + match &self.uses { + Some(uses) => { + d.append_line(&format!("Uses the callable workflow: `{}`", uses)); + d.append_new_lines(1); + } + _ => {} + } + + if !self.steps.is_empty() { + d.append_line(&format!("**Steps:**")); + for step in &self.steps { + let uses = match &step.uses { + Some(uses) => { uses } + None => { "" } + }; + + let name = match &step.name { + Some(name) => { name } + None => { + match &step.id { + Some(id) => { id } + None => { uses } + } + } + }; + + d.append_line(&format!("* {} (`{}`)", name, match uses.is_empty() { true => "script", false => uses } )); + } + d.append_new_lines(1); + } + + return d.to_string() + } +} diff --git a/src/workflow_docs/triggers.rs b/src/workflow_docs/triggers.rs new file mode 100644 index 0000000..3fff6ed --- /dev/null +++ b/src/workflow_docs/triggers.rs @@ -0,0 +1,127 @@ +use std::collections::HashMap; +use std::fmt::{Formatter}; +use indoc::indoc; +use serde::{Deserialize}; +use crate::markdown::{backtick, Markdown}; +use super::inputs::{GithubWorkflowInput, GithubWorkflowSecret}; + +#[derive(Debug, Deserialize, PartialEq)] +pub struct GithubWorkflowTriggerPayload { + #[serde(default)] + pub branches: Vec, + #[serde(default)] + pub paths: Vec, + #[serde(default)] + pub inputs: HashMap, + #[serde(default)] + pub secrets: HashMap +} + +#[derive(Debug, Deserialize, Eq, PartialEq, Hash)] +pub enum GithubWorkflowTrigger { + #[serde(alias = "pull_request")] + PullRequest, + #[serde(alias = "push")] + Push, + #[serde(alias = "workflow_call")] + WorkflowCall, + #[serde(alias = "workflow_dispatch")] + WorkflowDispatch +} + +impl std::fmt::Display for GithubWorkflowTrigger { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl GithubWorkflowTriggerPayload { + + fn doc_pull_request(&self) -> String { + let mut doc = Markdown::new(); + + doc.append_line("### Pull Request"); + doc.append_new_lines(1); + + if !self.branches.is_empty() { + doc.append_line("Branches:"); + doc.append_list(&self.branches); + } + + if !self.paths.is_empty() { + doc.append_line("Paths:"); + let paths = &self.paths.iter().map(|t| backtick(t)).collect(); + doc.append_list(paths); + } + + return doc.to_string(); + } + + fn doc_workflow_call(&self) -> String { + let mut mdown = String::new(); + mdown.push_str(indoc!(" + ### Workflow Call + + This workflow can be [called](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_call) + from other workflows. + + #### Inputs + + ")); + + if !self.inputs.is_empty() { + mdown.push_str(indoc!(" + | Input | Description | Type | Required | Default | + |:-------------------|:------------|:---------|:---------|:--------| + ")); + for (name, input) in &self.inputs { + let input_default = match &input.default { + Some(x) => format!("`{}`", x), + None => " ".to_string() + }; + mdown.push_str(&format!( + "| {:18} | {:18} | {:10} | {:10} | {:18} |\n", + name, + input.description, + input.input_type, + match input.required { true => "yes", false => "no" }, + input_default + )); + } + } else { + mdown.push_str("No inputs.") + } + + mdown.push_str("\n\n#### Secrets\n"); + if !self.secrets.is_empty() { + mdown.push_str(indoc!(" + | Secret | Description | Required | + |:-------------------|:----------------|:---------| + ")); + for (name, secret) in &self.secrets { + mdown.push_str(&format!( + "| {:18} | {:18} | {:10} |\n", + name, + secret.description, + match secret.required { true => "yes", false => "no" } + )); + } + } else { + mdown.push_str("No secrets.") + } + + return mdown + } + + pub fn to_markdown(&self, trigger: &GithubWorkflowTrigger) -> String { + return match trigger { + GithubWorkflowTrigger::PullRequest => { + self.doc_pull_request() + }, + GithubWorkflowTrigger::WorkflowCall => { + self.doc_workflow_call() + } + _ => { format!("") } + } + } +}