feat: add per-repository issue tracker config with Gitea support
Some checks failed
CI / Clippy (push) Failing after 3m12s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Format (push) Successful in 5s
Some checks failed
CI / Clippy (push) Failing after 3m12s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Format (push) Successful in 5s
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 <noreply@anthropic.com>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -87,6 +87,20 @@ pub struct AddRepositoryRequest {
|
||||
pub tracker_type: Option<TrackerType>,
|
||||
pub tracker_owner: Option<String>,
|
||||
pub tracker_repo: Option<String>,
|
||||
pub tracker_token: Option<String>,
|
||||
pub scan_schedule: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateRepositoryRequest {
|
||||
pub name: Option<String>,
|
||||
pub default_branch: Option<String>,
|
||||
pub auth_token: Option<String>,
|
||||
pub auth_username: Option<String>,
|
||||
pub tracker_type: Option<TrackerType>,
|
||||
pub tracker_owner: Option<String>,
|
||||
pub tracker_repo: Option<String>,
|
||||
pub tracker_token: Option<String>,
|
||||
pub scan_schedule: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
Json(req): Json<UpdateRepositoryRequest>,
|
||||
) -> Result<Json<serde_json::Value>, 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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
211
compliance-agent/src/trackers/gitea.rs
Normal file
211
compliance-agent/src/trackers/gitea.rs
Normal file
@@ -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<TrackerIssue, CoreError> {
|
||||
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<ReviewComment>,
|
||||
) -> Result<(), CoreError> {
|
||||
let url = self.api_url(&format!("/repos/{owner}/{repo}/pulls/{pr_number}/reviews"));
|
||||
|
||||
let review_comments: Vec<serde_json::Value> = 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<Option<TrackerIssue>, 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<serde_json::Value> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod gitea;
|
||||
pub mod github;
|
||||
pub mod gitlab;
|
||||
pub mod jira;
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ pub struct TrackedRepository {
|
||||
pub tracker_type: Option<TrackerType>,
|
||||
pub tracker_owner: Option<String>,
|
||||
pub tracker_repo: Option<String>,
|
||||
/// Optional per-repo PAT for the issue tracker (GitHub/GitLab/Jira)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tracker_token: Option<String>,
|
||||
/// Optional auth token for HTTPS private repos (PAT or password)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_token: Option<String>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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::<Toasts>();
|
||||
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
|
||||
let mut edit_repo_id = use_signal(|| Option::<String>::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::<String>::new);
|
||||
let mut graph_repo_id = use_signal(|| Option::<String>::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",
|
||||
|
||||
Reference in New Issue
Block a user