Skip to main content

tatara_github_watcher/
event.rs

1//! Typed GitHub event shapes — only the fields the watcher reads.
2//! Everything else is intentionally dropped (forward-compatible — any
3//! new GitHub field doesn't break our deserialization).
4
5use serde::{Deserialize, Serialize};
6
7/// Discriminator over the event types the watcher handles.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum EventKind {
10    PullRequest,
11    Push,
12    Other,
13}
14
15impl EventKind {
16    /// Decode from the `X-GitHub-Event` header value.
17    pub fn from_header(s: &str) -> Self {
18        match s {
19            "pull_request" => Self::PullRequest,
20            "push" => Self::Push,
21            _ => Self::Other,
22        }
23    }
24}
25
26/// PR event subaction.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum PrAction {
30    Opened,
31    Reopened,
32    Synchronize,
33    Closed,
34    /// Anything else — typed-honest fallthrough.
35    #[serde(other)]
36    Other,
37}
38
39/// Pull-request event payload.
40#[derive(Debug, Clone, Deserialize)]
41pub struct PullRequestEvent {
42    pub action: PrAction,
43    pub number: u64,
44    pub repository: Repository,
45    pub pull_request: PullRequest,
46}
47
48#[derive(Debug, Clone, Deserialize)]
49pub struct PullRequest {
50    pub head: Branch,
51    pub base: Branch,
52    pub draft: Option<bool>,
53    pub merged: Option<bool>,
54    #[serde(default)]
55    pub labels: Vec<Label>,
56    pub user: User,
57}
58
59#[derive(Debug, Clone, Deserialize)]
60pub struct Branch {
61    #[serde(rename = "ref")]
62    pub ref_name: String,
63    pub sha: String,
64}
65
66#[derive(Debug, Clone, Deserialize)]
67pub struct Label {
68    pub name: String,
69}
70
71#[derive(Debug, Clone, Deserialize)]
72pub struct User {
73    pub login: String,
74}
75
76#[derive(Debug, Clone, Deserialize)]
77pub struct Repository {
78    pub full_name: String, // "org/repo"
79    #[serde(rename = "default_branch")]
80    pub default_branch: Option<String>,
81}
82
83/// Push event payload.
84#[derive(Debug, Clone, Deserialize)]
85pub struct PushEvent {
86    #[serde(rename = "ref")]
87    pub ref_name: String,
88    pub after: String,
89    pub repository: Repository,
90    pub pusher: Pusher,
91}
92
93#[derive(Debug, Clone, Deserialize)]
94pub struct Pusher {
95    pub name: String,
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn event_kind_decodes_known_headers() {
104        assert_eq!(EventKind::from_header("pull_request"), EventKind::PullRequest);
105        assert_eq!(EventKind::from_header("push"), EventKind::Push);
106        assert_eq!(EventKind::from_header("ping"), EventKind::Other);
107    }
108
109    #[test]
110    fn pr_action_deserializes_known() {
111        for s in ["opened", "reopened", "synchronize", "closed"] {
112            let q = format!("\"{s}\"");
113            let action: PrAction = serde_json::from_str(&q).unwrap();
114            assert!(matches!(
115                action,
116                PrAction::Opened | PrAction::Reopened | PrAction::Synchronize | PrAction::Closed
117            ));
118        }
119    }
120
121    #[test]
122    fn pr_action_unknown_falls_through_to_other() {
123        let action: PrAction = serde_json::from_str("\"edited\"").unwrap();
124        assert_eq!(action, PrAction::Other);
125    }
126
127    #[test]
128    fn pull_request_event_parses_minimal_payload() {
129        let json = r#"{
130            "action": "opened",
131            "number": 123,
132            "repository": {
133                "full_name": "pleme-io/akeyless-deployment",
134                "default_branch": "main"
135            },
136            "pull_request": {
137                "head": { "ref": "fix-something", "sha": "abc123" },
138                "base": { "ref": "main", "sha": "def456" },
139                "draft": false,
140                "merged": false,
141                "labels": [ { "name": "needs-akeyless" } ],
142                "user": { "login": "drzln" }
143            }
144        }"#;
145        let evt: PullRequestEvent = serde_json::from_str(json).unwrap();
146        assert_eq!(evt.action, PrAction::Opened);
147        assert_eq!(evt.number, 123);
148        assert_eq!(evt.repository.full_name, "pleme-io/akeyless-deployment");
149        assert_eq!(evt.pull_request.head.ref_name, "fix-something");
150        assert_eq!(evt.pull_request.labels.len(), 1);
151        assert_eq!(evt.pull_request.labels[0].name, "needs-akeyless");
152        assert_eq!(evt.pull_request.user.login, "drzln");
153    }
154
155    #[test]
156    fn push_event_parses_minimal_payload() {
157        let json = r#"{
158            "ref": "refs/heads/main",
159            "after": "deadbeef",
160            "repository": { "full_name": "pleme-io/tatara", "default_branch": "main" },
161            "pusher": { "name": "drzln" }
162        }"#;
163        let evt: PushEvent = serde_json::from_str(json).unwrap();
164        assert_eq!(evt.ref_name, "refs/heads/main");
165        assert_eq!(evt.after, "deadbeef");
166        assert_eq!(evt.repository.full_name, "pleme-io/tatara");
167    }
168
169    #[test]
170    fn extra_fields_are_ignored() {
171        // GitHub's actual payloads have ~hundreds of fields; we drop them.
172        let json = r#"{
173            "action": "opened",
174            "number": 1,
175            "repository": {
176                "full_name": "x/y",
177                "default_branch": "main",
178                "forks_count": 42,
179                "open_issues": 7
180            },
181            "pull_request": {
182                "head": { "ref": "x", "sha": "y", "extra": "ignored" },
183                "base": { "ref": "x", "sha": "y" },
184                "user": { "login": "x", "site_admin": false }
185            },
186            "sender": { "anything": "here" }
187        }"#;
188        let evt: PullRequestEvent = serde_json::from_str(json).unwrap();
189        assert_eq!(evt.number, 1);
190    }
191}