Skip to content

Commit

Permalink
Add the ability to view CI checks via Rox (#8)
Browse files Browse the repository at this point in the history
Co-authored-by: ThomasLaPiana <[email protected]>
  • Loading branch information
ThomasLaPiana and ThomasLaPiana authored Jan 1, 2024
1 parent 955f1ea commit e5ae4f4
Show file tree
Hide file tree
Showing 9 changed files with 247 additions and 20 deletions.
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ chrono = "0.4.31"
clap = { version = "4.4.4", features = ["string", "cargo"] }
cli-table = "0.4.7"
colored = "2.0.4"
git2 = "0.18.1"
octocrab = "0.32.0"
rayon = "1.8.0"
serde = { version = "1.0.188", features = ["derive"] }
serde_yaml = "0.9.25"
termimad = "0.26.1"
tokio = { version = "1.35.1", features = ["tokio-macros", "full"] }
webbrowser = "0.8.12"

[dev-dependencies]
Expand Down
32 changes: 17 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
![crates.io](https://img.shields.io/crates/v/rox-cli.svg)
[![CI Checks](https://github.com/ThomasLaPiana/rox/actions/workflows/checks.yml/badge.svg)](https://github.com/ThomasLaPiana/rox/actions/workflows/checks.yml)

Composable build tool inspired by [Nox](https://nox.thea.codes/en/stable/), Make & [cargo-make](https://github.com/sagiegurari/cargo-make)
Rox is a robust developer workflow framework inspired by [Nox](https://nox.thea.codes/en/stable/), Make & [cargo-make](https://github.com/sagiegurari/cargo-make)

Rox gives you the ability to build your own devtools CLI using YAML files. Tasks and Pipelines are dynamically added to the CLI as subcommands at runtime. The flexibility of `rox` intends to makes it easier for dev teams to standardize their workflows without writing endless "glue" scripts.
Rox gives you the ability to build your own devtools CLI using YAML files, check CI pipeline statuses from your terminal, and even read documentation. Tasks and Pipelines are dynamically added to the CLI as subcommands at runtime. The flexibility of `rox` intends to makes it easier for dev teams to standardize their workflows without writing endless "glue" scripts.

The subcommands and their help messages are automatically populated at runtime from the `name` and `description` of each `task` or `pipeline`.

Expand All @@ -17,6 +17,7 @@ See [synthesizer](https://github.com/ThomasLaPiana/synthesizer) for an example o
- [Video Walkthrough](#video-walkthrough)
- [Installation](#installation)
- [Roxfile Syntax](#roxfile-syntax)
- [CI](#ci)
- [Docs](#docs)
- [Templates](#templates)
- [Tasks](#tasks)
Expand Down Expand Up @@ -48,6 +49,20 @@ Rox can be installed via binaries provided with each release [here](https://gith

Rox requires a `YAML` file with the correct format and syntax to be parsed into a CLI. This file is expected to be at `./roxfile.yml` by default but that can be overriden with the `-f` flag at runtime.

### CI

Rox allows developers to see CI pipeline results from their terminal with some minimal configuration.

```yaml
ci:
# The CI provider to use. Currently only GitHub Actions is supported.
provider: github_actions
repo_owner: ThomasLaPiana
repo_name: rox
# The name of the env var with the stored PAT (Personal Access Token)
token_env_var: GITHUB_TOKEN
```
### Docs
Specifying `docs` within your `roxfile` allows you to keep track of various documentation for your project, with multiple supported formats usable via the `kind` field. The supported values are as follows:
Expand Down Expand Up @@ -127,16 +142,3 @@ pipelines:
### Putting it all together

Now that we've seen each individual piece of the Rox puzzle, we can put them all together into a full `roxfile`. See the [example roxfile.yml](roxfile.yml) in this repo for a working example!

## Releasing

`Rox` is released by running `cargo release` locally.

Steps to Release:

1. Make sure that all desired changes are pushed up and merged to `main`
1. `cargo install cargo-release` (if not already installed)
1. `cargo release [major|minor|patch] --execute` - Updates the `Cargo.toml`, commits and pushes the change, and then publishes the crate to <crates.io>
1. `cargo release tag --execute` - Creates a git tag with the same version as the `Cargo.toml`
1. `cargo release push --execute` - Pushes the git tag
1. Finally, a CI job is automatically triggered to build and upload the release assets
12 changes: 12 additions & 0 deletions docs/release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Releasing

`Rox` is released by running `cargo release` locally.

Steps to Release:

1. Make sure that all desired changes are pushed up and merged to `main`
1. `cargo install cargo-release` (if not already installed)
1. `cargo release [major|minor|patch] --execute` - Updates the `Cargo.toml`, commits and pushes the change, and then publishes the crate to <crates.io>
1. `cargo release tag --execute` - Creates a git tag with the same version as the `Cargo.toml`
1. `cargo release push --execute` - Pushes the git tag
1. Finally, a CI job is automatically triggered to build and upload the release assets
16 changes: 16 additions & 0 deletions roxfile.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
ci:
# The CI provider to use. Currently only GitHub Actions is supported.
provider: github_actions
repo_owner: ThomasLaPiana
repo_name: rox
token_env_var: GITHUB_TOKEN

# Add documentation to your Rox CLI
docs:
- name: "release"
description: "Documentation around cutting releases"
kind: markdown
path: "docs/release.md"

- name: "markdown"
description: "Development-related documentation"
# Markdown files are rendered in a special viewer within the terminal
kind: markdown
path: "docs/dev_docs.md"

Expand All @@ -11,11 +25,13 @@ docs:

- name: "text"
description: "Development-related documentation"
# Text files are printed directly to stdout
kind: text
path: "docs/dev_docs.txt"

- name: "url"
description: "Development-related documentation"
# URLs are opened in the default browser
kind: url
path: "http://google.com"

Expand Down
170 changes: 170 additions & 0 deletions src/ci.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
use crate::models::CiInfo;
use chrono::{DateTime, Utc};
use cli_table::{format::Justify, print_stdout, Cell, Style, Table};
use colored::Colorize;
use git2::Repository;
use octocrab::models::workflows::Conclusion;
use octocrab::params::workflows::Filter;

pub enum StepStatus {
Success,
Failed,
Skipped,
InProgress,
Cancelled,
Other,
}

impl std::fmt::Display for StepStatus {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let message = match self {
StepStatus::Success => "Success",
StepStatus::Failed => "Failed",
StepStatus::Skipped => "Skipped",
StepStatus::InProgress => "In Progress",
StepStatus::Cancelled => "Cancelled",
StepStatus::Other => "Other",
};
write!(f, "{}", message)
}
}

/// Convert the OctoCrab Conclusion enum to a StepStatus enum
/// for more user-friendly messaging.
pub fn step_conclusion_lookup(conclusion: &Conclusion) -> StepStatus {
match conclusion {
Conclusion::Success => StepStatus::Success,
Conclusion::Failure | Conclusion::TimedOut => StepStatus::Failed,
Conclusion::Skipped => StepStatus::Skipped,
Conclusion::Cancelled => StepStatus::Cancelled,
Conclusion::ActionRequired | Conclusion::Neutral => StepStatus::Other,
_ => StepStatus::InProgress,
}
}

pub struct RunResult {
name: String,
job: String,
status: StepStatus,
started_at: Option<DateTime<Utc>>,
ended_at: Option<DateTime<Utc>>,
}
impl RunResult {
pub fn get_elapsed_time(&self) -> String {
let elapsed_time = match self.ended_at {
Some(ended_at) => {
let started_at = self.started_at.as_ref().unwrap();
let elapsed_time = ended_at.signed_duration_since(started_at).num_seconds();
elapsed_time.to_string()
}
None => "N/A".to_string(),
};
elapsed_time
}
}

/// Print the execution results in a pretty table format
pub fn display_results_table(results: &[RunResult]) {
let mut table = Vec::new();

results.iter().for_each(|result| {
// Convert the StepStatus enum to a string with the correct color
let status_string = result.status.to_string();
let status = match result.status {
StepStatus::Success => "Success"
.to_string()
.green()
.cell()
.justify(Justify::Center),
StepStatus::Failed => status_string.red().cell().justify(Justify::Center),
_ => status_string.yellow().cell().justify(Justify::Center),
};

// Add a row to the table
table.push(vec![
result.name.to_owned().cell(),
result.job.clone().cell().justify(Justify::Center),
status,
result.get_elapsed_time().cell().justify(Justify::Center),
])
});

assert!(print_stdout(
table
.table()
.title(vec![
"Name".yellow().cell().bold(true),
"Job".yellow().cell().bold(true),
"Status".yellow().cell().bold(true),
"Elapsed Time (sec)".yellow().cell().bold(true),
])
.bold(true),
)
.is_ok());
}

/// Show the most recent CI workflow
pub async fn display_ci_status(ci_info: CiInfo) {
// Configure Git and retrieve repo info
let repo = Repository::open_from_env().unwrap();
let head = repo.head().unwrap();
assert!(head.is_branch());
let branch = head.name().unwrap().split('/').last().unwrap();
println!("> Getting CI status for branch: {}", branch);

// Build an Authenticated GitHub Client
let token = std::env::var(ci_info.token_env_var).expect("Failed to get token from env var!");
let octo_instance = octocrab::OctocrabBuilder::new()
.personal_token(token)
.build()
.unwrap();

// Verify that the client is authorized
if octo_instance.current().user().await.is_err() {
panic!("GitHub client is not authorized!");
}

let workflow_instance = octo_instance.workflows(ci_info.repo_owner, ci_info.repo_name);
let workflow = workflow_instance
.list_all_runs()
.page(1u32)
.per_page(1)
.branch(branch)
.send()
.await
.expect("Failed to retrieve workflow data!")
.into_iter()
.next()
.unwrap();

let results: Vec<RunResult> = workflow_instance
.list_jobs(workflow.id)
.per_page(100)
.page(1u8)
.filter(Filter::All)
.send()
.await
.unwrap()
.into_iter()
.flat_map(|job| {
let results: Vec<RunResult> = job
.steps
.into_iter()
.map(|step| RunResult {
name: step.name,
job: job.name.clone(),
status: if step.conclusion.is_none() {
StepStatus::InProgress
} else {
step_conclusion_lookup(step.conclusion.as_ref().unwrap())
},
started_at: step.started_at,
ended_at: step.completed_at,
})
.collect();
results
})
.collect();

display_results_table(&results);
}
9 changes: 8 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
use crate::models::{Docs, Pipeline, Task};
use crate::models::{CiInfo, Docs, Pipeline, Task};
use clap::{crate_version, Arg, ArgAction, Command};

/// Dyanmically construct the CLI from the Roxfile
pub fn construct_cli(
tasks: &[Task],
pipelines: &Option<Vec<Pipeline>>,
docs: &Option<Vec<Docs>>,
ci: &Option<CiInfo>,
) -> clap::Command {
let mut cli = cli_builder(true);

// CI
if ci.is_some() {
// TODO: Add a flag to only show failures
cli = cli.subcommand(Command::new("ci").about("View CI pipeline information."));
}

// Docs
if let Some(docs) = docs {
let docs_subcommands = build_docs_subcommands(docs);
Expand Down
11 changes: 9 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod ci;
mod cli;
mod docs;
mod execution;
Expand Down Expand Up @@ -30,7 +31,7 @@ fn get_filepath_arg_value() -> String {
}

/// Entrypoint for the Crate CLI
pub fn rox() -> RoxResult<()> {
pub async fn rox() -> RoxResult<()> {
let start = std::time::Instant::now();
let execution_start = chrono::Utc::now().to_rfc3339();

Expand All @@ -48,7 +49,8 @@ pub fn rox() -> RoxResult<()> {
let tasks = inject_task_metadata(roxfile.tasks, &file_path);
let pipelines = inject_pipeline_metadata(roxfile.pipelines);
let docs = roxfile.docs;
let cli = construct_cli(&tasks, &pipelines, &docs);
let ci = roxfile.ci;
let cli = construct_cli(&tasks, &pipelines, &docs, &ci);
let cli_matches = cli.get_matches();

// Build Hashmaps for Tasks, Templates and Pipelines
Expand Down Expand Up @@ -91,6 +93,11 @@ pub fn rox() -> RoxResult<()> {
output::display_logs(number);
std::process::exit(0);
}
"ci" => {
assert!(ci.is_some());
ci::display_ci_status(ci.unwrap()).await;
std::process::exit(0);
}
"pl" => {
let pipeline_map: HashMap<String, models::Pipeline> =
std::collections::HashMap::from_iter(
Expand Down
5 changes: 3 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
fn main() {
if let Err(e) = rox::rox() {
#[tokio::main]
async fn main() {
if let Err(e) = rox::rox().await {
eprintln!("{}", e);
std::process::exit(1);
}
Expand Down
9 changes: 9 additions & 0 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ use serde::{Deserialize, Serialize};
use std::error::Error;
use std::fmt;

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CiInfo {
pub provider: String,
pub repo_owner: String,
pub repo_name: String,
pub token_env_var: String,
}

/// Format for completed executions
#[derive(Serialize, Deserialize, Debug)]
pub struct AllResults {
Expand Down Expand Up @@ -178,6 +186,7 @@ pub struct Pipeline {
#[derive(Deserialize, Debug, Default, Clone)]
#[serde(deny_unknown_fields)]
pub struct RoxFile {
pub ci: Option<CiInfo>,
pub docs: Option<Vec<Docs>>,
pub tasks: Vec<Task>,
pub pipelines: Option<Vec<Pipeline>>,
Expand Down

0 comments on commit e5ae4f4

Please sign in to comment.