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, next_cursor: Option } pub(super) fn comment_url(provider: &str, base: &str, repository: &str, number: u64) -> Result { 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 { 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 { 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::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) -> Result { 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::().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 { 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::().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":"

Hello

","format":"html","createdBy":{"displayName":"Alex"}}),"azure-devops").unwrap(); assert_eq!(c.id,"7"); assert!(c.body_html); assert_eq!(c.author,"Alex"); } }