Skip to content

Commit e253bd4

Browse files
committed
Add smbCloud GitHub App integration: manifest, smb github commands, API contract
Ship the client side of a Vercel-style GitHub App (auto-deploy on push): - github-app/manifest.json for GitHub's app-manifest registration flow (contents:read, metadata:read, statuses:write; push webhook), plus docs/github-app.md covering registration, architecture, and CLI usage. - New `smb github` command group: install (opens the app install page and polls until the installation appears), connect (link a GitHub repo and production branch to the current project's deploy repo, interactive or --repo/--branch for CI), status, and disconnect. - New GithubInstallation/GithubRepository/GithubConnection models and an optional github_connection field on DeployRepo (backward compatible). - New v1/github and v1/deploy_repos/{id}/github_connection client calls in smbcloud-networking-project defining the contract the server implements. - Move current_project helpers from mail to project so github shares them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RVaVmviDggxPidc7wvYwer
1 parent 02606e2 commit e253bd4

19 files changed

Lines changed: 820 additions & 3 deletions

File tree

crates/cli/src/cli/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use {
2-
crate::{account, mail, project},
2+
crate::{account, github, mail, project},
33
clap::{Parser, Subcommand},
44
smbcloud_network::environment::Environment,
55
spinners::Spinner,
@@ -71,6 +71,11 @@ pub enum Commands {
7171
#[clap(subcommand)]
7272
command: project::cli::Commands,
7373
},
74+
#[clap(about = "Connect GitHub repositories for automatic deploys.")]
75+
Github {
76+
#[clap(subcommand)]
77+
command: github::cli::Commands,
78+
},
7479
#[clap(about = "Manage smbCloud Mail.")]
7580
Mail {
7681
#[clap(subcommand)]

crates/cli/src/github.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub mod cli;
2+
pub mod process;

crates/cli/src/github/cli.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
use clap::Subcommand;
2+
3+
#[derive(Subcommand)]
4+
pub enum Commands {
5+
#[clap(about = "Install the smbCloud GitHub App on your account or organization.")]
6+
Install {},
7+
8+
#[clap(about = "Connect a GitHub repository to a project for auto-deploy on push.")]
9+
Connect {
10+
/// GitHub repository as owner/name. Omit to pick interactively.
11+
#[clap(long)]
12+
repo: Option<String>,
13+
14+
/// Branch that triggers production deploys. Defaults to the
15+
/// repository's default branch.
16+
#[clap(long)]
17+
branch: Option<String>,
18+
19+
/// Project to connect. Defaults to the current project (`smb project use`).
20+
#[clap(long)]
21+
project_id: Option<String>,
22+
},
23+
24+
#[clap(about = "Show the GitHub connection for a project.")]
25+
Status {
26+
/// Project to inspect. Defaults to the current project.
27+
#[clap(long)]
28+
project_id: Option<String>,
29+
},
30+
31+
#[clap(about = "Disconnect the GitHub repository from a project.")]
32+
Disconnect {
33+
/// Project to disconnect. Defaults to the current project.
34+
#[clap(long)]
35+
project_id: Option<String>,
36+
},
37+
}

crates/cli/src/github/process.rs

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
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+
}

crates/cli/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod account;
44
pub mod ci;
55
pub mod cli;
66
pub mod deploy;
7+
pub mod github;
78
pub mod mail;
89
pub mod project;
910
mod token;

crates/cli/src/mail.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
pub mod cli;
2-
mod current_project;
32
pub mod process;
43
mod render;

0 commit comments

Comments
 (0)