Files
GitLite/src-tauri/src/integrations/issue_comments.rs
T
Christoph 0539020545 feat(integrations): add multi-provider board and issue integrations
Add backend integration modules to discover, read, and modify
provider-hosted boards, issues, and comments across multiple providers.
Expose Tauri commands for board discovery, listing, and card moves,
and implement safe issue actions and comment APIs.
Wire new Svelte UI components to render boards, issue centers,
comments, labels, and assignees, and add sanitized markdown rendering.

- Add board discovery, board reading, and card-move APIs
- Add Svelte components and styles for integrated board UI
- Use marked + DOMPurify for safe markdown rendering
2026-09-08 21:19:54 +02:00

96 lines
7.1 KiB
Rust

use super::*;
use serde_json::{Value, json};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IssueComment { id: String, author: String, body: String, created_at: String, body_html: bool }
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IssueCommentPage { comments: Vec<IssueComment>, next_cursor: Option<String> }
pub(super) fn comment_url(provider: &str, base: &str, repository: &str, number: u64) -> Result<reqwest::Url, String> {
if repository.trim().is_empty() || number == 0 { return Err("Missing issue repository or number.".into()); }
let base = normalized_base_url(base)?;
let mut url = reqwest::Url::parse(if provider == "github" { "https://api.github.com" } else { &base }).map_err(|e|e.to_string())?;
let num = number.to_string();
let mut path = url.path_segments_mut().map_err(|_|"Invalid API URL.")?;
path.pop_if_empty();
match provider {
"github" | "gitea" => {
let (owner, repo) = repository.split_once('/').filter(|(a,b)|!a.is_empty() && !b.is_empty() && !b.contains('/')).ok_or("Invalid issue repository.")?;
if provider == "gitea" { path.extend(["api","v1"]); }
path.extend(["repos",owner,repo,"issues",&num,"comments"]);
},
"gitlab" | "gitlab-self-hosted" => { path.extend(["api","v4","projects",repository,"issues",&num,"notes"]); },
"azure-devops" => { path.extend([repository,"_apis","wit","workItems",&num,"comments"]); },
_ => return Err("Unsupported issue provider.".into()),
}
drop(path);
if provider == "azure-devops" { url.query_pairs_mut().append_pair("api-version","7.1-preview.4"); }
Ok(url)
}
pub(super) fn request(client: &Client, method: reqwest::Method, url: reqwest::Url, provider: &str, username: &str, token: &str) -> Result<reqwest::blocking::RequestBuilder, String> {
if token.trim().is_empty() { return Err("No token stored for this integration.".into()); }
let req = client.request(method,url).header(USER_AGENT,"Gitty").header(ACCEPT,"application/json");
Ok(match provider {
"github" => req.bearer_auth(token),
"gitea" => req.header("Authorization",format!("token {token}")),
"gitlab" | "gitlab-self-hosted" => req.header("PRIVATE-TOKEN",token),
_ => req.basic_auth(if username.is_empty(){"gitty"}else{username},Some(token)),
})
}
fn parse_comment(v: &Value, provider: &str) -> Option<IssueComment> {
if v["system"] == true || v["isDeleted"] == true { return None; }
let azure = provider == "azure-devops";
let id = if azure && v["id"].is_null() { json_id(&json!({"id":v["commentId"]})) } else { json_id(v) };
Some(IssueComment { id, author: value_string(v, if azure { &["createdBy","displayName"] } else if provider.starts_with("gitlab") { &["author","username"] } else { &["user","login"] }), body:value_string(v,&[if azure{"text"}else{"body"}]), created_at:value_string(v,&[if azure{"createdDate"}else{"created_at"}]), body_html: azure && (v["format"] == "html" || v["format"] == 0) })
}
pub(super) fn comment_client() -> Result<Client,String> {
Client::builder().timeout(Duration::from_secs(30)).connect_timeout(Duration::from_secs(7)).redirect(reqwest::redirect::Policy::none()).build().map_err(|e|e.to_string())
}
#[tauri::command]
pub async fn list_integration_issue_comments(provider:String, base_url:String, username:String, token:String, repository:String, number:u64, cursor:Option<String>) -> Result<IssueCommentPage,String> {
tauri::async_runtime::spawn_blocking(move || {
let client=comment_client()?;
let mut url=comment_url(&provider,&base_url,&repository,number)?;
let page=if provider!="azure-devops" { cursor.as_deref().unwrap_or("1").parse::<u32>().map_err(|_|"Invalid comment page.")?.max(1) } else {1};
if provider=="azure-devops" {
url.query_pairs_mut().append_pair("$top","100").append_pair("order","asc").append_pair("includeDeleted","false");
if let Some(cursor)=cursor {url.query_pairs_mut().append_pair("continuationToken",&cursor);}
} else {url.query_pairs_mut().append_pair("page",&page.to_string()).append_pair("per_page","100").append_pair("limit","100").append_pair("sort","asc").append_pair("order_by","created_at");}
let response=request(&client,reqwest::Method::GET,url,&provider,&username,&token)?.send().map_err(|e|e.to_string())?;
if !response.status().is_success(){return Err(response_error(response,&provider));}
let linked_next=response.headers().get("link").and_then(|h|h.to_str().ok()).map(|s|s.contains("rel=\"next\""));
let value:Value=response.json().map_err(|e|e.to_string())?;
let items=if provider=="azure-devops" {value["comments"].as_array()}else{value.as_array()}.ok_or("Invalid comment list.")?;
let next_cursor=if provider=="azure-devops" {value["continuationToken"].as_str().filter(|s|!s.is_empty()).map(str::to_string)}else if linked_next.unwrap_or(items.len()==100) {Some(page.saturating_add(1).to_string())}else{None};
Ok(IssueCommentPage{comments:items.iter().filter_map(|v|parse_comment(v,&provider)).collect(),next_cursor})
}).await.map_err(|e|e.to_string())?
}
#[tauri::command]
pub async fn add_integration_issue_comment(provider:String, base_url:String, username:String, token:String, repository:String, number:u64, body:String) -> Result<IssueComment,String> {
tauri::async_runtime::spawn_blocking(move || {
if body.trim().is_empty() || body.len()>100_000 {return Err("Comment must contain between 1 and 100,000 bytes.".into());}
let mut url=comment_url(&provider,&base_url,&repository,number)?;
if provider=="azure-devops" {url.query_pairs_mut().append_pair("format","markdown");}
let payload=if provider=="azure-devops"{json!({"text":body})}else{json!({"body":body})};
let response=request(&comment_client()?,reqwest::Method::POST,url,&provider,&username,&token)?.json(&payload).send().map_err(|e|format!("Comment delivery not confirmed: {e}"))?;
if !response.status().is_success(){return Err(response_error(response,&provider));}
parse_comment(&response.json::<Value>().map_err(|e|e.to_string())?,&provider).ok_or("Invalid posted comment.".into())
}).await.map_err(|e|e.to_string())?
}
#[cfg(test)]
mod tests {
use super::*;
#[test] fn issue_endpoints_use_issue_notes_and_encode_project_paths() {
assert_eq!(comment_url("gitlab","https://git.test","team/sub/repo",4).unwrap().as_str(),"https://git.test/api/v4/projects/team%2Fsub%2Frepo/issues/4/notes");
assert_eq!(comment_url("azure-devops","https://dev.azure.com/org","My Project",4).unwrap().path(),"/org/My%20Project/_apis/wit/workItems/4/comments");
assert!(comment_url("gitea","https://git.test","owner/repo/invalid",4).is_err());
}
#[test] fn comments_filter_system_entries_and_preserve_html_format() {
assert!(parse_comment(&json!({"system":true}),"gitlab").is_none());
let c=parse_comment(&json!({"commentId":7,"text":"<p>Hello</p>","format":"html","createdBy":{"displayName":"Alex"}}),"azure-devops").unwrap();
assert_eq!(c.id,"7"); assert!(c.body_html); assert_eq!(c.author,"Alex");
}
}