act-up 1.0.1

Update `uses` references in your GitHub Actions workflow files.
use std::fs;
use std::path::PathBuf;

use anyhow::{Context, Result};
use clap::Parser;
use ignore::WalkBuilder;
use ignore::overrides::OverrideBuilder;
use tracing::{debug, info, warn};

use crate::github_api::GithubApi;
use crate::parser::{
    is_sha, is_short_sha, is_version_like_ref, parse_comment_ref, parse_quote, parse_repo_ref,
    parse_uses_line, replace_ref,
};

#[derive(Debug, Parser)]
#[command(name = "act-up")]
#[command(author = clap::crate_authors!())]
#[command(version = clap::crate_version!())]
#[command(about = clap::crate_description!(), long_about = None)]
#[command(after_help = r#"Examples:
  - Update all workflow files in current directory: `act-up`
  - Update all workflow files other than deploy.yml: `act-up --files !.github/workflows/deploy.yml`
  - Preview changes without writing to files: `act-up --dry-run`

Notes:
  - If you hit GitHub API rate limit, you can set `GITHUB_TOKEN` environment variable to a personal access token.
  - Non-semver refs (e.g. `main`) won't be updated unless --pin is set.
  - If the first line contains `autogenerated` or `auto generated`, the file is skipped."#)]
struct Cli {
    /// Extra gitignore-like file globs merged with defaults. Use `!` prefix to exclude.
    #[arg(long, value_delimiter = ',')]
    files: Vec<String>,

    /// Preview changes only. By default, act-up writes updates to files.
    #[arg(long)]
    dry_run: bool,

    /// Force pin refs to commit SHA for non-pinned actions.
    #[arg(long)]
    pin: bool,
}

const DEFAULT_FILE_PATTERNS: [&str; 2] = [".github/workflows/*.yml", ".github/workflows/*.yaml"];
const GENERATED_FILE_INDICATORS: [&str; 3] = ["autogenerated", "auto generated", "@generated"];

#[derive(Debug)]
struct ChangeRecord {
    line_no: usize,
    repo: String,
    old_ref: String,
    new_ref: String,
    pinned: bool,
}

pub fn run() -> Result<()> {
    let cli = Cli::parse();
    let files = collect_yaml_files(&cli.files)?;

    if files.is_empty() {
        warn!("no files matching given patterns");
        return Ok(());
    }
    info!("checking {} files", files.len());

    let mut api = GithubApi::new()?;
    let mut all_changes = Vec::new();

    for file in files {
        let content =
            fs::read_to_string(&file).with_context(|| format!("failed reading {file:?}"))?;

        if is_generated_file(&content) {
            debug!("skipped generated: {file:?}");
            continue;
        }

        let (new_content, line_changes) = update_file_content(&mut api, &content, cli.pin)?;
        if line_changes.is_empty() {
            continue;
        }

        for change in line_changes {
            info!(
                "{}: {}:{} {}@{} -> {}{}",
                if cli.dry_run {
                    "would update"
                } else {
                    "updated"
                },
                file.display(),
                change.line_no,
                change.repo,
                change.old_ref,
                change.new_ref,
                if change.pinned { " (pinned)" } else { "" }
            );
            all_changes.push(change);
        }

        if !cli.dry_run {
            fs::write(&file, new_content).with_context(|| format!("failed writing {file:?}"))?;
        }
    }

    if all_changes.is_empty() {
        info!("completed: no updates");
    } else {
        info!(
            "completed:\n{}",
            all_changes
                .iter()
                .map(|c| {
                    format!(
                        "      - {}@{} -> {}{}",
                        c.repo,
                        c.old_ref,
                        c.new_ref,
                        if c.pinned { " (pinned)" } else { "" }
                    )
                })
                .collect::<Vec<_>>()
                .join("\n")
        );
    }

    if cli.dry_run && !all_changes.is_empty() {
        info!("re-run without --dry-run to apply changes");
    }

    Ok(())
}

fn collect_yaml_files(cli_files: &[String]) -> Result<Vec<PathBuf>> {
    let mut builder = OverrideBuilder::new(".");
    for p in DEFAULT_FILE_PATTERNS {
        builder.add(p)?;
    }
    for p in cli_files {
        builder.add(p.trim())?;
    }
    let overrides = builder.build().context("failed to build file overrides")?;

    let mut files = WalkBuilder::new(".")
        .follow_links(false)
        .hidden(false)
        .ignore(false)
        .git_ignore(false)
        .git_global(false)
        .git_exclude(false)
        .parents(false)
        .overrides(overrides)
        .build()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_some_and(|it| it.is_file()))
        .map(|e| e.into_path())
        .collect::<Vec<_>>();

    files.sort();
    files.dedup();
    Ok(files)
}

fn is_generated_file(content: &str) -> bool {
    content.lines().next().is_some_and(|line| {
        let line = line.to_ascii_lowercase();
        GENERATED_FILE_INDICATORS.iter().any(|i| line.contains(i))
    })
}

fn update_file_content(
    api: &mut GithubApi,
    content: &str,
    pin: bool,
) -> Result<(String, Vec<ChangeRecord>)> {
    let mut changes = Vec::new();
    let lines: Vec<String> = content
        .lines()
        .enumerate()
        .map(|(idx, line)| match maybe_update_uses_line(api, line, pin) {
            Ok(Some((new_line, Some(mut record)))) => {
                record.line_no = idx + 1;
                changes.push(record);
                new_line
            }
            Ok(Some((new_line, None))) => new_line,
            Ok(None) => line.to_string(),
            Err(err) => {
                warn!("failed to check line {}: {err:#}", idx + 1);
                line.to_string()
            }
        })
        .collect();

    let mut new_content = lines.join("\n");
    if content.ends_with('\n') {
        new_content.push('\n');
    }
    Ok((new_content, changes))
}

fn maybe_update_uses_line(
    api: &mut GithubApi,
    line: &str,
    pin: bool,
) -> Result<Option<(String, Option<ChangeRecord>)>> {
    let Some(parsed) = parse_uses_line(line) else {
        return Ok(None);
    };
    let q = parse_quote(&parsed.value_raw);
    let Some(repo) = parse_repo_ref(q.value) else {
        return Ok(Some((line.to_string(), None)));
    };

    if repo.hostname != "github.com" {
        return Ok(Some((line.to_string(), None)));
    }

    let is_pinned_digest = is_sha(repo.current_ref) || is_short_sha(repo.current_ref);
    let comment_body = parsed.comment_suffix.strip_prefix(" #").unwrap_or_default();

    let (target_ref, target_sha, old_display_ref) = if is_pinned_digest {
        let Some(c_ref) = parse_comment_ref(comment_body) else {
            return Ok(Some((line.to_string(), None)));
        };
        let updated = if is_version_like_ref(&c_ref) {
            api.resolve_updated_ref(repo.owner, repo.repo, &c_ref)?
                .unwrap_or(c_ref)
        } else {
            c_ref
        };
        let sha = api.resolve_ref_sha(repo.owner, repo.repo, &updated)?;
        (
            updated,
            sha,
            parse_comment_ref(comment_body).unwrap_or_default(),
        )
    } else if pin {
        let sha = api.resolve_ref_sha(repo.owner, repo.repo, repo.current_ref)?;
        (
            repo.current_ref.to_string(),
            sha,
            repo.current_ref.to_string(),
        )
    } else if is_version_like_ref(repo.current_ref) {
        let updated = api.resolve_updated_ref(repo.owner, repo.repo, repo.current_ref)?;
        match updated {
            Some(u) if u != repo.current_ref => (u, String::new(), repo.current_ref.to_string()),
            _ => return Ok(Some((line.to_string(), None))),
        }
    } else {
        return Ok(Some((line.to_string(), None)));
    };

    let new_value_ref = if is_pinned_digest || pin {
        &target_sha
    } else {
        &target_ref
    };
    if new_value_ref == repo.current_ref {
        return Ok(Some((line.to_string(), None)));
    }

    let replaced = replace_ref(q.value, new_value_ref);
    let val = if q.quote.is_empty() {
        replaced
    } else {
        format!("{}{replaced}{}", q.quote, q.quote)
    };

    let (final_comment, display_new_ref, is_pinned) = if is_pinned_digest || pin {
        (format!(" # {target_ref}"), target_ref.clone(), true)
    } else {
        (parsed.comment_suffix.clone(), target_ref.clone(), false)
    };

    let new_line = format!("{}{val}{final_comment}", parsed.prefix);
    let record = ChangeRecord {
        line_no: 0,
        repo: format!("{}/{}", repo.owner, repo.repo),
        old_ref: old_display_ref,
        new_ref: if pin && !is_pinned_digest {
            target_sha
        } else {
            display_new_ref
        },
        pinned: is_pinned,
    };

    Ok(Some((new_line, Some(record))))
}