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

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:
Sharang Parnerkar
2026-03-11 10:26:54 +01:00
parent be4b43ed64
commit a4415dd94c
9 changed files with 570 additions and 2 deletions

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;