use serde::{Deserialize, Serialize};
use crate::api::client::{ApiError, GitHub};
use crate::api::model::{Capped, RepoId};
use crate::schema::schema;
#[derive(cynic::Enum, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[cynic(graphql_type = "CheckAnnotationLevel")]
pub enum Level {
Failure,
Warning,
Notice,
}
impl Level {
pub fn word(self) -> &'static str {
match self {
Level::Failure => "Error",
Level::Warning => "Warning",
Level::Notice => "Notice",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Annotation {
pub path: Option<String>,
pub start_line: u32,
pub end_line: u32,
pub level: Level,
pub title: String,
pub message: String,
pub url: Option<String>,
}
struct Raw {
path: String,
start: i64,
end: i64,
level: Level,
title: String,
message: String,
blob: String,
}
impl Annotation {
fn new(raw: Raw) -> Self {
let line = |n: i64| u32::try_from(n).unwrap_or(0);
let path = Some(raw.path).filter(|p| !p.is_empty() && p != ".github");
let (start_line, end_line) = (line(raw.start), line(raw.end.max(raw.start)));
let lines = match (start_line, end_line) {
(0, _) => String::new(),
(a, b) if a == b => format!("#L{a}"),
(a, b) => format!("#L{a}-L{b}"),
};
Self {
url: path.as_ref().map(|_| format!("{}{lines}", raw.blob)),
path,
start_line,
end_line,
level: raw.level,
title: raw.title,
message: raw.message,
}
}
pub fn place(&self) -> String {
match &self.path {
Some(path) if self.start_line > 0 => format!("{path}:{}", self.start_line),
Some(path) => path.clone(),
None => "the run".to_owned(),
}
}
}
pub fn sorted(mut list: Vec<Annotation>) -> Vec<Annotation> {
list.sort_by(|a, b| (a.level, &a.path, a.start_line).cmp(&(b.level, &b.path, b.start_line)));
list
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Artifact {
pub id: u64,
pub name: String,
pub size: u64,
pub expires_at: Option<String>,
pub expired: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Input {
pub name: String,
pub description: String,
pub kind: InputKind,
pub default: Option<String>,
pub required: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputKind {
Choice(Vec<String>),
Boolean,
Text,
}
impl Input {
pub fn start(&self) -> String {
match (&self.default, &self.kind) {
(Some(d), _) => d.clone(),
(None, InputKind::Choice(options)) => options.first().cloned().unwrap_or_default(),
(None, InputKind::Boolean) => "false".into(),
(None, InputKind::Text) => String::new(),
}
}
}
pub fn dispatch_inputs(file: &str) -> Option<Vec<Input>> {
use crate::yaml::{self, Value};
let doc = yaml::parse(file);
let on = doc.get("on").or_else(|| doc.get("true"))?;
let dispatch = match on {
Value::Text(name) => return (name == "workflow_dispatch").then(Vec::new),
Value::List(names) => {
let named = names.iter().any(|n| n.text() == Some("workflow_dispatch"));
return named.then(Vec::new);
}
Value::Map(_) => on.get("workflow_dispatch")?,
Value::Null => return None,
};
let Some(Value::Map(inputs)) = dispatch.get("inputs") else {
return Some(Vec::new());
};
let text = |v: &Value, key: &str| v.get(key).and_then(Value::text).map(str::to_owned);
let inputs = inputs.iter().map(|(name, spec)| {
let kind = match text(spec, "type").as_deref() {
Some("choice") => {
let options = spec.get("options").map(Value::names).unwrap_or_default();
InputKind::Choice(options)
}
Some("boolean") => InputKind::Boolean,
_ => InputKind::Text,
};
Input {
name: name.clone(),
description: text(spec, "description").unwrap_or_default(),
default: text(spec, "default"),
required: spec.get("required").and_then(Value::text) == Some("true"),
kind,
}
});
Some(inputs.collect())
}
#[derive(cynic::QueryFragment, Debug)]
#[cynic(graphql_type = "CheckAnnotationConnection")]
pub struct WireAnnotations {
pub total_count: i32,
pub nodes: Option<Vec<Option<WireAnnotation>>>,
}
#[derive(cynic::QueryFragment, Debug)]
#[cynic(graphql_type = "CheckAnnotation")]
pub struct WireAnnotation {
pub path: String,
pub blob_url: crate::api::queries::Uri,
pub location: WireSpan,
pub annotation_level: Option<Level>,
pub title: Option<String>,
pub message: String,
}
#[derive(cynic::QueryFragment, Debug)]
#[cynic(graphql_type = "CheckAnnotationSpan")]
pub struct WireSpan {
pub start: WirePosition,
pub end: WirePosition,
}
#[derive(cynic::QueryFragment, Debug)]
#[cynic(graphql_type = "CheckAnnotationPosition")]
pub struct WirePosition {
pub line: i32,
}
impl WireAnnotations {
pub fn into_capped(self) -> Capped<Annotation> {
Capped::from_nodes(self.total_count, self.nodes, |a| {
Annotation::new(Raw {
path: a.path,
start: a.location.start.line.into(),
end: a.location.end.line.into(),
level: a.annotation_level.unwrap_or(Level::Notice),
title: a.title.unwrap_or_default(),
message: a.message,
blob: a.blob_url.0,
})
})
}
}
mod rest {
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Annotation {
pub path: String,
pub blob_href: String,
pub start_line: i64,
pub end_line: i64,
pub annotation_level: String,
pub title: Option<String>,
pub message: String,
}
#[derive(Deserialize)]
pub struct CheckRun {
pub output: Output,
}
#[derive(Deserialize)]
pub struct Output {
pub annotations_count: u64,
}
#[derive(Deserialize)]
pub struct Artifacts {
pub total_count: u64,
pub artifacts: Vec<Artifact>,
}
#[derive(Deserialize)]
pub struct Artifact {
pub id: u64,
pub name: String,
pub size_in_bytes: u64,
pub expires_at: Option<String>,
pub expired: bool,
}
#[derive(Deserialize)]
pub struct Dispatched {
pub workflow_run_id: Option<u64>,
}
}
const ANNOTATIONS: u64 = 50;
const ARTIFACTS: u64 = 100;
impl GitHub {
pub async fn job_annotations(
&self,
repo: &RepoId,
job: u64,
) -> Result<Capped<Annotation>, ApiError> {
let base = format!("/repos/{}/{}/check-runs/{job}", repo.owner, repo.name);
let what = format!("{repo}'s check run {job}");
let run: rest::CheckRun = serde_json::from_str(&self.rest_get(&base, &what).await?)?;
let total = run.output.annotations_count;
if total == 0 {
return Ok(Capped::default());
}
let path = format!("{base}/annotations?per_page={ANNOTATIONS}");
let wire: Vec<rest::Annotation> =
serde_json::from_str(&self.rest_get(&path, &what).await?)?;
let list = wire.into_iter().map(|a| {
let level = match a.annotation_level.as_str() {
"failure" => Level::Failure,
"warning" => Level::Warning,
_ => Level::Notice,
};
Annotation::new(Raw {
path: a.path,
start: a.start_line,
end: a.end_line,
level,
title: a.title.unwrap_or_default(),
message: a.message,
blob: a.blob_href,
})
});
Ok(Capped::new(sorted(list.collect()), total))
}
pub async fn artifacts(&self, repo: &RepoId, run: u64) -> Result<Capped<Artifact>, ApiError> {
let path = format!(
"/repos/{}/{}/actions/runs/{run}/artifacts?per_page={ARTIFACTS}",
repo.owner, repo.name
);
let wire: rest::Artifacts = serde_json::from_str(
&self
.rest_get(&path, format!("run {run}'s artifacts"))
.await?,
)?;
let items = wire.artifacts.into_iter().map(|a| Artifact {
id: a.id,
name: a.name,
size: a.size_in_bytes,
expires_at: a.expires_at,
expired: a.expired,
});
Ok(Capped::new(items.collect(), wire.total_count))
}
pub async fn download_artifact(
&self,
repo: &RepoId,
id: u64,
dest: &std::path::Path,
replace: bool,
) -> Result<u64, ApiError> {
let path = format!(
"/repos/{}/{}/actions/artifacts/{id}/zip",
repo.owner, repo.name
);
self.download(&path, dest, replace).await
}
pub async fn dispatch(
&self,
repo: &RepoId,
file: &str,
git_ref: &str,
inputs: &[(String, String)],
) -> Result<Option<u64>, ApiError> {
let path = format!(
"/repos/{}/{}/actions/workflows/{}/dispatches",
repo.owner,
repo.name,
crate::ui::pages::url::encode_path(file)
);
let inputs: serde_json::Map<String, serde_json::Value> = (inputs.iter())
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
.collect();
let body = serde_json::json!({
"ref": git_ref,
"inputs": inputs,
"return_run_details": true,
});
let answer = self
.rest_write_answer(http::Method::POST, &path, Some(&body))
.await?;
if answer.trim().is_empty() {
return Ok(None);
}
let dispatched: rest::Dispatched = serde_json::from_str(&answer)?;
Ok(dispatched.workflow_run_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dispatch_inputs_come_from_the_workflow_file() {
let file = "name: actions\non:\n workflow_dispatch:\n inputs:\n outcome:\n description: How the job ends\n type: choice\n options: [fail, pass]\n default: fail\n seconds:\n type: string\n default: \"60\"\n required: true\n summary:\n type: boolean\n push:\njobs: {}\n";
let inputs = dispatch_inputs(file).unwrap();
let names: Vec<&str> = inputs.iter().map(|i| i.name.as_str()).collect();
assert_eq!(names, ["outcome", "seconds", "summary"]);
assert_eq!(
inputs[0].kind,
InputKind::Choice(vec!["fail".into(), "pass".into()])
);
assert_eq!(inputs[0].description, "How the job ends");
assert_eq!(inputs[1].start(), "60");
assert!(inputs[1].required);
assert_eq!(
(&inputs[2].kind, inputs[2].start().as_str()),
(&InputKind::Boolean, "false")
);
assert_eq!(
dispatch_inputs("on: [push, workflow_dispatch]\n"),
Some(Vec::new())
);
assert_eq!(dispatch_inputs("on: push\n"), None);
}
}