From a4415dd94cec8e7296183ce028cb278e826d0cac Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 11 Mar 2026 10:26:54 +0100 Subject: [PATCH] feat: add per-repository issue tracker config with Gitea support Add ability to configure issue tracker (GitHub, GitLab, Gitea, Jira) per repository at creation time and edit later via PATCH endpoint. Includes new Gitea tracker implementation, edit modal in dashboard, and tracker_token field on the repository model. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 2 + compliance-agent/src/api/handlers/mod.rs | 70 ++++++ compliance-agent/src/api/routes.rs | 2 +- compliance-agent/src/trackers/gitea.rs | 211 ++++++++++++++++++ compliance-agent/src/trackers/mod.rs | 1 + compliance-core/src/models/issue.rs | 2 + compliance-core/src/models/repository.rs | 4 + .../src/infrastructure/repositories.rs | 80 +++++++ .../src/pages/repositories.rs | 200 ++++++++++++++++- 9 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 compliance-agent/src/trackers/gitea.rs diff --git a/Cargo.lock b/Cargo.lock index 47d56d6..d3e3e28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -650,6 +650,7 @@ dependencies = [ "dioxus-logger 0.6.2", "dotenvy", "gloo-timers", + "js-sys", "mongodb", "rand 0.9.2", "reqwest", @@ -665,6 +666,7 @@ dependencies = [ "tracing", "url", "uuid", + "wasm-bindgen", "web-sys", ] diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs index 0dca8b3..dbf0e12 100644 --- a/compliance-agent/src/api/handlers/mod.rs +++ b/compliance-agent/src/api/handlers/mod.rs @@ -87,6 +87,20 @@ pub struct AddRepositoryRequest { pub tracker_type: Option, pub tracker_owner: Option, pub tracker_repo: Option, + pub tracker_token: Option, + pub scan_schedule: Option, +} + +#[derive(Deserialize)] +pub struct UpdateRepositoryRequest { + pub name: Option, + pub default_branch: Option, + pub auth_token: Option, + pub auth_username: Option, + pub tracker_type: Option, + pub tracker_owner: Option, + pub tracker_repo: Option, + pub tracker_token: Option, pub scan_schedule: Option, } @@ -318,6 +332,7 @@ pub async fn add_repository( repo.tracker_type = req.tracker_type; repo.tracker_owner = req.tracker_owner; repo.tracker_repo = req.tracker_repo; + repo.tracker_token = req.tracker_token; repo.scan_schedule = req.scan_schedule; agent @@ -339,6 +354,61 @@ pub async fn add_repository( })) } +#[tracing::instrument(skip_all, fields(repo_id = %id))] +pub async fn update_repository( + Extension(agent): AgentExt, + Path(id): Path, + Json(req): Json, +) -> Result, StatusCode> { + let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; + + let mut set_doc = doc! { "updated_at": mongodb::bson::DateTime::now() }; + + if let Some(name) = &req.name { + set_doc.insert("name", name); + } + if let Some(branch) = &req.default_branch { + set_doc.insert("default_branch", branch); + } + if let Some(token) = &req.auth_token { + set_doc.insert("auth_token", token); + } + if let Some(username) = &req.auth_username { + set_doc.insert("auth_username", username); + } + if let Some(tracker_type) = &req.tracker_type { + set_doc.insert("tracker_type", tracker_type.to_string()); + } + if let Some(owner) = &req.tracker_owner { + set_doc.insert("tracker_owner", owner); + } + if let Some(repo) = &req.tracker_repo { + set_doc.insert("tracker_repo", repo); + } + if let Some(token) = &req.tracker_token { + set_doc.insert("tracker_token", token); + } + if let Some(schedule) = &req.scan_schedule { + set_doc.insert("scan_schedule", schedule); + } + + let result = agent + .db + .repositories() + .update_one(doc! { "_id": oid }, doc! { "$set": set_doc }) + .await + .map_err(|e| { + tracing::warn!("Failed to update repository: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if result.matched_count == 0 { + return Err(StatusCode::NOT_FOUND); + } + + Ok(Json(serde_json::json!({ "status": "updated" }))) +} + #[tracing::instrument(skip_all)] pub async fn get_ssh_public_key( Extension(agent): AgentExt, diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index bf6877a..808b5fd 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -19,7 +19,7 @@ pub fn build_router() -> Router { ) .route( "/api/v1/repositories/{id}", - delete(handlers::delete_repository), + delete(handlers::delete_repository).patch(handlers::update_repository), ) .route("/api/v1/findings", get(handlers::list_findings)) .route("/api/v1/findings/{id}", get(handlers::get_finding)) diff --git a/compliance-agent/src/trackers/gitea.rs b/compliance-agent/src/trackers/gitea.rs new file mode 100644 index 0000000..1d69a14 --- /dev/null +++ b/compliance-agent/src/trackers/gitea.rs @@ -0,0 +1,211 @@ +use compliance_core::error::CoreError; +use compliance_core::models::{TrackerIssue, TrackerType}; +use compliance_core::traits::issue_tracker::{IssueTracker, ReviewComment}; +use secrecy::{ExposeSecret, SecretString}; + +pub struct GiteaTracker { + base_url: String, + http: reqwest::Client, + token: SecretString, +} + +impl GiteaTracker { + pub fn new(base_url: String, token: SecretString) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + http: reqwest::Client::new(), + token, + } + } + + fn api_url(&self, path: &str) -> String { + format!("{}/api/v1{}", self.base_url, path) + } +} + +impl IssueTracker for GiteaTracker { + fn name(&self) -> &str { + "gitea" + } + + async fn create_issue( + &self, + owner: &str, + repo: &str, + title: &str, + body: &str, + labels: &[String], + ) -> Result { + let url = self.api_url(&format!("/repos/{owner}/{repo}/issues")); + + let mut payload = serde_json::json!({ + "title": title, + "body": body, + }); + if !labels.is_empty() { + // Gitea expects label IDs, but we can pass label names via the API + // For simplicity, we add labels as part of the body if they can't be resolved + payload["labels"] = serde_json::json!(labels); + } + + let resp = self + .http + .post(&url) + .header( + "Authorization", + format!("token {}", self.token.expose_secret()), + ) + .json(&payload) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Gitea create issue failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(CoreError::IssueTracker(format!( + "Gitea returned {status}: {text}" + ))); + } + + let issue: serde_json::Value = resp + .json() + .await + .map_err(|e| CoreError::IssueTracker(format!("Failed to parse Gitea response: {e}")))?; + + Ok(TrackerIssue::new( + String::new(), + TrackerType::Gitea, + issue["number"].to_string(), + issue["html_url"].as_str().unwrap_or("").to_string(), + title.to_string(), + )) + } + + async fn update_issue_status( + &self, + owner: &str, + repo: &str, + external_id: &str, + status: &str, + ) -> Result<(), CoreError> { + let url = self.api_url(&format!("/repos/{owner}/{repo}/issues/{external_id}")); + + let state = match status { + "closed" | "resolved" => "closed", + _ => "open", + }; + + self.http + .patch(&url) + .header( + "Authorization", + format!("token {}", self.token.expose_secret()), + ) + .json(&serde_json::json!({ "state": state })) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Gitea update issue failed: {e}")))?; + + Ok(()) + } + + async fn add_comment( + &self, + owner: &str, + repo: &str, + external_id: &str, + body: &str, + ) -> Result<(), CoreError> { + let url = self.api_url(&format!( + "/repos/{owner}/{repo}/issues/{external_id}/comments" + )); + + self.http + .post(&url) + .header( + "Authorization", + format!("token {}", self.token.expose_secret()), + ) + .json(&serde_json::json!({ "body": body })) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Gitea add comment failed: {e}")))?; + + Ok(()) + } + + async fn create_pr_review( + &self, + owner: &str, + repo: &str, + pr_number: u64, + body: &str, + comments: Vec, + ) -> Result<(), CoreError> { + let url = self.api_url(&format!("/repos/{owner}/{repo}/pulls/{pr_number}/reviews")); + + let review_comments: Vec = comments + .iter() + .map(|c| { + serde_json::json!({ + "path": c.path, + "new_position": c.line, + "body": c.body, + }) + }) + .collect(); + + self.http + .post(&url) + .header( + "Authorization", + format!("token {}", self.token.expose_secret()), + ) + .json(&serde_json::json!({ + "body": body, + "event": "COMMENT", + "comments": review_comments, + })) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Gitea PR review failed: {e}")))?; + + Ok(()) + } + + async fn find_existing_issue( + &self, + owner: &str, + repo: &str, + fingerprint: &str, + ) -> Result, CoreError> { + let url = self.api_url(&format!( + "/repos/{owner}/{repo}/issues?type=issues&state=open&q={fingerprint}" + )); + + let resp = self + .http + .get(&url) + .header( + "Authorization", + format!("token {}", self.token.expose_secret()), + ) + .send() + .await + .map_err(|e| CoreError::IssueTracker(format!("Gitea search failed: {e}")))?; + + let issues: Vec = resp.json().await.unwrap_or_default(); + if let Some(issue) = issues.first() { + Ok(Some(TrackerIssue::new( + String::new(), + TrackerType::Gitea, + issue["number"].to_string(), + issue["html_url"].as_str().unwrap_or("").to_string(), + issue["title"].as_str().unwrap_or("").to_string(), + ))) + } else { + Ok(None) + } + } +} diff --git a/compliance-agent/src/trackers/mod.rs b/compliance-agent/src/trackers/mod.rs index b4abd57..d9bda8a 100644 --- a/compliance-agent/src/trackers/mod.rs +++ b/compliance-agent/src/trackers/mod.rs @@ -1,3 +1,4 @@ +pub mod gitea; pub mod github; pub mod gitlab; pub mod jira; diff --git a/compliance-core/src/models/issue.rs b/compliance-core/src/models/issue.rs index 3c7d670..8f1448a 100644 --- a/compliance-core/src/models/issue.rs +++ b/compliance-core/src/models/issue.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; pub enum TrackerType { GitHub, GitLab, + Gitea, Jira, } @@ -14,6 +15,7 @@ impl std::fmt::Display for TrackerType { match self { Self::GitHub => write!(f, "github"), Self::GitLab => write!(f, "gitlab"), + Self::Gitea => write!(f, "gitea"), Self::Jira => write!(f, "jira"), } } diff --git a/compliance-core/src/models/repository.rs b/compliance-core/src/models/repository.rs index 96fddf8..5fab4d0 100644 --- a/compliance-core/src/models/repository.rs +++ b/compliance-core/src/models/repository.rs @@ -28,6 +28,9 @@ pub struct TrackedRepository { pub tracker_type: Option, pub tracker_owner: Option, pub tracker_repo: Option, + /// Optional per-repo PAT for the issue tracker (GitHub/GitLab/Jira) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tracker_token: Option, /// Optional auth token for HTTPS private repos (PAT or password) #[serde(default, skip_serializing_if = "Option::is_none")] pub auth_token: Option, @@ -82,6 +85,7 @@ impl TrackedRepository { tracker_type: None, tracker_owner: None, tracker_repo: None, + tracker_token: None, last_scanned_commit: None, findings_count: 0, created_at: now, diff --git a/compliance-dashboard/src/infrastructure/repositories.rs b/compliance-dashboard/src/infrastructure/repositories.rs index 6f55ae6..af68bb9 100644 --- a/compliance-dashboard/src/infrastructure/repositories.rs +++ b/compliance-dashboard/src/infrastructure/repositories.rs @@ -36,6 +36,10 @@ pub async fn add_repository( default_branch: String, auth_token: Option, auth_username: Option, + tracker_type: Option, + tracker_owner: Option, + tracker_repo: Option, + tracker_token: Option, ) -> 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, + default_branch: Option, + auth_token: Option, + auth_username: Option, + tracker_type: Option, + tracker_owner: Option, + tracker_repo: Option, + tracker_token: Option, + scan_schedule: Option, +) -> 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 { let state: super::server_state::ServerState = diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs index 6294869..7748e7e 100644 --- a/compliance-dashboard/src/pages/repositories.rs +++ b/compliance-dashboard/src/pages/repositories.rs @@ -1,5 +1,7 @@ use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::*; +#[allow(unused_imports)] +use dioxus_free_icons::icons::bs_icons::{BsGear, BsPencil}; use dioxus_free_icons::Icon; use crate::components::page_header::PageHeader; @@ -29,9 +31,22 @@ pub fn RepositoriesPage() -> Element { let mut auth_username = use_signal(String::new); let mut show_auth = use_signal(|| false); let mut ssh_public_key = use_signal(String::new); + let mut show_tracker = use_signal(|| false); + let mut tracker_type_val = use_signal(String::new); + let mut tracker_owner_val = use_signal(String::new); + let mut tracker_repo_val = use_signal(String::new); + let mut tracker_token_val = use_signal(String::new); let mut adding = use_signal(|| false); let mut toasts = use_context::(); let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name) + let mut edit_repo_id = use_signal(|| Option::::None); + let mut edit_name = use_signal(String::new); + let mut edit_branch = use_signal(String::new); + let mut edit_tracker_type = use_signal(String::new); + let mut edit_tracker_owner = use_signal(String::new); + let mut edit_tracker_repo = use_signal(String::new); + let mut edit_tracker_token = use_signal(String::new); + let mut edit_saving = use_signal(|| false); let mut scanning_ids = use_signal(Vec::::new); let mut graph_repo_id = use_signal(|| Option::::None); @@ -154,6 +169,63 @@ pub fn RepositoriesPage() -> Element { } } + // Issue tracker config section + div { style: "margin-top: 8px;", + button { + class: "btn btn-ghost", + style: "font-size: 12px; padding: 4px 8px;", + onclick: move |_| show_tracker.toggle(), + if show_tracker() { "Hide tracker options" } else { "Issue tracker?" } + } + } + + if show_tracker() { + div { class: "auth-section", style: "margin-top: 12px; padding: 12px; border: 1px solid var(--border-subtle); border-radius: 8px;", + p { style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;", + "Configure an issue tracker to auto-create issues from findings" + } + div { class: "form-group", + label { "Tracker Type" } + select { + value: "{tracker_type_val}", + onchange: move |e| tracker_type_val.set(e.value()), + option { value: "", "None" } + option { value: "github", "GitHub" } + option { value: "gitlab", "GitLab" } + option { value: "gitea", "Gitea" } + option { value: "jira", "Jira" } + } + } + div { class: "form-group", + label { "Owner / Namespace" } + input { + r#type: "text", + placeholder: "org-name", + value: "{tracker_owner_val}", + oninput: move |e| tracker_owner_val.set(e.value()), + } + } + div { class: "form-group", + label { "Repository / Project" } + input { + r#type: "text", + placeholder: "repo-name", + value: "{tracker_repo_val}", + oninput: move |e| tracker_repo_val.set(e.value()), + } + } + div { class: "form-group", + label { "Tracker Token (PAT)" } + input { + r#type: "password", + placeholder: "ghp_xxxx / glpat-xxxx", + value: "{tracker_token_val}", + oninput: move |e| tracker_token_val.set(e.value()), + } + } + } + } + button { class: "btn btn-primary", disabled: adding(), @@ -169,9 +241,13 @@ pub fn RepositoriesPage() -> Element { let v = auth_username(); if v.is_empty() { None } else { Some(v) } }; + let tt = { let v = tracker_type_val(); if v.is_empty() { None } else { Some(v) } }; + let t_owner = { let v = tracker_owner_val(); if v.is_empty() { None } else { Some(v) } }; + let t_repo = { let v = tracker_repo_val(); if v.is_empty() { None } else { Some(v) } }; + let t_tok = { let v = tracker_token_val(); if v.is_empty() { None } else { Some(v) } }; adding.set(true); spawn(async move { - match crate::infrastructure::repositories::add_repository(n, u, b, tok, usr).await { + match crate::infrastructure::repositories::add_repository(n, u, b, tok, usr, tt, t_owner, t_repo, t_tok).await { Ok(_) => { toasts.push(ToastType::Success, "Repository added"); repos.restart(); @@ -182,10 +258,15 @@ pub fn RepositoriesPage() -> Element { }); show_add_form.set(false); show_auth.set(false); + show_tracker.set(false); name.set(String::new()); git_url.set(String::new()); auth_token.set(String::new()); auth_username.set(String::new()); + tracker_type_val.set(String::new()); + tracker_owner_val.set(String::new()); + tracker_repo_val.set(String::new()); + tracker_token_val.set(String::new()); }, if adding() { "Validating..." } else { "Add" } } @@ -234,6 +315,105 @@ pub fn RepositoriesPage() -> Element { } } + // ── Edit repository dialog ── + if let Some(eid) = edit_repo_id() { + div { class: "modal-overlay", + div { class: "modal-dialog", + h3 { "Edit Repository" } + div { class: "form-group", + label { "Name" } + input { + r#type: "text", + value: "{edit_name}", + oninput: move |e| edit_name.set(e.value()), + } + } + div { class: "form-group", + label { "Default Branch" } + input { + r#type: "text", + value: "{edit_branch}", + oninput: move |e| edit_branch.set(e.value()), + } + } + h4 { style: "margin-top: 16px; margin-bottom: 8px; font-size: 14px; color: var(--text-secondary);", "Issue Tracker" } + div { class: "form-group", + label { "Tracker Type" } + select { + value: "{edit_tracker_type}", + onchange: move |e| edit_tracker_type.set(e.value()), + option { value: "", "None" } + option { value: "github", "GitHub" } + option { value: "gitlab", "GitLab" } + option { value: "jira", "Jira" } + } + } + div { class: "form-group", + label { "Owner / Namespace" } + input { + r#type: "text", + placeholder: "org-name", + value: "{edit_tracker_owner}", + oninput: move |e| edit_tracker_owner.set(e.value()), + } + } + div { class: "form-group", + label { "Repository / Project" } + input { + r#type: "text", + placeholder: "repo-name", + value: "{edit_tracker_repo}", + oninput: move |e| edit_tracker_repo.set(e.value()), + } + } + div { class: "form-group", + label { "Tracker Token (leave empty to keep existing)" } + input { + r#type: "password", + placeholder: "Enter new token to change", + value: "{edit_tracker_token}", + oninput: move |e| edit_tracker_token.set(e.value()), + } + } + div { class: "modal-actions", + button { + class: "btn btn-secondary", + onclick: move |_| edit_repo_id.set(None), + "Cancel" + } + button { + class: "btn btn-primary", + disabled: edit_saving(), + onclick: move |_| { + let id = eid.clone(); + let nm = { let v = edit_name(); if v.is_empty() { None } else { Some(v) } }; + let br = { let v = edit_branch(); if v.is_empty() { None } else { Some(v) } }; + let tt = { let v = edit_tracker_type(); if v.is_empty() { None } else { Some(v) } }; + let t_owner = { let v = edit_tracker_owner(); if v.is_empty() { None } else { Some(v) } }; + let t_repo = { let v = edit_tracker_repo(); if v.is_empty() { None } else { Some(v) } }; + let t_tok = { let v = edit_tracker_token(); if v.is_empty() { None } else { Some(v) } }; + edit_saving.set(true); + spawn(async move { + match crate::infrastructure::repositories::update_repository( + id, nm, br, None, None, tt, t_owner, t_repo, t_tok, None, + ).await { + Ok(_) => { + toasts.push(ToastType::Success, "Repository updated"); + repos.restart(); + } + Err(e) => toasts.push(ToastType::Error, e.to_string()), + } + edit_saving.set(false); + edit_repo_id.set(None); + }); + }, + if edit_saving() { "Saving..." } else { "Save" } + } + } + } + } + } + match &*repos.read() { Some(Some(resp)) => { let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1); @@ -257,7 +437,9 @@ pub fn RepositoriesPage() -> Element { let repo_id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); let repo_id_scan = repo_id.clone(); let repo_id_del = repo_id.clone(); + let repo_id_edit = repo_id.clone(); let repo_name_del = repo.name.clone(); + let edit_repo_data = repo.clone(); let is_scanning = scanning_ids().contains(&repo_id); rsx! { tr { @@ -302,6 +484,22 @@ pub fn RepositoriesPage() -> Element { }, Icon { icon: BsDiagram3, width: 16, height: 16 } } + button { + class: "btn btn-ghost", + title: "Edit repository", + onclick: move |_| { + edit_name.set(edit_repo_data.name.clone()); + edit_branch.set(edit_repo_data.default_branch.clone()); + edit_tracker_type.set( + edit_repo_data.tracker_type.as_ref().map(|t| t.to_string()).unwrap_or_default() + ); + edit_tracker_owner.set(edit_repo_data.tracker_owner.clone().unwrap_or_default()); + edit_tracker_repo.set(edit_repo_data.tracker_repo.clone().unwrap_or_default()); + edit_tracker_token.set(String::new()); + edit_repo_id.set(Some(repo_id_edit.clone())); + }, + Icon { icon: BsPencil, width: 16, height: 16 } + } button { class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" }, title: "Trigger scan",