use kube::Resource;
use tatara_process::allocation::{AllocationSpec, EphemeralAllocation, Requestor};
use tatara_process::pool::AllocationRef;
use crate::event::{PrAction, PullRequestEvent};
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum FactoryError {
#[error("PR action {0:?} does not warrant allocation")]
NotAllocatable(PrAction),
#[error("PR is draft and drafts are excluded")]
DraftExcluded,
}
#[must_use]
pub fn allocation_name(repo: &str, pr_number: u64) -> String {
let safe_repo = repo.replace('/', "-");
let safe_repo: String = safe_repo
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
.collect();
let trimmed = if safe_repo.len() > 50 {
&safe_repo[..50]
} else {
&safe_repo
};
format!("pr-{pr_number}-{trimmed}").to_lowercase()
}
pub fn build_allocation(
evt: &PullRequestEvent,
namespace: &str,
pool_name: Option<&str>,
include_drafts: bool,
) -> Result<EphemeralAllocation, FactoryError> {
match evt.action {
PrAction::Opened | PrAction::Reopened | PrAction::Synchronize => {}
other => return Err(FactoryError::NotAllocatable(other)),
}
if !include_drafts && evt.pull_request.draft.unwrap_or(false) {
return Err(FactoryError::DraftExcluded);
}
let name = allocation_name(&evt.repository.full_name, evt.number);
let labels: Vec<String> = evt
.pull_request
.labels
.iter()
.map(|l| l.name.clone())
.collect();
let pool_ref = pool_name.map(|n| AllocationRef {
name: n.to_string(),
namespace: namespace.to_string(),
});
let spec = AllocationSpec {
pool_ref,
requestor: Requestor {
kind: "github-pr".into(),
repo: Some(evt.repository.full_name.clone()),
branch: Some(evt.pull_request.head.ref_name.clone()),
pr_number: Some(evt.number),
sha: Some(evt.pull_request.head.sha.clone()),
pr_labels: labels,
actor: Some(evt.pull_request.user.login.clone()),
},
ttl: None,
note: Some(format!(
"github webhook: PR #{} on {} ({})",
evt.number,
evt.repository.full_name,
format_action(evt.action)
)),
};
let mut alloc = EphemeralAllocation::new(&name, spec);
alloc.meta_mut().namespace = Some(namespace.to_string());
Ok(alloc)
}
fn format_action(a: PrAction) -> &'static str {
match a {
PrAction::Opened => "opened",
PrAction::Reopened => "reopened",
PrAction::Synchronize => "synchronize",
PrAction::Closed => "closed",
PrAction::Other => "other",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Branch, Label, PullRequest, Repository, User};
fn sample_event(action: PrAction, draft: bool) -> PullRequestEvent {
PullRequestEvent {
action,
number: 42,
repository: Repository {
full_name: "pleme-io/akeyless-deployment".into(),
default_branch: Some("main".into()),
},
pull_request: PullRequest {
head: Branch {
ref_name: "fix-something".into(),
sha: "abc123def".into(),
},
base: Branch {
ref_name: "main".into(),
sha: "def456abc".into(),
},
draft: Some(draft),
merged: Some(false),
labels: vec![
Label {
name: "needs-akeyless".into(),
},
Label {
name: "integration".into(),
},
],
user: User {
login: "drzln".into(),
},
},
}
}
#[test]
fn opened_pr_builds_typed_allocation() {
let evt = sample_event(PrAction::Opened, false);
let alloc = build_allocation(&evt, "ephemeral-pools", None, false).unwrap();
assert_eq!(
alloc.metadata.name.as_deref(),
Some("pr-42-pleme-io-akeyless-deployment")
);
assert_eq!(alloc.metadata.namespace.as_deref(), Some("ephemeral-pools"));
assert_eq!(alloc.spec.requestor.kind, "github-pr");
assert_eq!(
alloc.spec.requestor.repo.as_deref(),
Some("pleme-io/akeyless-deployment")
);
assert_eq!(alloc.spec.requestor.pr_number, Some(42));
assert_eq!(alloc.spec.requestor.sha.as_deref(), Some("abc123def"));
assert_eq!(alloc.spec.requestor.pr_labels, vec!["needs-akeyless", "integration"]);
assert_eq!(alloc.spec.requestor.actor.as_deref(), Some("drzln"));
assert!(alloc.spec.pool_ref.is_none());
}
#[test]
fn pool_ref_pins_to_named_pool() {
let evt = sample_event(PrAction::Opened, false);
let alloc = build_allocation(&evt, "pools", Some("akeyless-pool"), false).unwrap();
let pr = alloc.spec.pool_ref.unwrap();
assert_eq!(pr.name, "akeyless-pool");
assert_eq!(pr.namespace, "pools");
}
#[test]
fn draft_pr_excluded_by_default() {
let evt = sample_event(PrAction::Opened, true);
let err = build_allocation(&evt, "pools", None, false).unwrap_err();
assert_eq!(err, FactoryError::DraftExcluded);
}
#[test]
fn draft_pr_included_when_allowed() {
let evt = sample_event(PrAction::Opened, true);
let alloc = build_allocation(&evt, "pools", None, true).unwrap();
assert_eq!(alloc.spec.requestor.pr_number, Some(42));
}
#[test]
fn closed_pr_does_not_allocate() {
let evt = sample_event(PrAction::Closed, false);
let err = build_allocation(&evt, "pools", None, false).unwrap_err();
assert_eq!(err, FactoryError::NotAllocatable(PrAction::Closed));
}
#[test]
fn allocation_name_is_deterministic() {
let a = allocation_name("pleme-io/akeyless-deployment", 42);
let b = allocation_name("pleme-io/akeyless-deployment", 42);
assert_eq!(a, b);
}
#[test]
fn allocation_name_is_dns_safe() {
let n = allocation_name("Some/Org/Weird@Name", 7);
assert!(n.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
assert!(n.starts_with("pr-7-"));
}
#[test]
fn synchronize_action_allocates() {
let evt = sample_event(PrAction::Synchronize, false);
let alloc = build_allocation(&evt, "pools", None, false).unwrap();
assert_eq!(alloc.spec.requestor.sha.as_deref(), Some("abc123def"));
}
#[test]
fn note_records_event_action_for_audit() {
let evt = sample_event(PrAction::Reopened, false);
let alloc = build_allocation(&evt, "pools", None, false).unwrap();
let note = alloc.spec.note.unwrap();
assert!(note.contains("PR #42"));
assert!(note.contains("reopened"));
assert!(note.contains("pleme-io/akeyless-deployment"));
}
}