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
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:
@@ -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,24 @@ 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 edit_webhook_secret = use_signal(|| Option::<String>::None);
|
||||
let mut edit_webhook_tracker = use_signal(String::new);
|
||||
let mut scanning_ids = use_signal(Vec::<String>::new);
|
||||
let mut graph_repo_id = use_signal(|| Option::<String>::None);
|
||||
|
||||
@@ -154,6 +171,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 +243,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 +260,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 +317,139 @@ 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: "gitea", "Gitea" }
|
||||
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()),
|
||||
}
|
||||
}
|
||||
// Webhook configuration section
|
||||
if let Some(secret) = edit_webhook_secret() {
|
||||
h4 {
|
||||
style: "margin-top: 16px; margin-bottom: 8px; font-size: 14px; color: var(--text-secondary);",
|
||||
"Webhook Configuration"
|
||||
}
|
||||
p {
|
||||
style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;",
|
||||
"Add this webhook in your repository settings to enable push-triggered scans and PR reviews."
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Webhook URL" }
|
||||
input {
|
||||
r#type: "text",
|
||||
readonly: true,
|
||||
style: "font-family: monospace; font-size: 12px;",
|
||||
value: format!("/webhook/{}/{eid}", edit_webhook_tracker()),
|
||||
}
|
||||
p {
|
||||
style: "font-size: 11px; color: var(--text-secondary); margin-top: 4px;",
|
||||
"Use the full dashboard URL as the base, e.g. https://your-domain.com/webhook/..."
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Webhook Secret" }
|
||||
input {
|
||||
r#type: "text",
|
||||
readonly: true,
|
||||
style: "font-family: monospace; font-size: 12px;",
|
||||
value: "{secret}",
|
||||
}
|
||||
}
|
||||
}
|
||||
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 +473,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 +520,32 @@ 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_webhook_secret.set(None);
|
||||
edit_webhook_tracker.set(String::new());
|
||||
edit_repo_id.set(Some(repo_id_edit.clone()));
|
||||
// Fetch webhook config in background
|
||||
let rid = repo_id_edit.clone();
|
||||
spawn(async move {
|
||||
if let Ok(cfg) = crate::infrastructure::repositories::fetch_webhook_config(rid).await {
|
||||
edit_webhook_secret.set(cfg.webhook_secret);
|
||||
edit_webhook_tracker.set(cfg.tracker_type);
|
||||
}
|
||||
});
|
||||
},
|
||||
Icon { icon: BsPencil, width: 16, height: 16 }
|
||||
}
|
||||
button {
|
||||
class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" },
|
||||
title: "Trigger scan",
|
||||
|
||||
@@ -677,23 +677,32 @@ fn license_type_class(is_copyleft: bool) -> &'static str {
|
||||
#[cfg(feature = "web")]
|
||||
fn trigger_download(content: &str, filename: &str) {
|
||||
use wasm_bindgen::JsCast;
|
||||
let window = web_sys::window().expect("no window");
|
||||
let document = window.document().expect("no document");
|
||||
let Some(window) = web_sys::window() else {
|
||||
return;
|
||||
};
|
||||
let Some(document) = window.document() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let blob_parts = js_sys::Array::new();
|
||||
blob_parts.push(&wasm_bindgen::JsValue::from_str(content));
|
||||
|
||||
let mut opts = web_sys::BlobPropertyBag::new();
|
||||
opts.type_("application/json");
|
||||
let blob = web_sys::Blob::new_with_str_sequence_and_options(&blob_parts, &opts).expect("blob");
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type("application/json");
|
||||
let Ok(blob) = web_sys::Blob::new_with_str_sequence_and_options(&blob_parts, &opts) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).expect("object url");
|
||||
let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let a: web_sys::HtmlAnchorElement = document
|
||||
.create_element("a")
|
||||
.expect("create a")
|
||||
.dyn_into()
|
||||
.expect("cast");
|
||||
let Ok(el) = document.create_element("a") else {
|
||||
return;
|
||||
};
|
||||
let Ok(a) = el.dyn_into::<web_sys::HtmlAnchorElement>() else {
|
||||
return;
|
||||
};
|
||||
a.set_href(&url);
|
||||
a.set_download(filename);
|
||||
a.click();
|
||||
|
||||
Reference in New Issue
Block a user