Skip to main content

tatara_github_watcher/
allocation_factory.rs

1//! Translate a GitHub event into an `EphemeralAllocation` spec.
2//!
3//! Pure function: GitHub event in, typed Allocation out. The handler
4//! applies the resulting Allocation via kube-rs.
5
6use kube::Resource;
7
8use tatara_process::allocation::{AllocationSpec, EphemeralAllocation, Requestor};
9use tatara_process::pool::AllocationRef;
10
11use crate::event::{PrAction, PullRequestEvent};
12
13/// Errors building an Allocation from an event.
14#[derive(Debug, thiserror::Error, PartialEq, Eq)]
15pub enum FactoryError {
16    /// The PR action doesn't warrant an allocation (e.g., draft PRs,
17    /// labeling-only edits).
18    #[error("PR action {0:?} does not warrant allocation")]
19    NotAllocatable(PrAction),
20    /// PR is a draft and the watcher's policy excludes drafts.
21    #[error("PR is draft and drafts are excluded")]
22    DraftExcluded,
23}
24
25/// Deterministic Allocation name from a PR event — re-running the same
26/// event yields the same name (idempotent create).
27#[must_use]
28pub fn allocation_name(repo: &str, pr_number: u64) -> String {
29    // Replace `/` so the result is a valid K8s name.
30    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
49/// Build a typed EphemeralAllocation from a PR event.
50///
51/// Routing knobs:
52/// * `namespace` — where to create the Allocation. Same namespace as the
53///   pool (typically `ephemeral-pools`).
54/// * `pool_name` — when `Some`, pin the allocation to a named pool (skips
55///   selector routing). When `None`, the reconciler matches via PoolSelector.
56/// * `include_drafts` — when `false`, draft PRs return `DraftExcluded`.
57pub fn build_allocation(
58    evt: &PullRequestEvent,
59    namespace: &str,
60    pool_name: Option<&str>,
61    include_drafts: bool,
62) -> Result<EphemeralAllocation, FactoryError> {
63    // Action filter — only opening states allocate; "closed" is handled
64    // by the deletion path elsewhere.
65    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    // The `spec.poolRef` slot rides through the substrate
83    // constructor `AllocationRef::new` — pre-lift this was a hand-
84    // authored `AllocationRef { name, namespace }` struct-literal,
85    // one of FOUR workspace-wide restatements past the ★★ PRIME-
86    // DIRECTIVE ≥ 2 duplication threshold (peers at
87    // `tatara-pool-reconciler::controller_allocation`'s Bind + Release
88    // `assignedProcess` seeds, and at
89    // `tatara-pool-reconciler::allocation_decide::AllocationConvergenceCtx::observe`'s
90    // pool_ref seed). Post-lift the four consumers share ONE substrate
91    // owner on `AllocationRef`.
92    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        // Selector-routed (no pool pinned).
190        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        // PR push events come as `synchronize` — same allocation is
242        // refreshed (idempotent name).
243        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}