use chrono::{DateTime, Utc};
use regex::Regex;
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, LINK, USER_AGENT};
use rusqlite::params;
use serde::Deserialize;
use tracing::{debug, info, warn};
use tga::core::config::{DoraConfig, RepositoryConfig};
use tga::core::db::Database;
use super::{
ingest_git_tags, CollectStats, GITHUB_API_BASE, GITHUB_TOKEN_ENV, PAGE_SIZE, USER_AGENT_VALUE,
};
#[derive(Debug, Deserialize)]
pub(super) struct ApiRelease {
pub(super) tag_name: String,
#[serde(default)]
pub(super) target_commitish: Option<String>,
#[serde(default)]
pub(super) published_at: Option<DateTime<Utc>>,
#[serde(default)]
pub(super) draft: bool,
#[serde(default)]
pub(super) prerelease: bool,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub(super) struct ApiWorkflowRun {
pub(super) id: u64,
pub(super) head_sha: String,
#[serde(default)]
pub(super) head_branch: Option<String>,
#[serde(default)]
pub(super) created_at: Option<DateTime<Utc>>,
#[serde(default)]
pub(super) updated_at: Option<DateTime<Utc>>,
#[serde(default)]
pub(super) conclusion: Option<String>,
#[serde(default)]
pub(super) name: Option<String>,
#[serde(default)]
pub(super) path: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct ApiWorkflowRunsEnvelope {
#[serde(default)]
pub(super) workflow_runs: Vec<ApiWorkflowRun>,
}
fn build_github_http_client(token: Option<&str>) -> anyhow::Result<reqwest::Client> {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
headers.insert(
ACCEPT,
HeaderValue::from_static("application/vnd.github+json"),
);
if let Some(t) = token {
let val = HeaderValue::from_str(&format!("Bearer {t}"))
.map_err(|e| anyhow::anyhow!("invalid GITHUB_TOKEN: {e}"))?;
headers.insert(AUTHORIZATION, val);
}
Ok(reqwest::Client::builder()
.default_headers(headers)
.timeout(std::time::Duration::from_secs(30))
.build()?)
}
pub(super) fn resolve_repo_to_github_slug(repo_cfg: &RepositoryConfig) -> Option<(String, String)> {
let repo_name = repo_cfg.name.clone().or_else(|| {
repo_cfg
.path
.file_name()
.and_then(|n| n.to_str())
.map(str::to_string)
});
if let Some(owner) = &repo_cfg.org {
if let Some(name) = repo_name.clone() {
if !name.is_empty() {
return Some((owner.clone(), name));
}
}
}
owner_repo_from_remote(&repo_cfg.path)
}
fn owner_repo_from_remote(repo_path: &std::path::Path) -> Option<(String, String)> {
let repo = git2::Repository::open(repo_path).ok()?;
let remote = repo.find_remote("origin").ok()?;
let url = remote.url()?;
extract_owner_repo_from_url(url)
}
pub(super) fn extract_owner_repo_from_url(url: &str) -> Option<(String, String)> {
let cleaned = url.strip_suffix(".git").unwrap_or(url);
if let Some(rest) = cleaned.strip_prefix("git@github.com:") {
return split_owner_repo(rest);
}
for prefix in [
"https://github.com/",
"http://github.com/",
"ssh://git@github.com/",
] {
if let Some(rest) = cleaned.strip_prefix(prefix) {
return split_owner_repo(rest);
}
}
if let Some(after_scheme) = cleaned.strip_prefix("https://") {
if let Some(at_idx) = after_scheme.find('@') {
let after_at = &after_scheme[at_idx + 1..];
if let Some(rest) = after_at.strip_prefix("github.com/") {
return split_owner_repo(rest);
}
}
}
None
}
fn split_owner_repo(rest: &str) -> Option<(String, String)> {
let mut parts = rest.splitn(3, '/');
let owner = parts.next()?;
let name = parts.next()?;
if owner.is_empty() || name.is_empty() {
return None;
}
Some((owner.to_string(), name.to_string()))
}
fn next_link(headers: &HeaderMap) -> Option<String> {
let link = headers.get(LINK)?.to_str().ok()?;
parse_next_link_value(link)
}
pub(super) fn parse_next_link_value(link: &str) -> Option<String> {
for entry in link.split(',') {
let entry = entry.trim();
let Some((url_part, rel_part)) = entry.split_once(';') else {
continue;
};
let url = url_part.trim();
if !url.starts_with('<') || !url.ends_with('>') {
continue;
}
let url = &url[1..url.len() - 1];
for param in rel_part.split(';') {
let param = param.trim();
if param == "rel=\"next\"" || param == "rel=next" {
return Some(url.to_string());
}
}
}
None
}
pub(super) async fn ingest_github_releases(
db: &mut Database,
repositories: &[RepositoryConfig],
dora: &DoraConfig,
) -> anyhow::Result<CollectStats> {
let token = std::env::var(GITHUB_TOKEN_ENV).ok();
if token.is_none() {
warn!(
"deployment_source = 'github_releases' requires {GITHUB_TOKEN_ENV} \
but it is unset. Falling back to git_tags so fact_deployments still populates."
);
return ingest_git_tags(db, repositories, dora);
}
let client = build_github_http_client(token.as_deref())?;
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 mut stats = CollectStats::default();
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, 'github_release')",
)?;
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 Some((owner, name)) = resolve_repo_to_github_slug(repo_cfg) else {
warn!(repo = %repo_name, "could not resolve owner/repo; skipping github_releases");
continue;
};
let releases = match fetch_all_releases(&client, &owner, &name).await {
Ok(v) => v,
Err(e) => {
warn!(
repo = %repo_name,
error = %e,
"GitHub releases fetch failed; continuing with remaining repos"
);
continue;
}
};
for rel in releases {
stats.inspected_tags += 1;
if rel.draft || rel.prerelease {
continue;
}
if !pattern.is_match(&rel.tag_name) {
continue;
}
stats.matched_tags += 1;
let Some(published_at) = rel.published_at else {
debug!(repo = %repo_name, tag = %rel.tag_name, "release has no published_at; skipping");
continue;
};
let deploy_id = format!("{repo_name}@{}", rel.tag_name);
let sha_or_branch = rel.target_commitish.unwrap_or_default();
let changed = insert.execute(params![
deploy_id,
repo_name,
published_at.to_rfc3339(),
sha_or_branch,
rel.tag_name,
])?;
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,
"github_releases deployment ingestion complete"
);
Ok(stats)
}
async fn fetch_all_releases(
client: &reqwest::Client,
owner: &str,
repo: &str,
) -> anyhow::Result<Vec<ApiRelease>> {
let mut out: Vec<ApiRelease> = Vec::new();
let mut next_url = Some(format!(
"{GITHUB_API_BASE}/repos/{owner}/{repo}/releases?per_page={PAGE_SIZE}"
));
while let Some(url) = next_url {
debug!(url = %url, "GET (github releases)");
let resp = client.get(&url).send().await?;
let next = next_link(resp.headers());
let resp = resp.error_for_status()?;
let page: Vec<ApiRelease> = resp.json().await?;
let n = page.len();
out.extend(page);
if next.is_none() || n == 0 {
break;
}
next_url = next;
}
Ok(out)
}
pub(super) async fn ingest_github_actions(
db: &mut Database,
repositories: &[RepositoryConfig],
dora: &DoraConfig,
) -> anyhow::Result<CollectStats> {
let token = std::env::var(GITHUB_TOKEN_ENV).ok();
if token.is_none() {
warn!(
"deployment_source = 'github_actions' requires {GITHUB_TOKEN_ENV} \
but it is unset. Falling back to git_tags so fact_deployments still populates."
);
return ingest_git_tags(db, repositories, dora);
}
let client = build_github_http_client(token.as_deref())?;
let workflow_filter = dora.deployment_workflow.clone();
let branch = dora.production_branch.clone();
let mut stats = CollectStats::default();
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, ?4, 'success', ?5, NULL, NULL, 'github_actions')",
)?;
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 Some((owner, name)) = resolve_repo_to_github_slug(repo_cfg) else {
warn!(repo = %repo_name, "could not resolve owner/repo; skipping github_actions");
continue;
};
let runs = match fetch_all_workflow_runs(&client, &owner, &name, &branch).await {
Ok(v) => v,
Err(e) => {
warn!(
repo = %repo_name,
error = %e,
"GitHub actions runs fetch failed; continuing with remaining repos"
);
continue;
}
};
for run in runs {
stats.inspected_tags += 1;
if !is_kept_run(&run, workflow_filter.as_deref()) {
continue;
}
stats.matched_tags += 1;
let Some(triggered_at) = run.created_at else {
debug!(repo = %repo_name, id = run.id, "run has no created_at; skipping");
continue;
};
let completed_at = run.updated_at.unwrap_or(triggered_at);
let deploy_id = format!("{repo_name}@run:{}", run.id);
let changed = insert.execute(params![
deploy_id,
repo_name,
triggered_at.to_rfc3339(),
completed_at.to_rfc3339(),
run.head_sha,
])?;
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,
"github_actions deployment ingestion complete"
);
Ok(stats)
}
pub(super) fn is_kept_run(run: &ApiWorkflowRun, workflow_filter: Option<&str>) -> bool {
if run.conclusion.as_deref() != Some("success") {
return false;
}
let Some(filter) = workflow_filter else {
return true;
};
if filter.is_empty() {
return true;
}
if run.name.as_deref() == Some(filter) {
return true;
}
if let Some(path) = run.path.as_deref() {
if path == filter || path.ends_with(&format!("/{filter}")) {
return true;
}
}
false
}
async fn fetch_all_workflow_runs(
client: &reqwest::Client,
owner: &str,
repo: &str,
branch: &str,
) -> anyhow::Result<Vec<ApiWorkflowRun>> {
let mut out: Vec<ApiWorkflowRun> = Vec::new();
let mut next_url = Some(format!(
"{GITHUB_API_BASE}/repos/{owner}/{repo}/actions/runs\
?branch={branch}&status=success&per_page={PAGE_SIZE}",
));
while let Some(url) = next_url {
debug!(url = %url, "GET (github actions runs)");
let resp = client.get(&url).send().await?;
let next = next_link(resp.headers());
let resp = resp.error_for_status()?;
let env: ApiWorkflowRunsEnvelope = resp.json().await?;
let n = env.workflow_runs.len();
out.extend(env.workflow_runs);
if next.is_none() || n == 0 {
break;
}
next_url = next;
}
Ok(out)
}