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 tatara_process::allocation::{AllocationSpec, EphemeralAllocation, Requestor};
7use tatara_process::pool::AllocationRef;
8
9use crate::event::{PrAction, PullRequestEvent};
10
11/// Errors building an Allocation from an event.
12#[derive(Debug, thiserror::Error, PartialEq, Eq)]
13pub enum FactoryError {
14    /// The PR action doesn't warrant an allocation (e.g., draft PRs,
15    /// labeling-only edits).
16    #[error("PR action {0:?} does not warrant allocation")]
17    NotAllocatable(PrAction),
18    /// PR is a draft and the watcher's policy excludes drafts.
19    #[error("PR is draft and drafts are excluded")]
20    DraftExcluded,
21}
22
23/// Deterministic Allocation name from a PR event — re-running the same
24/// event yields the same name (idempotent create).
25#[must_use]
26pub fn allocation_name(repo: &str, pr_number: u64) -> String {
27    // Replace `/` so the result is a valid K8s name.
28    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
47/// Build a typed EphemeralAllocation from a PR event.
48///
49/// Routing knobs:
50/// * `namespace` — where to create the Allocation. Same namespace as the
51///   pool (typically `ephemeral-pools`).
52/// * `pool_name` — when `Some`, pin the allocation to a named pool (skips
53///   selector routing). When `None`, the reconciler matches via PoolSelector.
54/// * `include_drafts` — when `false`, draft PRs return `DraftExcluded`.
55pub fn build_allocation(
56    evt: &PullRequestEvent,
57    namespace: &str,
58    pool_name: Option<&str>,
59    include_drafts: bool,
60) -> Result<EphemeralAllocation, FactoryError> {
61    // Action filter — only opening states allocate; "closed" is handled
62    // by the deletion path elsewhere.
63    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    // The `spec.poolRef` slot rides through the substrate
81    // constructor `AllocationRef::new` — pre-lift this was a hand-
82    // authored `AllocationRef { name, namespace }` struct-literal,
83    // one of FOUR workspace-wide restatements past the ★★ PRIME-
84    // DIRECTIVE ≥ 2 duplication threshold (peers at
85    // `tatara-pool-reconciler::controller_allocation`'s Bind + Release
86    // `assignedProcess` seeds, and at
87    // `tatara-pool-reconciler::allocation_decide::AllocationConvergenceCtx::observe`'s
88    // pool_ref seed). Post-lift the four consumers share ONE substrate
89    // owner on `AllocationRef`.
90    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    // The 2-line construct-then-set-namespace chain rides the ONE
113    // substrate composer [`EphemeralAllocation::new_in`] — one of TWO
114    // pre-lift sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
115    // threshold across two workspace crates (peer at
116    // `tatara-pool-reconciler::allocation_decide::tests::alloc` — the
117    // allocation-decision test fixture); see the primitive's doc-
118    // comment for the migration rationale + the sibling
119    // `EphemeralPool::new_in` peer.
120    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        // Selector-routed (no pool pinned).
194        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        // PR push events come as `synchronize` — same allocation is
246        // refreshed (idempotent name).
247        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}