use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Default)]
pub struct DashboardResponse {
#[serde(rename = "_embedded", default)]
pub embedded: DashboardEmbedded,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct DashboardEmbedded {
#[serde(default)]
pub pipeline_groups: Vec<DashboardGroup>,
#[serde(default)]
pub pipelines: Vec<DashboardPipeline>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardGroup {
pub name: String,
#[serde(default)]
pub pipelines: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardPipeline {
pub name: String,
#[serde(default)]
pub locked: bool,
#[serde(default)]
pub pause_info: PauseInfo,
#[allow(dead_code)]
#[serde(default)]
pub can_pause: bool,
#[allow(dead_code)]
#[serde(default)]
pub can_operate: bool,
#[serde(rename = "_embedded", default)]
pub embedded: DashboardPipelineEmbedded,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PauseInfo {
pub paused: bool,
#[allow(dead_code)]
#[serde(default)]
pub paused_by: Option<String>,
#[allow(dead_code)]
#[serde(default)]
pub pause_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DashboardPipelineEmbedded {
#[serde(default)]
pub instances: Vec<DashboardInstance>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardInstance {
#[serde(default)]
pub label: String,
#[serde(default)]
pub counter: i64,
#[serde(default)]
pub triggered_by: Option<String>,
#[serde(rename = "_embedded", default)]
pub embedded: DashboardInstanceEmbedded,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DashboardInstanceEmbedded {
#[serde(default)]
pub stages: Vec<DashboardStage>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardStage {
pub name: String,
#[serde(default)]
pub status: Option<String>,
}
impl DashboardPipeline {
pub fn latest_status(&self) -> &'static str {
match self.embedded.instances.first() {
Some(inst) => rollup_status(inst.embedded.stages.iter().map(|s| s.status.as_deref())),
None => "Unknown",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct HistoryResponse {
#[serde(rename = "_links", default)]
pub links: Option<HistoryLinks>,
#[serde(default)]
pub pipelines: Vec<PipelineInstance>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct HistoryLinks {
#[serde(default)]
pub next: Option<Link>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Link {
pub href: String,
}
pub fn next_page_cursor(href: &str) -> Option<u64> {
let query = href.split('?').nth(1)?;
query
.split('&')
.find_map(|kv| kv.strip_prefix("after="))
.and_then(|v| v.parse().ok())
}
#[derive(Debug, Clone, Deserialize)]
pub struct PipelineInstance {
#[allow(dead_code)]
pub name: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub counter: i64,
#[serde(default)]
pub comment: Option<String>,
#[serde(default)]
pub scheduled_date: Option<i64>,
#[serde(default)]
pub stages: Vec<StageInstance>,
#[serde(default)]
pub build_cause: Option<BuildCause>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct StageInstance {
pub name: String,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub approval_type: Option<String>,
#[serde(default)]
pub scheduled_date: Option<i64>,
#[serde(default)]
pub counter: Option<String>,
#[serde(default)]
pub jobs: Vec<JobInstance>,
}
impl StageInstance {
pub fn is_active(&self) -> bool {
matches!(self.status.as_deref(), Some("Building") | Some("Scheduled"))
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct JobInstance {
pub name: String,
#[serde(default)]
pub result: Option<String>,
#[serde(default)]
pub state: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BuildCause {
#[serde(default)]
pub trigger_message: Option<String>,
#[serde(default)]
pub approver: Option<String>,
#[serde(default)]
pub material_revisions: Vec<MaterialRevision>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MaterialRevision {
pub material: MaterialInfo,
#[serde(default)]
pub modifications: Vec<Modification>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct MaterialInfo {
#[serde(rename = "type", default)]
pub kind: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Modification {
#[serde(default)]
pub revision: Option<String>,
#[serde(default)]
pub user_name: Option<String>,
#[serde(default)]
pub comment: Option<String>,
#[serde(default)]
pub modified_time: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitRef {
pub host: String,
pub owner: String,
pub repo: String,
pub branch: String,
pub deployed_sha: String,
pub via: Option<(String, i64)>,
}
impl GitRef {
pub fn key(&self) -> String {
format!("{}/{}/{}@{}", self.host, self.owner, self.repo, self.branch)
}
}
impl PipelineInstance {
fn first_git_revision(&self) -> Option<&MaterialRevision> {
self.build_cause
.as_ref()?
.material_revisions
.iter()
.find(|mr| mr.material.kind.as_deref() == Some("Git"))
}
pub fn git_refs(&self) -> Vec<GitRef> {
let Some(cause) = &self.build_cause else {
return Vec::new();
};
cause
.material_revisions
.iter()
.filter(|mr| mr.material.kind.as_deref() == Some("Git"))
.filter_map(|mr| {
let desc = mr.material.description.as_deref()?;
let (host, owner, repo) = parse_git_host_owner_repo(desc)?;
let branch = parse_branch(desc).unwrap_or_else(|| "main".to_string());
let deployed_sha = mr.modifications.first()?.revision.clone()?;
Some(GitRef {
via: None,
host,
owner,
repo,
branch,
deployed_sha,
})
})
.collect()
}
pub fn git_ref(&self) -> Option<GitRef> {
self.git_refs().into_iter().next()
}
pub fn git_modification(&self) -> Option<&Modification> {
self.first_git_revision()?.modifications.first()
}
}
pub fn upstream_deps(inst: &PipelineInstance) -> Vec<(String, i64)> {
let Some(cause) = &inst.build_cause else {
return Vec::new();
};
cause
.material_revisions
.iter()
.filter(|mr| mr.material.kind.as_deref() == Some("Pipeline"))
.filter_map(|mr| {
let rev = mr.modifications.first()?.revision.as_deref()?;
let mut parts = rev.split('/');
let name = parts.next()?.to_string();
let counter: i64 = parts.next()?.parse().ok()?;
(!name.is_empty()).then_some((name, counter))
})
.collect()
}
fn parse_git_host_owner_repo(desc: &str) -> Option<(String, String, String)> {
let after = desc.split("URL: ").nth(1)?;
let url = after.split(',').next()?.trim();
let (host, rest) = if let Some(ssh) = url.strip_prefix("git@") {
ssh.split_once(':')?
} else {
let no_scheme = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
no_scheme.split_once('/')?
};
let rest = rest
.trim_start_matches('/')
.trim_end_matches('/')
.trim_end_matches(".git");
let (owner, repo) = rest.split_once('/')?;
(!host.is_empty() && !owner.is_empty() && !repo.is_empty())
.then(|| (host.to_string(), owner.to_string(), repo.to_string()))
}
fn parse_branch(desc: &str) -> Option<String> {
let idx = desc.find("Branch: ")?;
Some(desc[idx + "Branch: ".len()..].trim().to_string())
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize, Default)]
pub struct PipelineStatus {
pub paused: bool,
pub locked: bool,
pub schedulable: bool,
}
fn rollup_status<'a>(statuses: impl Iterator<Item = Option<&'a str>>) -> &'static str {
let mut any_building = false;
let mut any_failed = false;
let mut any_cancelled = false;
let mut saw_any = false;
let mut all_passed = true;
for status in statuses {
saw_any = true;
match status {
Some("Passed") => {}
Some("Failed") => {
any_failed = true;
all_passed = false;
}
Some("Cancelled") => {
any_cancelled = true;
all_passed = false;
}
Some("Building") | Some("Scheduled") => {
any_building = true;
all_passed = false;
}
_ => all_passed = false,
}
}
if any_failed {
"Failed"
} else if any_cancelled {
"Cancelled"
} else if any_building {
"Building"
} else if saw_any && all_passed {
"Passed"
} else {
"Unknown"
}
}
impl PipelineInstance {
pub fn overall_status(&self) -> &'static str {
rollup_status(self.stages.iter().map(|s| s.status.as_deref()))
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ArtifactNode {
pub name: String,
#[serde(default)]
pub url: Option<String>,
#[serde(rename = "type", default)]
pub kind: Option<String>,
#[serde(default)]
pub files: Vec<ArtifactNode>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtifactRow {
pub depth: usize,
pub name: String,
pub is_folder: bool,
pub url: Option<String>,
pub path: String,
pub expanded: bool,
}
pub fn flatten_artifacts(
nodes: &[ArtifactNode],
expanded: &std::collections::HashSet<String>,
) -> Vec<ArtifactRow> {
fn walk(
nodes: &[ArtifactNode],
depth: usize,
prefix: &str,
expanded: &std::collections::HashSet<String>,
out: &mut Vec<ArtifactRow>,
) {
for n in nodes {
let is_folder = n.kind.as_deref() == Some("folder") || !n.files.is_empty();
let path = if prefix.is_empty() {
n.name.clone()
} else {
format!("{prefix}/{}", n.name)
};
let open = is_folder && expanded.contains(&path);
out.push(ArtifactRow {
depth,
name: n.name.clone(),
is_folder,
url: n.url.clone(),
path: path.clone(),
expanded: open,
});
if open {
walk(&n.files, depth + 1, &path, expanded, out);
}
}
}
let mut out = Vec::new();
walk(nodes, 0, "", expanded, &mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_page_cursor_parses_after_param() {
assert_eq!(
next_page_cursor("http://go/api/pipelines/x/history?after=205"),
Some(205)
);
assert_eq!(
next_page_cursor("/go/api/pipelines/x/history?page_size=10&after=42"),
Some(42)
);
assert_eq!(
next_page_cursor("/go/api/pipelines/x/history?after=42&page_size=10"),
Some(42)
);
assert_eq!(next_page_cursor("/go/api/pipelines/x/history"), None);
assert_eq!(next_page_cursor("/history?before=9"), None);
assert_eq!(next_page_cursor("/history?after=notanumber"), None);
}
#[test]
fn parse_host_owner_repo_forms() {
let f = |desc: &str| parse_git_host_owner_repo(desc);
assert_eq!(
f("URL: git@github.com:acme/web-app.git, Branch: main"),
Some(("github.com".into(), "acme".into(), "web-app".into()))
);
assert_eq!(
f("URL: https://github.com/acme/web-app, Branch: main"),
Some(("github.com".into(), "acme".into(), "web-app".into()))
);
assert_eq!(
f("URL: git@ghe.corp.io:platform/deploy.git, Branch: release"),
Some(("ghe.corp.io".into(), "platform".into(), "deploy".into()))
);
assert_eq!(
f("URL: https://ghe.corp.io/platform/deploy.git, Branch: release"),
Some(("ghe.corp.io".into(), "platform".into(), "deploy".into()))
);
assert_eq!(
f("URL: http://git.internal/team/tool, Branch: dev"),
Some(("git.internal".into(), "team".into(), "tool".into()))
);
assert_eq!(f("URL: /local/bare/repo.git, Branch: main"), None);
assert_eq!(f("no url here"), None);
assert_eq!(f("URL: https://host/only-owner, Branch: x"), None);
}
fn git_revision(url: &str, branch: &str, sha: &str) -> MaterialRevision {
MaterialRevision {
material: MaterialInfo {
kind: Some("Git".into()),
description: Some(format!("URL: {url}, Branch: {branch}")),
},
modifications: vec![Modification {
revision: Some(sha.into()),
user_name: None,
comment: None,
modified_time: None,
}],
}
}
#[test]
fn git_refs_returns_all_git_materials_in_order() {
let inst = PipelineInstance {
name: "p".into(),
label: "l".into(),
counter: 1,
comment: None,
scheduled_date: None,
stages: Vec::new(),
build_cause: Some(BuildCause {
trigger_message: None,
approver: None,
material_revisions: vec![
git_revision("git@github.com:acme/web-app.git", "main", "aaa"),
MaterialRevision {
material: MaterialInfo {
kind: Some("Pipeline".into()),
description: None,
},
modifications: Vec::new(),
},
git_revision("https://ghe.corp.io/platform/deploy", "release", "bbb"),
],
}),
};
let refs = inst.git_refs();
assert_eq!(refs.len(), 2);
assert_eq!(
(
refs[0].host.as_str(),
refs[0].repo.as_str(),
refs[0].deployed_sha.as_str()
),
("github.com", "web-app", "aaa")
);
assert_eq!(
(
refs[1].host.as_str(),
refs[1].branch.as_str(),
refs[1].deployed_sha.as_str()
),
("ghe.corp.io", "release", "bbb")
);
assert_eq!(inst.git_ref().unwrap().deployed_sha, "aaa");
assert_eq!(refs[0].key(), "github.com/acme/web-app@main");
}
#[test]
fn git_refs_empty_without_git_materials() {
let inst = PipelineInstance {
name: "p".into(),
label: "l".into(),
counter: 1,
comment: None,
scheduled_date: None,
stages: Vec::new(),
build_cause: None,
};
assert!(inst.git_refs().is_empty());
assert!(inst.git_ref().is_none());
}
fn pipeline_material(revision: &str) -> MaterialRevision {
MaterialRevision {
material: MaterialInfo {
kind: Some("Pipeline".into()),
description: Some("upstream [ stage ]".into()),
},
modifications: vec![Modification {
revision: Some(revision.into()),
user_name: None,
comment: None,
modified_time: None,
}],
}
}
fn instance_with(revisions: Vec<MaterialRevision>) -> PipelineInstance {
PipelineInstance {
name: "deploy".into(),
label: "l".into(),
counter: 12,
comment: None,
scheduled_date: None,
stages: Vec::new(),
build_cause: Some(BuildCause {
trigger_message: None,
approver: None,
material_revisions: revisions,
}),
}
}
#[test]
fn upstream_deps_parses_pipeline_material_revisions() {
let inst = instance_with(vec![pipeline_material("my-build/389/lint-test-build/1")]);
assert_eq!(upstream_deps(&inst), vec![("my-build".to_string(), 389)]);
}
#[test]
fn upstream_deps_ignores_git_materials_and_malformed_revisions() {
let git = git_revision("git@github.com:a/b.git", "main", "deadbeef");
assert!(upstream_deps(&instance_with(vec![git])).is_empty());
for bad in ["no-slashes", "name/notanumber/stage/1", "/389/stage/1"] {
assert!(
upstream_deps(&instance_with(vec![pipeline_material(bad)])).is_empty(),
"{bad:?} should not yield a dependency"
);
}
}
#[test]
fn upstream_deps_returns_every_dependency_in_order() {
let inst = instance_with(vec![
pipeline_material("build-a/10/stage/1"),
pipeline_material("build-b/22/stage/1"),
]);
assert_eq!(
upstream_deps(&inst),
vec![("build-a".to_string(), 10), ("build-b".to_string(), 22)]
);
}
#[test]
fn flatten_artifacts_walks_the_tree_and_keeps_depth() {
let file = |name: &str, url: &str| ArtifactNode {
name: name.into(),
url: Some(url.into()),
kind: Some("file".into()),
files: vec![],
};
let tree = vec![ArtifactNode {
name: "dist".into(),
url: None,
kind: Some("folder".into()),
files: vec![
file("app.tar.gz", "https://x/app.tar.gz"),
ArtifactNode {
name: "maps".into(),
url: None,
kind: Some("folder".into()),
files: vec![file("a.map", "https://x/a.map")],
},
],
}];
use std::collections::HashSet;
let none = HashSet::new();
let rows = flatten_artifacts(&tree, &none);
assert_eq!(rows.len(), 1, "a closed folder must not show its children");
assert_eq!(rows[0].name, "dist");
assert!(rows[0].is_folder);
assert!(!rows[0].expanded);
let open_dist: HashSet<String> = ["dist".to_string()].into_iter().collect();
let rows = flatten_artifacts(&tree, &open_dist);
let shape: Vec<(usize, &str, bool)> =
rows.iter().map(|r| (r.depth, r.name.as_str(), r.is_folder)).collect();
assert_eq!(
shape,
vec![(0, "dist", true), (1, "app.tar.gz", false), (1, "maps", true)],
"a nested folder stays closed until opened itself"
);
let open_both: HashSet<String> =
["dist".to_string(), "dist/maps".to_string()].into_iter().collect();
let rows = flatten_artifacts(&tree, &open_both);
assert_eq!(rows.len(), 4);
assert_eq!(rows[3].name, "a.map");
assert_eq!(rows[3].depth, 2);
assert_eq!(rows[2].path, "dist/maps");
assert_eq!(rows[1].url.as_deref(), Some("https://x/app.tar.gz"));
assert_eq!(rows[0].url, None);
}
#[test]
fn stage_is_active_only_while_running_or_scheduled() {
let stage = |st: Option<&str>| StageInstance {
name: "build".into(),
status: st.map(str::to_string),
approval_type: None,
scheduled_date: None,
counter: None,
jobs: vec![],
};
assert!(stage(Some("Building")).is_active());
assert!(stage(Some("Scheduled")).is_active());
for done in ["Passed", "Failed", "Cancelled", "Unknown"] {
assert!(!stage(Some(done)).is_active(), "{done} should not be active");
}
assert!(!stage(None).is_active());
}
#[test]
fn a_node_with_children_counts_as_a_folder_without_a_type() {
use std::collections::HashSet;
let tree = vec![ArtifactNode {
name: "untyped".into(),
url: None,
kind: None,
files: vec![ArtifactNode {
name: "inner.txt".into(),
url: Some("https://x/inner.txt".into()),
kind: None,
files: vec![],
}],
}];
let rows = flatten_artifacts(&tree, &HashSet::new());
assert_eq!(rows.len(), 1);
assert!(rows[0].is_folder, "a node with children must be expandable");
let open: HashSet<String> = ["untyped".to_string()].into_iter().collect();
assert_eq!(flatten_artifacts(&tree, &open).len(), 2);
}
#[test]
fn identically_named_folders_have_distinct_paths() {
use std::collections::HashSet;
let leaf = |n: &str| ArtifactNode {
name: n.into(),
url: Some("https://x".into()),
kind: Some("file".into()),
files: vec![],
};
let branch = |n: &str| ArtifactNode {
name: n.into(),
url: None,
kind: Some("folder".into()),
files: vec![ArtifactNode {
name: "logs".into(),
url: None,
kind: Some("folder".into()),
files: vec![leaf("a.txt")],
}],
};
let tree = vec![branch("one"), branch("two")];
let open: HashSet<String> =
["one".to_string(), "one/logs".to_string(), "two".to_string()].into_iter().collect();
let rows = flatten_artifacts(&tree, &open);
let opened: Vec<(&str, bool)> =
rows.iter().map(|r| (r.path.as_str(), r.expanded)).collect();
assert!(opened.contains(&("one/logs", true)));
assert!(opened.contains(&("two/logs", false)), "two/logs must stay closed");
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewFilters {
#[serde(default)]
pub filters: Vec<ViewFilter>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewFilter {
pub name: String,
#[serde(rename = "type", default)]
pub kind: String,
#[serde(default)]
pub state: Vec<String>,
#[serde(default)]
pub pipelines: Vec<String>,
}