Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,8 @@ hex = "0.4.3"
jsonwebtoken = "9"
base64 = "0.22"

# CLI
clap = { version = "4", features = ["derive"] }



40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,49 @@ GITHUB_WEBHOOK_SECRET=your-secret
```

```bash
cargo run
fila
```

The dashboard is available at `http://localhost:8000/`.

### CLI

Fila includes built-in commands to help with setup and troubleshooting:

```bash
fila # start the server (default, same as before)
fila doctor # validate config, database connection, and GitHub auth
fila setup # interactive wizard that creates a .env file
```

**`fila doctor`** reads each environment variable individually and reports what's present, what's missing, and what's broken — without starting the server. It validates your private key is valid RSA PEM, tests database connectivity, and verifies GitHub API authentication by hitting `GET /app`. Exit code 0 means everything is good, 1 means something needs attention.

```
$ fila doctor

.env file found
DATABASE_URL set
GITHUB_APP_ID set
GITHUB_PRIVATE_KEY set
GITHUB_WEBHOOK_SECRET set

SERVER_PORT 8000
HOST 127.0.0.1
MERGE_STRATEGY batch
BATCH_SIZE 5
BATCH_INTERVAL_SECS 10
CI_TIMEOUT_SECS 1800
POLL_INTERVAL_SECS 15

Private key valid RSA PEM
Database connected (sqlite)
GitHub API authenticated as "my-merge-queue"

All checks passed.
```

**`fila setup`** walks you through creating a `.env` file. It prompts for each value with sensible defaults, reads your private key from a file path, and writes everything properly formatted. Run `fila doctor` after to verify.

### Deploy

**Docker:**
Expand Down
143 changes: 143 additions & 0 deletions src/cli/doctor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
use std::env;
use std::path::Path;

use jsonwebtoken::EncodingKey;
use rapina::database::DatabaseConfig;

use crate::github::client::GitHubClient;

fn print_line(label: &str, value: &str) {
println!("{:<24} {}", label, value);
}

pub async fn run() -> i32 {
let mut issues: Vec<String> = Vec::new();

println!("fila doctor\n");

// .env file presence
let dotenv_exists = Path::new(".env").exists();
print_line(
".env file",
if dotenv_exists { "found" } else { "not found" },
);

// Required env vars — never print values
let required = [
"DATABASE_URL",
"GITHUB_APP_ID",
"GITHUB_PRIVATE_KEY",
"GITHUB_WEBHOOK_SECRET",
];

for var in &required {
match env::var(var) {
Ok(_) => print_line(var, "set"),
Err(_) => {
print_line(var, "MISSING");
issues.push(format!("{var} is not set"));
}
}
}

println!();

// Optional vars with defaults
let optionals: &[(&str, &str)] = &[
("SERVER_PORT", "8000"),
("HOST", "127.0.0.1"),
("MERGE_STRATEGY", "batch"),
("BATCH_SIZE", "5"),
("BATCH_INTERVAL_SECS", "10"),
("CI_TIMEOUT_SECS", "1800"),
("POLL_INTERVAL_SECS", "15"),
];

for (var, default) in optionals {
let value = env::var(var).unwrap_or_else(|_| default.to_string());
print_line(var, &value);
}

// Validate MERGE_STRATEGY
let strategy = env::var("MERGE_STRATEGY").unwrap_or_else(|_| "batch".to_string());
if strategy != "batch" && strategy != "sequential" {
issues.push(format!(
"MERGE_STRATEGY is \"{strategy}\", expected \"batch\" or \"sequential\""
));
}

println!();

// Private key validation
let key_result = env::var("GITHUB_PRIVATE_KEY");
match &key_result {
Ok(key) => match EncodingKey::from_rsa_pem(key.as_bytes()) {
Ok(_) => print_line("Private key", "valid RSA PEM"),
Err(e) => {
print_line("Private key", &format!("INVALID ({e})"));
issues.push(format!("GITHUB_PRIVATE_KEY is not valid RSA PEM: {e}"));
}
},
Err(_) => print_line("Private key", "skipped (not set)"),
}

// Database connectivity
let db_result = env::var("DATABASE_URL");
match &db_result {
Ok(url) => {
let db_config = DatabaseConfig::new(url);
match db_config.connect().await {
Ok(_) => {
let db_type = if url.starts_with("sqlite") {
"sqlite"
} else if url.starts_with("postgres") {
"postgres"
} else {
"unknown"
};
print_line("Database", &format!("connected ({db_type})"));
}
Err(e) => {
print_line("Database", &format!("FAILED ({e})"));
issues.push(format!("Database connection failed: {e}"));
}
}
}
Err(_) => print_line("Database", "skipped (DATABASE_URL not set)"),
}

// GitHub API auth
let app_id = env::var("GITHUB_APP_ID");
match (&app_id, &key_result) {
(Ok(id), Ok(key)) => {
let client = GitHubClient::new(id.clone(), key.clone());
match client.get_app_info().await {
Ok(info) => {
print_line("GitHub API", &format!("authenticated as \"{}\"", info.name));
}
Err(e) => {
print_line("GitHub API", &format!("FAILED ({e})"));
issues.push(format!("GitHub API authentication failed: {e}"));
}
}
}
_ => print_line("GitHub API", "skipped (credentials not set)"),
}

// Summary
println!();
if issues.is_empty() {
println!("All checks passed.");
0
} else {
println!(
"{} {} found:",
issues.len(),
if issues.len() == 1 { "issue" } else { "issues" }
);
for issue in &issues {
println!(" - {issue}");
}
1
}
}
19 changes: 19 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
pub mod doctor;
pub mod setup;

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "fila", version, about = "GitHub merge queue")]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
}

#[derive(Subcommand)]
pub enum Command {
/// Validate config, database, and GitHub auth
Doctor,
/// Interactive wizard to create a .env file
Setup,
}
94 changes: 94 additions & 0 deletions src/cli/setup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
use std::fs;
use std::io::{self, Write};
use std::path::Path;

fn prompt(label: &str, default: &str) -> String {
if default.is_empty() {
print!("{label}: ");
} else {
print!("{label} [{default}]: ");
}
io::stdout().flush().unwrap();

let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
let trimmed = input.trim();

if trimmed.is_empty() {
default.to_string()
} else {
trimmed.to_string()
}
}

pub fn run() -> i32 {
println!("fila setup\n");

if Path::new(".env").exists() {
print!(".env already exists. Overwrite? [y/N]: ");
io::stdout().flush().unwrap();
let mut answer = String::new();
io::stdin().read_line(&mut answer).unwrap();
if !answer.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return 0;
}
println!();
}

let app_id = prompt("GitHub App ID", "");
let key_path = prompt("Path to private key file (PEM)", "");
let webhook_secret = prompt("GitHub Webhook Secret", "");
let database_url = prompt("Database URL", "sqlite://fila.db?mode=rwc");
let merge_strategy = prompt("Merge strategy (batch/sequential)", "batch");
let batch_size = prompt("Batch size", "5");
let batch_interval = prompt("Batch interval (seconds)", "10");
let ci_timeout = prompt("CI timeout (seconds)", "1800");
let poll_interval = prompt("Poll interval (seconds)", "15");
let server_port = prompt("Server port", "8000");
let host = prompt("Host", "127.0.0.1");

// Read private key from file
let private_key = if key_path.is_empty() {
String::new()
} else {
match fs::read_to_string(&key_path) {
Ok(contents) => contents,
Err(e) => {
println!("Failed to read private key file: {e}");
return 1;
}
}
};

let mut env_content = String::new();

env_content.push_str(&format!("DATABASE_URL={database_url}\n"));
env_content.push_str(&format!("GITHUB_APP_ID={app_id}\n"));

if !private_key.is_empty() {
// Wrap in double quotes so dotenvy handles embedded newlines
env_content.push_str(&format!("GITHUB_PRIVATE_KEY=\"{}\"\n", private_key.trim()));
}

env_content.push_str(&format!("GITHUB_WEBHOOK_SECRET={webhook_secret}\n"));
env_content.push_str(&format!("MERGE_STRATEGY={merge_strategy}\n"));
env_content.push_str(&format!("BATCH_SIZE={batch_size}\n"));
env_content.push_str(&format!("BATCH_INTERVAL_SECS={batch_interval}\n"));
env_content.push_str(&format!("CI_TIMEOUT_SECS={ci_timeout}\n"));
env_content.push_str(&format!("POLL_INTERVAL_SECS={poll_interval}\n"));
env_content.push_str(&format!("SERVER_PORT={server_port}\n"));
env_content.push_str(&format!("HOST={host}\n"));

match fs::write(".env", &env_content) {
Ok(()) => {
println!("\n.env written successfully.");
println!("Run `fila doctor` to verify your configuration.");
0
}
Err(e) => {
println!("Failed to write .env: {e}");
1
}
}
}
32 changes: 32 additions & 0 deletions src/github/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,38 @@ impl GitHubClient {
Ok(())
}

/// Validate that the private key can produce a JWT.
pub fn validate_credentials(&self) -> Result<(), GitHubClientError> {
let _ = self.generate_jwt()?;
Ok(())
}

/// Fetch app info from GitHub using JWT auth (no installation token needed).
pub async fn get_app_info(&self) -> Result<GhAppInfo, GitHubClientError> {
let jwt = self.generate_jwt()?;

let resp = self
.client
.get(format!("{GITHUB_API}/app"))
.header(AUTHORIZATION, format!("Bearer {jwt}"))
.header(ACCEPT, "application/vnd.github+json")
.send()
.await
.map_err(|e| GitHubClientError::Http(e.to_string()))?;

if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(GitHubClientError::Api(format!(
"GET /app failed: {status} {body}"
)));
}

resp.json()
.await
.map_err(|e| GitHubClientError::Http(e.to_string()))
}

/// Check if all check runs for a commit have passed.
pub async fn all_checks_passed(
&self,
Expand Down
6 changes: 6 additions & 0 deletions src/github/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ pub struct GhMergeCommit {
pub sha: String,
}

#[derive(Debug, Deserialize)]
pub struct GhAppInfo {
pub name: String,
pub slug: String,
}

pub enum MergeResult {
Created(String),
AlreadyMerged,
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod batches;
pub mod cli;
pub mod config;
pub mod dashboard;
pub mod entity;
Expand Down
Loading