1
+use {
2
+ crate::{
3
+ ci,
4
+ cli::CommandResult,
5
+ client,
6
+ github::cli::Commands,
7
+ project::current_project::resolve_required_project_id,
8
+ token::get_smb_token::get_smb_token,
9
+ ui::{prompt, succeed_message, succeed_symbol},
10
+ },
11
+ anyhow::{anyhow, Result},
12
+ smbcloud_model::github::{
13
+ GithubConnection, GithubConnectionCreate, GithubInstallation, GithubRepository,
14
+ },
15
+ smbcloud_network::environment::Environment,
16
+ smbcloud_networking::constants::GH_APP_SLUG,
17
+ smbcloud_networking_project::{
18
+ crud_github_connection::{
19
+ create_github_connection, delete_github_connection, get_github_connection,
20
+ get_github_installation_repositories, get_github_installations,
21
+ },
22
+ crud_project_read::get_project,
23
+ },
24
+ spinners::Spinner,
25
+ std::{collections::HashSet, time::Duration},
26
+};
27
+
28
+pub async fn process_github(env: Environment, commands: Commands) -> Result<CommandResult> {
29
+ match commands {
30
+ Commands::Install {} => process_github_install(env).await,
31
+ Commands::Connect {
32
+ repo,
33
+ branch,
34
+ project_id,
35
+ } => process_github_connect(env, repo, branch, project_id).await,
36
+ Commands::Status { project_id } => process_github_status(env, project_id).await,
37
+ Commands::Disconnect { project_id } => process_github_disconnect(env, project_id).await,
38
+ }
39
+}
40
+
41
+async fn process_github_install(env: Environment) -> Result<CommandResult> {
42
+ let access_token = get_smb_token(env)?;
43
+ if ci::is_ci() {
44
+ return Err(anyhow!(ci::interactive_message(
45
+ "GitHub App installation (a browser flow)"
46
+ )));
47
+ }
48
+
49
+ let existing_ids: HashSet<i64> = get_github_installations(env, client(), access_token.clone())
50
+ .await
51
+ .map_err(api_error)?
52
+ .iter()
53
+ .map(|installation| installation.id)
54
+ .collect();
55
+
56
+ let install_url = format!("https://github.com/apps/{GH_APP_SLUG}/installations/new");
57
+ if open::that(&install_url).is_err() {
58
+ println!("Open this URL in your browser to install the app:\n {install_url}");
59
+ }
60
+
61
+ let mut spinner = loading_spinner("Waiting for the installation to complete in your browser");
62
+ let poll_interval = Duration::from_secs(5);
63
+ let poll_attempts = 24;
64
+ for _ in 0..poll_attempts {
65
+ tokio::time::sleep(poll_interval).await;
66
+ let installations = get_github_installations(env, client(), access_token.clone())
67
+ .await
68
+ .map_err(api_error)?;
69
+ if let Some(new_installation) = installations
70
+ .iter()
71
+ .find(|installation| !existing_ids.contains(&installation.id))
72
+ {
73
+ spinner.stop_and_persist(
74
+ &succeed_symbol(),
75
+ succeed_message(&format!(
76
+ "App installed on {}.",
77
+ new_installation.account_login
78
+ )),
79
+ );
80
+ return Ok(done_result(
81
+ "Run `smb github connect` to link a repository to your project.",
82
+ ));
83
+ }
84
+ }
85
+
86
+ spinner.stop_and_persist(
87
+ &succeed_symbol(),
88
+ succeed_message("No new installation detected yet."),
89
+ );
90
+ Ok(done_result(
91
+ "If you completed the installation in the browser, run `smb github connect` \
92
+ or `smb github status` to continue.",
93
+ ))
94
+}
95
+
96
+async fn process_github_connect(
97
+ env: Environment,
98
+ repo: Option<String>,
99
+ branch: Option<String>,
100
+ project_id: Option<String>,
101
+) -> Result<CommandResult> {
102
+ let access_token = get_smb_token(env)?;
103
+ let deploy_repo_id = require_deploy_repo_id(env, access_token.clone(), project_id).await?;
104
+
105
+ let mut spinner = loading_spinner("Loading your GitHub App installations");
106
+ let installations = get_github_installations(env, client(), access_token.clone())
107
+ .await
108
+ .map_err(api_error)?;
109
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
110
+
111
+ if installations.is_empty() {
112
+ return Err(anyhow!(
113
+ "No smbCloud GitHub App installations found. Run `smb github install` first."
114
+ ));
115
+ }
116
+
117
+ let (installation, repository) = match normalize_optional(repo) {
118
+ Some(full_name) => {
119
+ find_repository(env, access_token.clone(), &installations, &full_name).await?
120
+ }
121
+ None => pick_repository(env, access_token.clone(), &installations).await?,
122
+ };
123
+
124
+ let payload = GithubConnectionCreate {
125
+ github_installation_id: installation.id,
126
+ github_repo_full_name: repository.full_name.clone(),
127
+ production_branch: normalize_optional(branch),
128
+ };
129
+
130
+ let mut spinner = loading_spinner("Connecting the repository");
131
+ let connection = create_github_connection(env, client(), access_token, deploy_repo_id, payload)
132
+ .await
133
+ .map_err(api_error)?;
134
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Connected."));
135
+ print_connection(&connection);
136
+
137
+ Ok(done_result(&format!(
138
+ "Connected. Pushes to `{}` now deploy automatically.",
139
+ connection.production_branch
140
+ )))
141
+}
142
+
143
+async fn process_github_status(
144
+ env: Environment,
145
+ project_id: Option<String>,
146
+) -> Result<CommandResult> {
147
+ let access_token = get_smb_token(env)?;
148
+ let deploy_repo_id = require_deploy_repo_id(env, access_token.clone(), project_id).await?;
149
+
150
+ let mut spinner = loading_spinner("Loading the GitHub connection");
151
+ let status = get_github_connection(env, client(), access_token, deploy_repo_id)
152
+ .await
153
+ .map_err(api_error)?;
154
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
155
+
156
+ match status.connection.filter(|_| status.connected) {
157
+ Some(connection) => {
158
+ print_connection(&connection);
159
+ Ok(done_result("Done."))
160
+ }
161
+ None => Ok(done_result(
162
+ "Not connected. Run `smb github connect` to set up auto-deploy on push.",
163
+ )),
164
+ }
165
+}
166
+
167
+async fn process_github_disconnect(
168
+ env: Environment,
169
+ project_id: Option<String>,
170
+) -> Result<CommandResult> {
171
+ let access_token = get_smb_token(env)?;
172
+ let deploy_repo_id = require_deploy_repo_id(env, access_token.clone(), project_id).await?;
173
+
174
+ let mut spinner = loading_spinner("Loading the GitHub connection");
175
+ let status = get_github_connection(env, client(), access_token.clone(), deploy_repo_id)
176
+ .await
177
+ .map_err(api_error)?;
178
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
179
+
180
+ let connection = match status.connection.filter(|_| status.connected) {
181
+ Some(connection) => connection,
182
+ None => return Ok(done_result("Nothing to disconnect.")),
183
+ };
184
+
185
+ let confirmed = prompt::confirm(
186
+ &format!(
187
+ "Disconnect {} from this project? Pushes will no longer deploy automatically",
188
+ connection.github_repo_full_name
189
+ ),
190
+ false,
191
+ )?;
192
+ if !confirmed {
193
+ return Ok(done_result("Aborted."));
194
+ }
195
+
196
+ let mut spinner = loading_spinner("Disconnecting the repository");
197
+ delete_github_connection(env, client(), access_token, deploy_repo_id)
198
+ .await
199
+ .map_err(api_error)?;
200
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Disconnected."));
201
+
202
+ Ok(done_result("Disconnected."))
203
+}
204
+
205
+/// Resolve the project (flag or current project) and return its deploy repo id,
206
+/// the unit a GitHub repository connects to.
207
+async fn require_deploy_repo_id(
208
+ env: Environment,
209
+ access_token: String,
210
+ project_id: Option<String>,
211
+) -> Result<i64> {
212
+ let resolved_project_id = resolve_required_project_id(env, normalize_optional(project_id))?;
213
+ let project = get_project(env, client(), access_token, resolved_project_id)
214
+ .await
215
+ .map_err(api_error)?;
216
+ project.deploy_repo_id.ok_or_else(|| {
217
+ anyhow!("This project has no deploy repo yet. Run `smb deploy` once to initialize it.")
218
+ })
219
+}
220
+
221
+/// Locate `full_name` across all installations. Non-interactive, so
222
+/// `--repo owner/name` works in CI.
223
+async fn find_repository(
224
+ env: Environment,
225
+ access_token: String,
226
+ installations: &[GithubInstallation],
227
+ full_name: &str,
228
+) -> Result<(GithubInstallation, GithubRepository)> {
229
+ for installation in installations {
230
+ let repositories = get_github_installation_repositories(
231
+ env,
232
+ client(),
233
+ access_token.clone(),
234
+ installation.id,
235
+ )
236
+ .await
237
+ .map_err(api_error)?;
238
+ if let Some(repository) = repositories
239
+ .into_iter()
240
+ .find(|repository| repository.full_name == full_name)
241
+ {
242
+ return Ok((installation.clone(), repository));
243
+ }
244
+ }
245
+ Err(anyhow!(
246
+ "Repository `{full_name}` is not accessible by any smbCloud GitHub App installation. \
247
+ Check the app's repository access on GitHub, or run `smb github install`."
248
+ ))
249
+}
250
+
251
+async fn pick_repository(
252
+ env: Environment,
253
+ access_token: String,
254
+ installations: &[GithubInstallation],
255
+) -> Result<(GithubInstallation, GithubRepository)> {
256
+ let installation = if installations.len() == 1 {
257
+ installations[0].clone()
258
+ } else {
259
+ let index = prompt::select("Select a GitHub account", installations, 0, None)?;
260
+ installations
261
+ .get(index)
262
+ .ok_or_else(|| anyhow!("Invalid selection."))?
263
+ .clone()
264
+ };
265
+
266
+ let mut spinner = loading_spinner("Loading repositories");
267
+ let repositories =
268
+ get_github_installation_repositories(env, client(), access_token, installation.id)
269
+ .await
270
+ .map_err(api_error)?;
271
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
272
+
273
+ if repositories.is_empty() {
274
+ return Err(anyhow!(
275
+ "The installation on {} has no accessible repositories. \
276
+ Grant the app access to a repository on GitHub first.",
277
+ installation.account_login
278
+ ));
279
+ }
280
+
281
+ let index = prompt::select("Select a repository", &repositories, 0, None)?;
282
+ let repository = repositories
283
+ .get(index)
284
+ .ok_or_else(|| anyhow!("Invalid selection."))?
285
+ .clone();
286
+ Ok((installation, repository))
287
+}
288
+
289
+fn print_connection(connection: &GithubConnection) {
290
+ println!("Repository: {}", connection.github_repo_full_name);
291
+ println!("Production branch: {}", connection.production_branch);
292
+ println!("Installation id: {}", connection.github_installation_id);
293
+ println!("Updated at: {}", connection.updated_at);
294
+}
295
+
296
+fn loading_spinner(message: &str) -> Spinner {
297
+ Spinner::new(
298
+ spinners::Spinners::SimpleDotsScrolling,
299
+ succeed_message(message),
300
+ )
301
+}
302
+
303
+fn done_result(message: &str) -> CommandResult {
304
+ CommandResult {
305
+ spinner: loading_spinner("Done"),
306
+ symbol: succeed_symbol(),
307
+ msg: succeed_message(message),
308
+ }
309
+}
310
+
311
+fn normalize_optional(value: Option<String>) -> Option<String> {
312
+ value.and_then(|value| {
313
+ let normalized_value = value.trim().to_string();
314
+ if normalized_value.is_empty() {
315
+ None
316
+ } else {
317
+ Some(normalized_value)
318
+ }
319
+ })
320
+}
321
+
322
+fn api_error(error: impl std::fmt::Display) -> anyhow::Error {
323
+ anyhow!(error.to_string())
324
+}