Skip to content

Commit b5de3b5

Browse files
haasonsaasclaude
andcommitted
Add GitHub App auth with OAuth device flow, webhooks, and check runs
- Add GitHub App config: app_id, client_id, client_secret, private_key, webhook_secret - Implement OAuth device flow (POST /api/gh/auth/device, /poll, DELETE) so users click "Connect with GitHub" instead of pasting PATs - Add webhook endpoint (POST /api/webhooks/github) with HMAC-SHA256 signature verification — auto-starts PR review on opened/synchronize - Add GitHub Check Runs — posts pass/fail status with annotations directly on the PR commit after review completes - Add installation token support for GitHub App identity (JWT + RS256) - Frontend: two-tab setup (GitHub App vs PAT fallback), device flow UI with code display + copy + polling, webhook status indicator - Consolidate secret masking into mask_config_secrets() helper - Simplify update_config masked field skipping (any "***" value skipped) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1a12ae6 commit b5de3b5

File tree

9 files changed

+1351
-196
lines changed

9 files changed

+1351
-196
lines changed

Cargo.lock

Lines changed: 122 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ tower-http = { version = "0.6", features = ["cors", "fs"] }
3636
rust-embed = "8"
3737
uuid = { version = "1", features = ["v4"] }
3838
mime_guess = "2"
39+
hmac = "0.12"
40+
sha2 = "0.10"
41+
jsonwebtoken = "9"
42+
base64 = "0.22"
3943

4044
[dev-dependencies]
4145
tempfile = "3.8"

src/config.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,26 @@ pub struct Config {
201201

202202
#[serde(default)]
203203
pub github_token: Option<String>,
204+
205+
/// GitHub App ID (from app settings page).
206+
#[serde(default)]
207+
pub github_app_id: Option<u64>,
208+
209+
/// GitHub App OAuth client ID (for device flow auth).
210+
#[serde(default)]
211+
pub github_client_id: Option<String>,
212+
213+
/// GitHub App OAuth client secret.
214+
#[serde(default)]
215+
pub github_client_secret: Option<String>,
216+
217+
/// GitHub App private key (PEM content).
218+
#[serde(default)]
219+
pub github_private_key: Option<String>,
220+
221+
/// Webhook secret for verifying GitHub webhook signatures.
222+
#[serde(default)]
223+
pub github_webhook_secret: Option<String>,
204224
}
205225

206226
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -327,6 +347,11 @@ impl Default for Config {
327347
rule_priority: Vec::new(),
328348
providers: HashMap::new(),
329349
github_token: None,
350+
github_app_id: None,
351+
github_client_id: None,
352+
github_client_secret: None,
353+
github_private_key: None,
354+
github_webhook_secret: None,
330355
}
331356
}
332357
}

src/server/api.rs

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ async fn run_review_task(
428428

429429
/// Build a wide event from review results.
430430
#[allow(clippy::too_many_arguments)]
431-
fn build_review_event(
431+
pub(super) fn build_review_event(
432432
review_id: &str,
433433
event_type: &str,
434434
diff_source: &str,
@@ -483,7 +483,7 @@ fn build_review_event(
483483
}
484484

485485
/// Emit a review wide event via structured tracing.
486-
fn emit_wide_event(event: &ReviewEvent) {
486+
pub(super) fn emit_wide_event(event: &ReviewEvent) {
487487
info!(
488488
review_id = %event.review_id,
489489
event_type = %event.event_type,
@@ -685,13 +685,7 @@ pub async fn get_config(State(state): State<Arc<AppState>>) -> Json<serde_json::
685685
let config = state.config.read().await;
686686
let mut value = serde_json::to_value(&*config).unwrap_or_default();
687687
if let Some(obj) = value.as_object_mut() {
688-
if obj.contains_key("api_key") {
689-
obj.insert("api_key".to_string(), serde_json::json!("***"));
690-
}
691-
if obj.contains_key("github_token") {
692-
obj.insert("github_token".to_string(), serde_json::json!("***"));
693-
}
694-
mask_provider_api_keys(obj);
688+
mask_config_secrets(obj);
695689
}
696690
Json(value)
697691
}
@@ -705,10 +699,8 @@ pub async fn update_config(
705699
let mut current = serde_json::to_value(&*config).unwrap_or_default();
706700
if let (Some(current_obj), Some(updates_obj)) = (current.as_object_mut(), updates.as_object()) {
707701
for (key, value) in updates_obj {
708-
if key == "api_key" && value.as_str() == Some("***") {
709-
continue;
710-
}
711-
if key == "github_token" && value.as_str() == Some("***") {
702+
// Skip masked secret fields (don't overwrite with "***")
703+
if value.as_str() == Some("***") {
712704
continue;
713705
}
714706
current_obj.insert(key.clone(), value.clone());
@@ -724,13 +716,7 @@ pub async fn update_config(
724716
// Build response while still holding the write lock
725717
let mut result = serde_json::to_value(&*config).unwrap_or_default();
726718
if let Some(obj) = result.as_object_mut() {
727-
if obj.contains_key("api_key") {
728-
obj.insert("api_key".to_string(), serde_json::json!("***"));
729-
}
730-
if obj.contains_key("github_token") {
731-
obj.insert("github_token".to_string(), serde_json::json!("***"));
732-
}
733-
mask_provider_api_keys(obj);
719+
mask_config_secrets(obj);
734720
}
735721

736722
drop(config);
@@ -741,6 +727,16 @@ pub async fn update_config(
741727
Ok(Json(result))
742728
}
743729

730+
/// Mask all secret fields in a config object for safe serialization.
731+
fn mask_config_secrets(obj: &mut serde_json::Map<String, serde_json::Value>) {
732+
for key in &["api_key", "github_token", "github_client_secret", "github_private_key", "github_webhook_secret"] {
733+
if obj.get(*key).and_then(|v| v.as_str()).is_some() {
734+
obj.insert(key.to_string(), serde_json::json!("***"));
735+
}
736+
}
737+
mask_provider_api_keys(obj);
738+
}
739+
744740
/// Mask api_key fields inside the providers map for safe serialization.
745741
fn mask_provider_api_keys(obj: &mut serde_json::Map<String, serde_json::Value>) {
746742
if let Some(serde_json::Value::Object(providers)) = obj.get_mut("providers") {
@@ -1803,7 +1799,7 @@ async fn run_pr_review_task(
18031799
AppState::prune_old_reviews(&state).await;
18041800
}
18051801

1806-
async fn post_pr_review_comments(
1802+
pub(super) async fn post_pr_review_comments(
18071803
client: &reqwest::Client,
18081804
token: &str,
18091805
repo: &str,

0 commit comments

Comments
 (0)