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

This commit was merged in pull request #10.
This commit is contained in:
2026-03-11 12:13:59 +00:00
parent be4b43ed64
commit 491665559f
22 changed files with 1582 additions and 122 deletions

View File

@@ -0,0 +1,213 @@
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"));
// Gitea expects label IDs (integers), not names. Append label names
// to the body instead since resolving IDs would require extra API calls.
let mut full_body = body.to_string();
if !labels.is_empty() {
full_body.push_str("\n\n**Labels:** ");
full_body.push_str(&labels.join(", "));
}
let payload = serde_json::json!({
"title": title,
"body": full_body,
});
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;