use anyhow::Result;
use std::collections::HashMap;
use std::path::Path;
use std::process::ExitCode;
use crate::auth;
use crate::github::{GitHubClient, Release};
use crate::output::{UpdateReport, UpdateResult};
use crate::workflow::{self, RefType};
pub async fn run(
repo_root: &Path,
apply: bool,
json: bool,
only: Option<&str>,
) -> Result<ExitCode> {
let token = auth::require_token().await?;
let client = GitHubClient::new(token);
let files = workflow::find_workflows(repo_root)?;
let mut report = UpdateReport {
updates: Vec::new(),
up_to_date: 0,
applied: apply,
};
let mut releases_cache: HashMap<String, Vec<Release>> = HashMap::new();
let mut releases_failed: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut tags_cache: HashMap<String, Vec<String>> = HashMap::new();
let mut tag_sha_cache: HashMap<String, String> = HashMap::new();
for file in &files {
let display_name = workflow::display_path(file, repo_root);
if !json {
eprintln!("Scanning {display_name}...");
}
let actions = workflow::scan_workflow(file)?;
let mut replacements: Vec<(usize, String)> = Vec::new();
for action in &actions {
if action.ref_type != RefType::Sha {
continue;
}
if let Some(pat) = only
&& !action.owner_repo().contains(pat)
{
continue;
}
let comment_tag = action
.tag_comment
.as_deref()
.map(leading_version_token)
.filter(|t| is_version_like(t));
let current_tag = match comment_tag {
Some(t) => t,
None => match client
.sha_to_tag(&action.owner, &action.repo, &action.ref_string)
.await
{
Ok(Some(tag)) => leading_version_token(&tag),
_ => continue,
},
};
if !json {
eprint!(" Checking {}@{}...", action.full_name(), current_tag);
}
let owner_repo = action.owner_repo();
if releases_failed.contains(&owner_repo) {
if !json {
eprintln!(" skipped");
}
continue;
}
let releases = if let Some(cached) = releases_cache.get(&owner_repo) {
if !json {
eprintln!(" cached");
}
cached.clone()
} else {
match client.list_releases(&action.owner, &action.repo).await {
Ok(r) => {
if !json {
eprintln!(" done");
}
releases_cache.insert(owner_repo.clone(), r.clone());
r
}
Err(_) => {
if !json {
eprintln!(" failed");
}
releases_failed.insert(owner_repo);
continue;
}
}
};
let (latest_tag, release_url) = match pick_latest_release(&releases) {
Some(r) => (r.tag_name.clone(), r.html_url.clone()),
None => {
let tags = match tags_cache.get(&owner_repo) {
Some(cached) => cached.clone(),
None => match client.list_tags(&action.owner, &action.repo).await {
Ok(t) => {
tags_cache.insert(owner_repo.clone(), t.clone());
t
}
Err(_) => continue,
},
};
match pick_latest_tag(&tags) {
Some(t) => (t.clone(), None),
None => {
report.up_to_date += 1;
continue;
}
}
}
};
if latest_tag == current_tag {
report.up_to_date += 1;
continue;
}
if !is_newer(¤t_tag, &latest_tag) {
report.up_to_date += 1;
continue;
}
let tag_key = format!("{owner_repo}@{latest_tag}");
let new_sha = if let Some(cached) = tag_sha_cache.get(&tag_key) {
cached.clone()
} else {
match client
.resolve_tag(&action.owner, &action.repo, &latest_tag)
.await
{
Ok(sha) => {
tag_sha_cache.insert(tag_key, sha.clone());
sha
}
Err(_) => continue,
}
};
if new_sha == action.ref_string {
report.up_to_date += 1;
continue;
}
report.updates.push(UpdateResult {
file: workflow::display_path(file, repo_root),
action: action.full_name(),
current_tag: current_tag.clone(),
current_sha: action.ref_string.clone(),
latest_tag: latest_tag.clone(),
latest_sha: new_sha.clone(),
line: action.line_number,
release_url,
});
if apply
&& let Some(new_line) =
workflow::build_pinned_line(&action.raw_line, &new_sha, &latest_tag)
{
replacements.push((action.line_number, new_line));
}
}
if apply && !replacements.is_empty() {
workflow::rewrite_actions(file, &replacements)?;
}
}
let has_updates = !report.updates.is_empty();
if json {
report.print_json();
} else {
report.print_human();
}
if has_updates && !apply {
Ok(ExitCode::from(1))
} else {
Ok(ExitCode::SUCCESS)
}
}
fn pick_latest_release(releases: &[Release]) -> Option<&Release> {
releases
.iter()
.filter(|r| !r.draft && !r.prerelease && is_version_like(&r.tag_name))
.reduce(|best, r| {
if is_newer(&best.tag_name, &r.tag_name) {
r
} else {
best
}
})
}
fn pick_latest_tag(tags: &[String]) -> Option<&String> {
let pick = |stable_only: bool| {
tags.iter()
.filter(|t| is_version_like(t) && (!stable_only || !parse_version(t).1))
.reduce(|best, t| if is_newer(best, t) { t } else { best })
};
pick(true).or_else(|| pick(false))
}
fn is_version_like(tag: &str) -> bool {
tag.strip_prefix('v')
.unwrap_or(tag)
.starts_with(|c: char| c.is_ascii_digit())
}
fn leading_version_token(comment: &str) -> String {
let trimmed = comment.trim();
let rest = trimmed.strip_prefix('v').unwrap_or(trimmed);
if !rest.starts_with(|c: char| c.is_ascii_digit()) {
return trimmed.to_string();
}
let end = trimmed
.find(|c: char| c.is_whitespace() || c == '(')
.unwrap_or(trimmed.len());
trimmed[..end].to_string()
}
fn is_newer(current: &str, candidate: &str) -> bool {
let (cur, cur_pre) = parse_version(current);
let (cand, cand_pre) = parse_version(candidate);
for (c, n) in cur.iter().zip(cand.iter()) {
if n > c {
return true;
}
if n < c {
return false;
}
}
if cand.len() != cur.len() {
return cand.len() > cur.len();
}
match (cur_pre, cand_pre) {
(true, false) => true,
(false, true) => false,
_ => false,
}
}
fn parse_version(s: &str) -> (Vec<u64>, bool) {
let s = s.trim_start_matches('v');
let (head, has_suffix) = match s.split_once('-') {
Some((before, _)) => (before, true),
None => (s, false),
};
let head = head.split_once('+').map(|(b, _)| b).unwrap_or(head);
let parts = head
.split('.')
.filter_map(|p| p.parse::<u64>().ok())
.collect();
(parts, has_suffix)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn newer_patch() {
assert!(is_newer("v1.2.3", "v1.2.4"));
}
#[test]
fn newer_minor() {
assert!(is_newer("v1.2.3", "v1.3.0"));
}
#[test]
fn newer_major() {
assert!(is_newer("v1.2.3", "v2.0.0"));
}
#[test]
fn same_version() {
assert!(!is_newer("v1.2.3", "v1.2.3"));
}
#[test]
fn older_version() {
assert!(!is_newer("v2.0.0", "v1.9.9"));
}
#[test]
fn without_v_prefix() {
assert!(is_newer("1.2.3", "1.2.4"));
}
#[test]
fn mixed_prefixes() {
assert!(is_newer("v1.0.0", "1.1.0"));
assert!(is_newer("1.0.0", "v1.1.0"));
}
#[test]
fn more_components_is_newer() {
assert!(is_newer("v4", "v4.1"));
assert!(is_newer("v4.1", "v4.1.1"));
}
#[test]
fn fewer_components_not_newer() {
assert!(!is_newer("v4.1", "v4"));
}
#[test]
fn major_only() {
assert!(is_newer("v3", "v4"));
assert!(!is_newer("v4", "v3"));
}
#[test]
fn prerelease_is_older_than_stable_same_numeric() {
assert!(!is_newer("v1.2.3", "v1.2.3-rc1"));
assert!(is_newer("v1.2.3-rc1", "v1.2.3"));
}
#[test]
fn two_prereleases_same_numeric_are_equal() {
assert!(!is_newer("v1.2.3-rc1", "v1.2.3-rc2"));
assert!(!is_newer("v1.2.3-rc2", "v1.2.3-rc1"));
}
#[test]
fn numeric_bump_beats_prerelease_tail() {
assert!(is_newer("v1.2.3-rc1", "v1.2.4"));
assert!(!is_newer("v1.2.4", "v1.2.3-rc1"));
}
#[test]
fn build_metadata_stripped() {
assert!(!is_newer("v1.2.3+build.5", "v1.2.3+build.9"));
assert!(is_newer("v1.2.3+build.9", "v1.2.4+build.1"));
}
#[test]
fn leading_zeros() {
assert!(is_newer("v01.02.03", "v01.02.04"));
}
#[test]
fn empty_segments_skipped() {
assert!(is_newer("v1..2", "v1..3"));
}
#[test]
fn long_version() {
assert!(is_newer("v1.2.3.4.5", "v1.2.3.4.6"));
assert!(!is_newer("v1.2.3.4.6", "v1.2.3.4.5"));
}
#[test]
fn both_empty_after_parse() {
assert!(!is_newer("alpha", "beta"));
}
#[test]
fn pick_latest_tag_takes_highest_version() {
let tags = vec![
"v1".to_string(),
"v1.0.0".to_string(),
"v1.3.0".to_string(),
"v1.2.0".to_string(),
"nightly".to_string(),
];
assert_eq!(pick_latest_tag(&tags).unwrap(), "v1.3.0");
}
#[test]
fn pick_latest_tag_ignores_non_version_tags() {
let tags = vec!["latest".to_string(), "stable".to_string()];
assert_eq!(pick_latest_tag(&tags), None);
assert_eq!(pick_latest_tag(&[]), None);
}
#[test]
fn pick_latest_tag_prefers_stable_over_newer_prerelease() {
let tags = vec!["v2.0.0".to_string(), "v2.1.0-rc1".to_string()];
assert_eq!(pick_latest_tag(&tags).unwrap(), "v2.0.0");
}
#[test]
fn pick_latest_tag_falls_back_to_prerelease_when_no_stable() {
let tags = vec!["v0.1.0-beta".to_string(), "v0.2.0-rc1".to_string()];
assert_eq!(pick_latest_tag(&tags).unwrap(), "v0.2.0-rc1");
}
#[test]
fn pick_latest_release_skips_drafts_and_prereleases() {
let rel = |tag: &str, draft: bool, prerelease: bool| Release {
tag_name: tag.to_string(),
draft,
prerelease,
html_url: None,
};
let releases = vec![
rel("v2.0.0", false, false),
rel("v3.0.0", true, false), rel("v2.5.0", false, true), rel("codeql-bundle-x", false, false), ];
assert_eq!(pick_latest_release(&releases).unwrap().tag_name, "v2.0.0");
}
#[test]
fn is_version_like_matches_digit_after_optional_v() {
assert!(is_version_like("v1.2.3"));
assert!(is_version_like("2.0"));
assert!(!is_version_like("latest"));
assert!(!is_version_like("codeql-bundle-v1"));
}
#[test]
fn leading_version_token_extracts_version() {
assert_eq!(leading_version_token("v6.0.2"), "v6.0.2");
assert_eq!(leading_version_token(" v6.0.2 "), "v6.0.2");
assert_eq!(
leading_version_token("v6.0.2 (pinned by renovate)"),
"v6.0.2"
);
assert_eq!(
leading_version_token("v1.2.3-rc1 do not bump"),
"v1.2.3-rc1"
);
assert_eq!(leading_version_token("1.2.3"), "1.2.3");
assert_eq!(leading_version_token("pinned manually"), "pinned manually");
}
}