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