feat: per-repo issue tracker, Gitea support, PR review pipeline #10

Merged
sharang merged 7 commits from feat/per-repo-tracker-config into main 2026-03-11 12:14:00 +00:00
9 changed files with 570 additions and 2 deletions
Showing only changes of commit a4415dd94c - Show all commits

2
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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,

View File

@@ -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))

View 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)
}
}
}

View File

@@ -1,3 +1,4 @@
pub mod gitea;
pub mod github;
pub mod gitlab;
pub mod jira;

View File

@@ -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"),
}
}

View File

@@ -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,

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 =

View File

@@ -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",