feat: per-repo issue tracker, Gitea support, PR review pipeline (#10)
Some checks failed
CI / Security Audit (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Successful in 4s

This commit was merged in pull request #10.
This commit is contained in:
2026-03-11 12:13:59 +00:00
parent be4b43ed64
commit 491665559f
22 changed files with 1582 additions and 122 deletions

View File

@@ -7,6 +7,7 @@ pub mod findings;
pub mod graph;
pub mod issues;
pub mod mcp;
#[allow(clippy::too_many_arguments)]
pub mod repositories;
pub mod sbom;
pub mod scans;

View File

@@ -36,6 +36,10 @@ pub async fn add_repository(
default_branch: String,
auth_token: Option<String>,
auth_username: Option<String>,
tracker_type: Option<String>,
tracker_owner: Option<String>,
tracker_repo: Option<String>,
tracker_token: Option<String>,
) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
@@ -52,6 +56,18 @@ pub async fn add_repository(
if let Some(username) = auth_username.filter(|u| !u.is_empty()) {
body["auth_username"] = serde_json::Value::String(username);
}
if let Some(tt) = tracker_type.filter(|t| !t.is_empty()) {
body["tracker_type"] = serde_json::Value::String(tt);
}
if let Some(to) = tracker_owner.filter(|t| !t.is_empty()) {
body["tracker_owner"] = serde_json::Value::String(to);
}
if let Some(tr) = tracker_repo.filter(|t| !t.is_empty()) {
body["tracker_repo"] = serde_json::Value::String(tr);
}
if let Some(tk) = tracker_token.filter(|t| !t.is_empty()) {
body["tracker_token"] = serde_json::Value::String(tk);
}
let client = reqwest::Client::new();
let resp = client
@@ -71,6 +87,70 @@ pub async fn add_repository(
Ok(())
}
#[server]
pub async fn update_repository(
repo_id: String,
name: Option<String>,
default_branch: Option<String>,
auth_token: Option<String>,
auth_username: Option<String>,
tracker_type: Option<String>,
tracker_owner: Option<String>,
tracker_repo: Option<String>,
tracker_token: Option<String>,
scan_schedule: Option<String>,
) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/repositories/{repo_id}", state.agent_api_url);
let mut body = serde_json::Map::new();
if let Some(v) = name.filter(|s| !s.is_empty()) {
body.insert("name".into(), serde_json::Value::String(v));
}
if let Some(v) = default_branch.filter(|s| !s.is_empty()) {
body.insert("default_branch".into(), serde_json::Value::String(v));
}
if let Some(v) = auth_token {
body.insert("auth_token".into(), serde_json::Value::String(v));
}
if let Some(v) = auth_username {
body.insert("auth_username".into(), serde_json::Value::String(v));
}
if let Some(v) = tracker_type {
body.insert("tracker_type".into(), serde_json::Value::String(v));
}
if let Some(v) = tracker_owner {
body.insert("tracker_owner".into(), serde_json::Value::String(v));
}
if let Some(v) = tracker_repo {
body.insert("tracker_repo".into(), serde_json::Value::String(v));
}
if let Some(v) = tracker_token {
body.insert("tracker_token".into(), serde_json::Value::String(v));
}
if let Some(v) = scan_schedule {
body.insert("scan_schedule".into(), serde_json::Value::String(v));
}
let client = reqwest::Client::new();
let resp = client
.patch(&url)
.json(&body)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!(
"Failed to update repository: {text}"
)));
}
Ok(())
}
#[server]
pub async fn fetch_ssh_public_key() -> Result<String, ServerFnError> {
let state: super::server_state::ServerState =
@@ -136,6 +216,31 @@ pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WebhookConfigResponse {
pub webhook_secret: Option<String>,
pub tracker_type: String,
}
#[server]
pub async fn fetch_webhook_config(repo_id: String) -> Result<WebhookConfigResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!(
"{}/api/v1/repositories/{repo_id}/webhook-config",
state.agent_api_url
);
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: WebhookConfigResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}
/// Check if a repository has any running scans
#[server]
pub async fn check_repo_scanning(repo_id: String) -> Result<bool, ServerFnError> {

View File

@@ -1,4 +1,4 @@
use axum::routing::get;
use axum::routing::{get, post};
use axum::{middleware, Extension};
use dioxus::prelude::*;
use time::Duration;
@@ -63,6 +63,8 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
.route("/auth", get(auth_login))
.route("/auth/callback", get(auth_callback))
.route("/logout", get(logout))
// Webhook proxy: forward to agent (no auth required)
.route("/webhook/{platform}/{repo_id}", post(webhook_proxy))
.serve_dioxus_application(ServeConfig::new(), app)
.layer(Extension(PendingOAuthStore::default()))
.layer(middleware::from_fn(require_auth))
@@ -77,6 +79,53 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
})
}
/// Forward incoming webhooks to the agent's webhook server.
/// The dashboard acts as a public-facing proxy so the agent isn't exposed directly.
async fn webhook_proxy(
Extension(state): Extension<ServerState>,
axum::extract::Path((platform, repo_id)): axum::extract::Path<(String, String)>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> axum::http::StatusCode {
// The agent_api_url typically looks like "http://agent:3001" or "http://localhost:3001"
// Webhook routes are on the same server, so strip any trailing path
let base = state.agent_api_url.trim_end_matches('/');
// Remove /api/v1 suffix if present to get base URL
let base = base
.strip_suffix("/api/v1")
.or_else(|| base.strip_suffix("/api"))
.unwrap_or(base);
let agent_url = format!("{base}/webhook/{platform}/{repo_id}");
// Forward all relevant headers
let client = reqwest::Client::new();
let mut req = client.post(&agent_url).body(body.to_vec());
for (name, value) in &headers {
let name_str = name.as_str().to_lowercase();
// Forward platform-specific headers
if name_str.starts_with("x-gitea-")
|| name_str.starts_with("x-github-")
|| name_str.starts_with("x-hub-")
|| name_str.starts_with("x-gitlab-")
|| name_str == "content-type"
{
if let Ok(v) = value.to_str() {
req = req.header(name.as_str(), v);
}
}
}
match req.send().await {
Ok(resp) => axum::http::StatusCode::from_u16(resp.status().as_u16())
.unwrap_or(axum::http::StatusCode::BAD_GATEWAY),
Err(e) => {
tracing::error!("Webhook proxy failed: {e}");
axum::http::StatusCode::BAD_GATEWAY
}
}
}
/// Seed three default MCP server configs (Findings, SBOM, DAST) if they don't already exist.
async fn seed_default_mcp_servers(db: &Database, mcp_endpoint_url: Option<&str>) {
let endpoint = mcp_endpoint_url.unwrap_or("http://localhost:8090");