1use kube::Resource;
7
8use tatara_process::allocation::{AllocationSpec, EphemeralAllocation, Requestor};
9use tatara_process::pool::AllocationRef;
10
11use crate::event::{PrAction, PullRequestEvent};
12
13#[derive(Debug, thiserror::Error, PartialEq, Eq)]
15pub enum FactoryError {
16 #[error("PR action {0:?} does not warrant allocation")]
19 NotAllocatable(PrAction),
20 #[error("PR is draft and drafts are excluded")]
22 DraftExcluded,
23}
24
25#[must_use]
28pub fn allocation_name(repo: &str, pr_number: u64) -> String {
29 let safe_repo = repo.replace('/', "-");
31 let safe_repo: String = safe_repo
32 .chars()
33 .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
34 .collect();
35 let trimmed = if safe_repo.len() > 50 {
36 &safe_repo[..50]
37 } else {
38 &safe_repo
39 };
40 format!("pr-{pr_number}-{trimmed}").to_lowercase()
41}
42
43pub fn build_allocation(
52 evt: &PullRequestEvent,
53 namespace: &str,
54 pool_name: Option<&str>,
55 include_drafts: bool,
56) -> Result<EphemeralAllocation, FactoryError> {
57 match evt.action {
60 PrAction::Opened | PrAction::Reopened | PrAction::Synchronize => {}
61 other => return Err(FactoryError::NotAllocatable(other)),
62 }
63
64 if !include_drafts && evt.pull_request.draft.unwrap_or(false) {
65 return Err(FactoryError::DraftExcluded);
66 }
67
68 let name = allocation_name(&evt.repository.full_name, evt.number);
69 let labels: Vec<String> = evt
70 .pull_request
71 .labels
72 .iter()
73 .map(|l| l.name.clone())
74 .collect();
75
76 let pool_ref = pool_name.map(|n| AllocationRef {
77 name: n.to_string(),
78 namespace: namespace.to_string(),
79 });
80
81 let spec = AllocationSpec {
82 pool_ref,
83 requestor: Requestor {
84 kind: "github-pr".into(),
85 repo: Some(evt.repository.full_name.clone()),
86 branch: Some(evt.pull_request.head.ref_name.clone()),
87 pr_number: Some(evt.number),
88 sha: Some(evt.pull_request.head.sha.clone()),
89 pr_labels: labels,
90 actor: Some(evt.pull_request.user.login.clone()),
91 },
92 ttl: None,
93 note: Some(format!(
94 "github webhook: PR #{} on {} ({})",
95 evt.number,
96 evt.repository.full_name,
97 format_action(evt.action)
98 )),
99 };
100
101 let mut alloc = EphemeralAllocation::new(&name, spec);
102 alloc.meta_mut().namespace = Some(namespace.to_string());
103 Ok(alloc)
104}
105
106fn format_action(a: PrAction) -> &'static str {
107 match a {
108 PrAction::Opened => "opened",
109 PrAction::Reopened => "reopened",
110 PrAction::Synchronize => "synchronize",
111 PrAction::Closed => "closed",
112 PrAction::Other => "other",
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use crate::event::{Branch, Label, PullRequest, Repository, User};
120
121 fn sample_event(action: PrAction, draft: bool) -> PullRequestEvent {
122 PullRequestEvent {
123 action,
124 number: 42,
125 repository: Repository {
126 full_name: "pleme-io/akeyless-deployment".into(),
127 default_branch: Some("main".into()),
128 },
129 pull_request: PullRequest {
130 head: Branch {
131 ref_name: "fix-something".into(),
132 sha: "abc123def".into(),
133 },
134 base: Branch {
135 ref_name: "main".into(),
136 sha: "def456abc".into(),
137 },
138 draft: Some(draft),
139 merged: Some(false),
140 labels: vec![
141 Label {
142 name: "needs-akeyless".into(),
143 },
144 Label {
145 name: "integration".into(),
146 },
147 ],
148 user: User {
149 login: "drzln".into(),
150 },
151 },
152 }
153 }
154
155 #[test]
156 fn opened_pr_builds_typed_allocation() {
157 let evt = sample_event(PrAction::Opened, false);
158 let alloc = build_allocation(&evt, "ephemeral-pools", None, false).unwrap();
159 assert_eq!(
160 alloc.metadata.name.as_deref(),
161 Some("pr-42-pleme-io-akeyless-deployment")
162 );
163 assert_eq!(alloc.metadata.namespace.as_deref(), Some("ephemeral-pools"));
164 assert_eq!(alloc.spec.requestor.kind, "github-pr");
165 assert_eq!(
166 alloc.spec.requestor.repo.as_deref(),
167 Some("pleme-io/akeyless-deployment")
168 );
169 assert_eq!(alloc.spec.requestor.pr_number, Some(42));
170 assert_eq!(alloc.spec.requestor.sha.as_deref(), Some("abc123def"));
171 assert_eq!(alloc.spec.requestor.pr_labels, vec!["needs-akeyless", "integration"]);
172 assert_eq!(alloc.spec.requestor.actor.as_deref(), Some("drzln"));
173 assert!(alloc.spec.pool_ref.is_none());
175 }
176
177 #[test]
178 fn pool_ref_pins_to_named_pool() {
179 let evt = sample_event(PrAction::Opened, false);
180 let alloc = build_allocation(&evt, "pools", Some("akeyless-pool"), false).unwrap();
181 let pr = alloc.spec.pool_ref.unwrap();
182 assert_eq!(pr.name, "akeyless-pool");
183 assert_eq!(pr.namespace, "pools");
184 }
185
186 #[test]
187 fn draft_pr_excluded_by_default() {
188 let evt = sample_event(PrAction::Opened, true);
189 let err = build_allocation(&evt, "pools", None, false).unwrap_err();
190 assert_eq!(err, FactoryError::DraftExcluded);
191 }
192
193 #[test]
194 fn draft_pr_included_when_allowed() {
195 let evt = sample_event(PrAction::Opened, true);
196 let alloc = build_allocation(&evt, "pools", None, true).unwrap();
197 assert_eq!(alloc.spec.requestor.pr_number, Some(42));
198 }
199
200 #[test]
201 fn closed_pr_does_not_allocate() {
202 let evt = sample_event(PrAction::Closed, false);
203 let err = build_allocation(&evt, "pools", None, false).unwrap_err();
204 assert_eq!(err, FactoryError::NotAllocatable(PrAction::Closed));
205 }
206
207 #[test]
208 fn allocation_name_is_deterministic() {
209 let a = allocation_name("pleme-io/akeyless-deployment", 42);
210 let b = allocation_name("pleme-io/akeyless-deployment", 42);
211 assert_eq!(a, b);
212 }
213
214 #[test]
215 fn allocation_name_is_dns_safe() {
216 let n = allocation_name("Some/Org/Weird@Name", 7);
217 assert!(n.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
218 assert!(n.starts_with("pr-7-"));
219 }
220
221 #[test]
222 fn synchronize_action_allocates() {
223 let evt = sample_event(PrAction::Synchronize, false);
226 let alloc = build_allocation(&evt, "pools", None, false).unwrap();
227 assert_eq!(alloc.spec.requestor.sha.as_deref(), Some("abc123def"));
228 }
229
230 #[test]
231 fn note_records_event_action_for_audit() {
232 let evt = sample_event(PrAction::Reopened, false);
233 let alloc = build_allocation(&evt, "pools", None, false).unwrap();
234 let note = alloc.spec.note.unwrap();
235 assert!(note.contains("PR #42"));
236 assert!(note.contains("reopened"));
237 assert!(note.contains("pleme-io/akeyless-deployment"));
238 }
239}