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| {
34 if c.is_ascii_alphanumeric() || c == '-' {
35 c
36 } else {
37 '-'
38 }
39 })
40 .collect();
41 let trimmed = if safe_repo.len() > 50 {
42 &safe_repo[..50]
43 } else {
44 &safe_repo
45 };
46 format!("pr-{pr_number}-{trimmed}").to_lowercase()
47}
48
49pub fn build_allocation(
58 evt: &PullRequestEvent,
59 namespace: &str,
60 pool_name: Option<&str>,
61 include_drafts: bool,
62) -> Result<EphemeralAllocation, FactoryError> {
63 match evt.action {
66 PrAction::Opened | PrAction::Reopened | PrAction::Synchronize => {}
67 other => return Err(FactoryError::NotAllocatable(other)),
68 }
69
70 if !include_drafts && evt.pull_request.draft.unwrap_or(false) {
71 return Err(FactoryError::DraftExcluded);
72 }
73
74 let name = allocation_name(&evt.repository.full_name, evt.number);
75 let labels: Vec<String> = evt
76 .pull_request
77 .labels
78 .iter()
79 .map(|l| l.name.clone())
80 .collect();
81
82 let pool_ref = pool_name.map(|n| AllocationRef::new(n, namespace));
93
94 let spec = AllocationSpec {
95 pool_ref,
96 requestor: Requestor {
97 kind: "github-pr".into(),
98 repo: Some(evt.repository.full_name.clone()),
99 branch: Some(evt.pull_request.head.ref_name.clone()),
100 pr_number: Some(evt.number),
101 sha: Some(evt.pull_request.head.sha.clone()),
102 pr_labels: labels,
103 actor: Some(evt.pull_request.user.login.clone()),
104 },
105 ttl: None,
106 note: Some(format!(
107 "github webhook: PR #{} on {} ({})",
108 evt.number,
109 evt.repository.full_name,
110 format_action(evt.action)
111 )),
112 };
113
114 let mut alloc = EphemeralAllocation::new(&name, spec);
115 alloc.meta_mut().namespace = Some(namespace.to_string());
116 Ok(alloc)
117}
118
119fn format_action(a: PrAction) -> &'static str {
120 match a {
121 PrAction::Opened => "opened",
122 PrAction::Reopened => "reopened",
123 PrAction::Synchronize => "synchronize",
124 PrAction::Closed => "closed",
125 PrAction::Other => "other",
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use crate::event::{Branch, Label, PullRequest, Repository, User};
133
134 fn sample_event(action: PrAction, draft: bool) -> PullRequestEvent {
135 PullRequestEvent {
136 action,
137 number: 42,
138 repository: Repository {
139 full_name: "pleme-io/demo-app".into(),
140 default_branch: Some("main".into()),
141 },
142 pull_request: PullRequest {
143 head: Branch {
144 ref_name: "fix-something".into(),
145 sha: "abc123def".into(),
146 },
147 base: Branch {
148 ref_name: "main".into(),
149 sha: "def456abc".into(),
150 },
151 draft: Some(draft),
152 merged: Some(false),
153 labels: vec![
154 Label {
155 name: "needs-ephemeral".into(),
156 },
157 Label {
158 name: "integration".into(),
159 },
160 ],
161 user: User {
162 login: "drzln".into(),
163 },
164 },
165 }
166 }
167
168 #[test]
169 fn opened_pr_builds_typed_allocation() {
170 let evt = sample_event(PrAction::Opened, false);
171 let alloc = build_allocation(&evt, "ephemeral-pools", None, false).unwrap();
172 assert_eq!(
173 alloc.metadata.name.as_deref(),
174 Some("pr-42-pleme-io-demo-app")
175 );
176 assert_eq!(alloc.metadata.namespace.as_deref(), Some("ephemeral-pools"));
177 assert_eq!(alloc.spec.requestor.kind, "github-pr");
178 assert_eq!(
179 alloc.spec.requestor.repo.as_deref(),
180 Some("pleme-io/demo-app")
181 );
182 assert_eq!(alloc.spec.requestor.pr_number, Some(42));
183 assert_eq!(alloc.spec.requestor.sha.as_deref(), Some("abc123def"));
184 assert_eq!(
185 alloc.spec.requestor.pr_labels,
186 vec!["needs-ephemeral", "integration"]
187 );
188 assert_eq!(alloc.spec.requestor.actor.as_deref(), Some("drzln"));
189 assert!(alloc.spec.pool_ref.is_none());
191 }
192
193 #[test]
194 fn pool_ref_pins_to_named_pool() {
195 let evt = sample_event(PrAction::Opened, false);
196 let alloc = build_allocation(&evt, "pools", Some("demo-pool"), false).unwrap();
197 let pr = alloc.spec.pool_ref.unwrap();
198 assert_eq!(pr.name, "demo-pool");
199 assert_eq!(pr.namespace, "pools");
200 }
201
202 #[test]
203 fn draft_pr_excluded_by_default() {
204 let evt = sample_event(PrAction::Opened, true);
205 let err = build_allocation(&evt, "pools", None, false).unwrap_err();
206 assert_eq!(err, FactoryError::DraftExcluded);
207 }
208
209 #[test]
210 fn draft_pr_included_when_allowed() {
211 let evt = sample_event(PrAction::Opened, true);
212 let alloc = build_allocation(&evt, "pools", None, true).unwrap();
213 assert_eq!(alloc.spec.requestor.pr_number, Some(42));
214 }
215
216 #[test]
217 fn closed_pr_does_not_allocate() {
218 let evt = sample_event(PrAction::Closed, false);
219 let err = build_allocation(&evt, "pools", None, false).unwrap_err();
220 assert_eq!(err, FactoryError::NotAllocatable(PrAction::Closed));
221 }
222
223 #[test]
224 fn allocation_name_is_deterministic() {
225 let a = allocation_name("pleme-io/demo-app", 42);
226 let b = allocation_name("pleme-io/demo-app", 42);
227 assert_eq!(a, b);
228 }
229
230 #[test]
231 fn allocation_name_is_dns_safe() {
232 let n = allocation_name("Some/Org/Weird@Name", 7);
233 assert!(n
234 .chars()
235 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
236 assert!(n.starts_with("pr-7-"));
237 }
238
239 #[test]
240 fn synchronize_action_allocates() {
241 let evt = sample_event(PrAction::Synchronize, false);
244 let alloc = build_allocation(&evt, "pools", None, false).unwrap();
245 assert_eq!(alloc.spec.requestor.sha.as_deref(), Some("abc123def"));
246 }
247
248 #[test]
249 fn note_records_event_action_for_audit() {
250 let evt = sample_event(PrAction::Reopened, false);
251 let alloc = build_allocation(&evt, "pools", None, false).unwrap();
252 let note = alloc.spec.note.unwrap();
253 assert!(note.contains("PR #42"));
254 assert!(note.contains("reopened"));
255 assert!(note.contains("pleme-io/demo-app"));
256 }
257}