use chrono::{DateTime, TimeZone, Utc};
use clap::Args;
use regex::Regex;
use rusqlite::params;
use tracing::{info, warn};
use tga::core::config::{Config, DoraConfig, RepositoryConfig};
use tga::core::db::Database;
mod github;
use github::{ingest_github_actions, ingest_github_releases};
#[cfg(test)]
use github::{
extract_owner_repo_from_url, is_kept_run, parse_next_link_value, resolve_repo_to_github_slug,
ApiRelease, ApiWorkflowRun, ApiWorkflowRunsEnvelope,
};
pub(super) const USER_AGENT_VALUE: &str = "trusty-git-analytics/0.1";
pub(super) const GITHUB_API_BASE: &str = "https://api.github.com";
pub(super) const PAGE_SIZE: u32 = 100;
pub(super) const GITHUB_TOKEN_ENV: &str = "GITHUB_TOKEN";
#[derive(Args, Debug)]
#[command(
about = "Ingest deployment events into fact_deployments.",
long_about = "Walk the configured deployment source and persist deployment events into\n\
`fact_deployments`. Supported sources:\n\n\
git_tags -- match tags against dora.deployment_tag_pattern (default)\n\
github_releases -- paginate GitHub Releases API (requires GITHUB_TOKEN)\n\
github_actions -- paginate GitHub Actions runs (requires GITHUB_TOKEN)\n\
manual -- no-op (operator INSERTs directly into fact_deployments)\n\n\
The source is configured via `dora.deployment_source` in config.yaml.\n\
Use --source to override at runtime without editing the config file.",
after_help = "EXAMPLES:\n\
# Ingest from the configured source (usually git_tags)\n\
tga deployments collect\n\n\
# Force GitHub Releases source regardless of config\n\
tga deployments collect --source github_releases\n\n\
TIPS:\n\
- Set GITHUB_TOKEN before using github_releases or github_actions sources.\n\
- After ingestion, run `tga dora` to compute deployment frequency and lead time."
)]
pub struct DeploymentsCollectArgs {
#[arg(long, value_name = "SOURCE")]
pub source: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub(super) struct CollectStats {
inspected_tags: usize,
matched_tags: usize,
inserted: usize,
skipped: usize,
}
pub async fn run(
config: Config,
db: &mut Database,
args: DeploymentsCollectArgs,
) -> anyhow::Result<()> {
let dora = config.dora.clone().unwrap_or_default();
let source = args
.source
.clone()
.unwrap_or_else(|| dora.deployment_source.clone());
let stats = match source.as_str() {
"git_tags" => ingest_git_tags(db, &config.repositories, &dora)?,
"github_releases" => ingest_github_releases(db, &config.repositories, &dora).await?,
"github_actions" => ingest_github_actions(db, &config.repositories, &dora).await?,
"manual" => {
println!(
"deployment_source = 'manual' — no-op. INSERT into \
fact_deployments directly."
);
CollectStats::default()
}
other => {
anyhow::bail!(
"unknown deployment_source '{other}'. Expected one of: \
git_tags, github_releases, github_actions, manual."
);
}
};
println!(
"Inspected {} tag(s) across {} repo(s); {} matched the deployment pattern; \
{} inserted into fact_deployments, {} skipped (already present).",
stats.inspected_tags,
config.repositories.len(),
stats.matched_tags,
stats.inserted,
stats.skipped,
);
Ok(())
}
pub(super) fn ingest_git_tags(
db: &mut Database,
repositories: &[RepositoryConfig],
dora: &DoraConfig,
) -> anyhow::Result<CollectStats> {
let mut stats = CollectStats::default();
let pattern = Regex::new(&dora.deployment_tag_pattern).map_err(|e| {
anyhow::anyhow!(
"dora.deployment_tag_pattern is not a valid regex: {e} \
(pattern: {pat:?})",
pat = dora.deployment_tag_pattern
)
})?;
let conn = db.connection_mut();
let tx = conn.transaction()?;
{
let mut insert = tx.prepare(
"INSERT OR IGNORE INTO fact_deployments \
(deploy_id, repo, environment, triggered_at, completed_at, \
status, git_sha, git_tag, triggered_by_pr, source) \
VALUES (?1, ?2, 'production', ?3, ?3, 'success', ?4, ?5, NULL, 'git_tag')",
)?;
for repo_cfg in repositories {
let repo_name = repo_cfg.name.clone().unwrap_or_else(|| {
repo_cfg
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("(unknown)")
.to_string()
});
let repo = match git2::Repository::open(&repo_cfg.path) {
Ok(r) => r,
Err(e) => {
warn!(repo = %repo_name, error = %e, "git open failed; skipping tags");
continue;
}
};
let tags = match repo.tag_names(None) {
Ok(t) => t,
Err(e) => {
warn!(repo = %repo_name, error = %e, "tag_names failed; skipping");
continue;
}
};
for tag in tags.iter().flatten() {
stats.inspected_tags += 1;
if !pattern.is_match(tag) {
continue;
}
stats.matched_tags += 1;
let refname = format!("refs/tags/{tag}");
let obj = match repo.revparse_single(&refname) {
Ok(o) => o,
Err(e) => {
warn!(repo = %repo_name, tag = %tag, error = %e, "revparse failed");
continue;
}
};
let commit = match obj.peel_to_commit() {
Ok(c) => c,
Err(e) => {
warn!(repo = %repo_name, tag = %tag, error = %e, "peel failed");
continue;
}
};
let sha = commit.id().to_string();
let time = commit.time();
let triggered_at: DateTime<Utc> = Utc
.timestamp_opt(time.seconds(), 0)
.single()
.unwrap_or_else(Utc::now);
let deploy_id = format!("{repo_name}@{tag}");
let changed = insert.execute(params![
deploy_id,
repo_name,
triggered_at.to_rfc3339(),
sha,
tag,
])?;
if changed > 0 {
stats.inserted += 1;
} else {
stats.skipped += 1;
}
}
}
}
tx.commit()?;
info!(
inspected = stats.inspected_tags,
matched = stats.matched_tags,
inserted = stats.inserted,
skipped = stats.skipped,
"git-tag deployment ingestion complete"
);
Ok(stats)
}
#[cfg(test)]
#[path = "deployments_tests.rs"]
mod tests;