use crate::InstallConfig;
use crate::artifact::{GhArtifact, GhArtifacts};
use crate::download::download_json;
use crate::tool::get_artifact_url;
use crate::error::{Context, Result, err};
use regex::Regex;
use serde::Deserialize;
use std::collections::HashSet;
use std::fmt::Display;
use std::sync::LazyLock;
static RE_CI_RUN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/actions/runs/(?P<run_id>\d+)$",
)
.unwrap()
});
pub(crate) static RE_CI_WORKFLOW: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/actions/workflows/(?P<workflow>.+)$",
)
.unwrap()
});
#[derive(Deserialize)]
struct ActionsArtifacts {
artifacts: Vec<ActionsArtifact>,
}
#[derive(Deserialize)]
struct ActionsArtifact {
#[allow(dead_code)]
id: u64,
name: String,
archive_download_url: String,
expired: bool,
}
#[derive(Deserialize)]
struct WorkflowRuns {
workflow_runs: Vec<WorkflowRun>,
}
#[derive(Deserialize)]
struct WorkflowRun {
id: u64,
#[allow(dead_code)]
status: String,
#[allow(dead_code)]
conclusion: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct CiRun {
pub(crate) owner: String,
pub(crate) repo: String,
pub(crate) run_id: u64,
}
impl CiRun {
async fn resolve(
owner: &str,
repo: &str,
run_str: &str,
retry: usize,
timeout: u64,
) -> Result<u64> {
if let Ok(id) = run_str.parse::<u64>() {
return Ok(id);
}
let url = format!(
"https://api.github.com/repos/{owner}/{repo}/actions/workflows/{run_str}/runs?per_page=1&status=completed&conclusion=success"
);
let runs: WorkflowRuns = download_json(&url, retry, timeout)
.await
.context("Failed to fetch workflow runs. GitHub Actions API requires authentication — set GITHUB_TOKEN or run `gh auth login`.")?;
let run = runs
.workflow_runs
.into_iter()
.next()
.context("No completed workflow runs found")?;
Ok(run.id)
}
#[allow(dead_code)]
pub(crate) async fn get_artifact_url(
&self,
config: &InstallConfig,
) -> Result<Vec<(String, String)>> {
let artifacts = self.get_artifacts(config.retry, config.timeout).await?;
get_artifact_url(artifacts, config)
}
pub(crate) async fn get_artifacts(&self, retry: usize, timeout: u64) -> Result<GhArtifacts> {
let url = format!(
"https://api.github.com/repos/{}/{}/actions/runs/{}/artifacts",
self.owner, self.repo, self.run_id
);
let response: ActionsArtifacts = download_json(&url, retry, timeout)
.await
.context("Failed to fetch CI artifacts. The GitHub Actions API requires authentication — set GITHUB_TOKEN or run `gh auth login`.")?;
let mut assets = HashSet::new();
for a in response.artifacts {
if a.expired {
continue;
}
assets.insert(GhArtifact {
name: format!("{}.zip", a.name),
browser_download_url: a.archive_download_url,
url: None,
});
}
Ok(GhArtifacts { assets })
}
}
impl Display for CiRun {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"https://github.com/{}/{}/actions/runs/{}",
self.owner, self.repo, self.run_id
)
}
}
impl TryFrom<&str> for CiRun {
type Error = crate::error::Error;
fn try_from(url: &str) -> Result<Self> {
if let Some(cap) = RE_CI_RUN.captures(url) {
return Ok(Self {
owner: cap["owner"].to_string(),
repo: cap["repo"].to_string(),
run_id: cap["run_id"].parse()?,
});
}
if let Some(_cap) = RE_CI_WORKFLOW.captures(url) {
return Err(err!(
"Workflow URLs require async resolution. Use the workflow file as a CI reference."
));
}
Err(err!("Invalid CI URL: {url}"))
}
}
pub(crate) async fn resolve_ci_workflow(url: &str, retry: usize, timeout: u64) -> Result<CiRun> {
let cap = RE_CI_WORKFLOW
.captures(url)
.context("Not a valid CI workflow URL")?;
let owner = cap["owner"].to_string();
let repo = cap["repo"].to_string();
let workflow = cap["workflow"].to_string();
let run_id = CiRun::resolve(&owner, &repo, &workflow, retry, timeout).await?;
Ok(CiRun {
owner,
repo,
run_id,
})
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_ci_run_url() {
let url = "https://github.com/theMackabu/ant/actions/runs/28723919642";
let ci = CiRun::try_from(url).unwrap();
assert_eq!(ci.owner, "theMackabu");
assert_eq!(ci.repo, "ant");
assert_eq!(ci.run_id, 28723919642);
}
#[test]
fn test_ci_workflow_url() {
let url = "https://github.com/lucid-softworks/lumen/actions/workflows/release.yml";
let cap = RE_CI_WORKFLOW.captures(url).unwrap();
assert_eq!(&cap["owner"], "lucid-softworks");
assert_eq!(&cap["repo"], "lumen");
assert_eq!(&cap["workflow"], "release.yml");
}
#[test]
fn test_invalid_url() {
assert!(CiRun::try_from("https://github.com/owner/repo/releases/tag/v1.0").is_err());
}
}