1 use {
2 chrono::{DateTime, Utc},
3 serde::{Deserialize, Serialize},
4 std::fmt::Display,
5 tsync::tsync,
6 };
7
8 /// An installation of the smbCloud GitHub App on a user or organization
9 /// account, as reported by the smbCloud API.
10 #[derive(Deserialize, Serialize, Debug, Clone)]
11 #[tsync]
12 pub struct GithubInstallation {
13 /// GitHub installation id.
14 pub id: i64,
15 pub account_login: String,
16 /// "User" or "Organization".
17 pub account_type: String,
18 /// "all" or "selected".
19 pub repository_selection: String,
20 pub created_at: DateTime<Utc>,
21 }
22
23 impl Display for GithubInstallation {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 write!(f, "{} ({})", self.account_login, self.account_type)
26 }
27 }
28
29 /// A GitHub repository accessible through an installation.
30 #[derive(Deserialize, Serialize, Debug, Clone)]
31 #[tsync]
32 pub struct GithubRepository {
33 /// GitHub repository id.
34 pub id: i64,
35 pub full_name: String,
36 pub default_branch: String,
37 pub private: bool,
38 }
39
40 impl Display for GithubRepository {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 write!(f, "{} [{}]", self.full_name, self.default_branch)
43 }
44 }
45
46 /// A link between a DeployRepo and a GitHub repository. Pushes to
47 /// `production_branch` trigger a deploy server-side.
48 #[derive(Deserialize, Serialize, Debug, Clone)]
49 #[tsync]
50 pub struct GithubConnection {
51 pub id: i64,
52 pub deploy_repo_id: i64,
53 pub github_installation_id: i64,
54 pub github_repo_full_name: String,
55 pub production_branch: String,
56 pub created_at: DateTime<Utc>,
57 pub updated_at: DateTime<Utc>,
58 }
59
60 /// Payload for connecting a GitHub repository to a DeployRepo.
61 #[derive(Deserialize, Serialize, Debug, Clone)]
62 #[tsync]
63 pub struct GithubConnectionCreate {
64 pub github_installation_id: i64,
65 pub github_repo_full_name: String,
66 /// Omit to let the server default to the repository's default branch.
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub production_branch: Option<String>,
69 }
70
71 /// Connection state of a DeployRepo, always returned with 200 so the CLI
72 /// can distinguish "not connected" from request errors.
73 #[derive(Deserialize, Serialize, Debug, Clone)]
74 #[tsync]
75 pub struct GithubConnectionStatus {
76 pub connected: bool,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub connection: Option<GithubConnection>,
79 }
80
81 #[cfg(test)]
82 mod tests {
83 use super::*;
84 use serde_json::json;
85
86 #[test]
87 fn test_github_connection_create_omits_missing_branch() {
88 let create = GithubConnectionCreate {
89 github_installation_id: 42,
90 github_repo_full_name: "octocat/hello-world".to_owned(),
91 production_branch: None,
92 };
93 let value = serde_json::to_value(&create).unwrap();
94 assert_eq!(value["github_installation_id"], json!(42));
95 assert_eq!(value["github_repo_full_name"], json!("octocat/hello-world"));
96 assert!(value.get("production_branch").is_none());
97 }
98
99 #[test]
100 fn test_github_connection_create_includes_branch() {
101 let create = GithubConnectionCreate {
102 github_installation_id: 42,
103 github_repo_full_name: "octocat/hello-world".to_owned(),
104 production_branch: Some("main".to_owned()),
105 };
106 let value = serde_json::to_value(&create).unwrap();
107 assert_eq!(value["production_branch"], json!("main"));
108 }
109
110 #[test]
111 fn test_github_connection_status_deserializes_without_connection() {
112 let status: GithubConnectionStatus =
113 serde_json::from_value(json!({ "connected": false })).unwrap();
114 assert!(!status.connected);
115 assert!(status.connection.is_none());
116 }
117
118 #[test]
119 fn test_github_connection_status_deserializes_with_connection() {
120 let status: GithubConnectionStatus = serde_json::from_value(json!({
121 "connected": true,
122 "connection": {
123 "id": 1,
124 "deploy_repo_id": 5,
125 "github_installation_id": 42,
126 "github_repo_full_name": "octocat/hello-world",
127 "production_branch": "main",
128 "created_at": "2026-01-01T00:00:00Z",
129 "updated_at": "2026-01-01T00:00:00Z"
130 }
131 }))
132 .unwrap();
133 assert!(status.connected);
134 let connection = status.connection.expect("connection should be present");
135 assert_eq!(connection.deploy_repo_id, 5);
136 assert_eq!(connection.github_repo_full_name, "octocat/hello-world");
137 assert_eq!(connection.production_branch, "main");
138 }
139
140 #[test]
141 fn test_github_installation_round_trip() {
142 let installation: GithubInstallation = serde_json::from_value(json!({
143 "id": 42,
144 "account_login": "octocat",
145 "account_type": "User",
146 "repository_selection": "selected",
147 "created_at": "2026-01-01T00:00:00Z"
148 }))
149 .unwrap();
150 assert_eq!(installation.to_string(), "octocat (User)");
151 let value = serde_json::to_value(&installation).unwrap();
152 assert_eq!(value["account_login"], json!("octocat"));
153 }
154
155 #[test]
156 fn test_github_repository_round_trip() {
157 let repository: GithubRepository = serde_json::from_value(json!({
158 "id": 7,
159 "full_name": "octocat/hello-world",
160 "default_branch": "main",
161 "private": true
162 }))
163 .unwrap();
164 assert_eq!(repository.to_string(), "octocat/hello-world [main]");
165 let value = serde_json::to_value(&repository).unwrap();
166 assert_eq!(value["private"], json!(true));
167 }
168 }