2026-04-06 18:22:02 +00:00
use super ::confluence_search ::SearchResult ;
2026-04-15 00:37:27 +00:00
use crate ::integrations ::query_expansion ::expand_query ;
2026-04-06 18:22:02 +00:00
2026-04-15 00:55:32 +00:00
const MAX_EXPANDED_QUERIES : usize = 3 ;
fn escape_wiql ( s : & str ) -> String {
s . replace ( '\'' , " '' " )
2026-04-15 01:07:59 +00:00
. replace ( '"' , " \\ \" " )
. replace ( '\\' , " \\ \\ " )
. replace ( '(' , " \\ ( " )
. replace ( ')' , " \\ ) " )
. replace ( ';' , " \\ ; " )
. replace ( '=' , " \\ = " )
2026-04-15 00:55:32 +00:00
}
security: address all issues from automated PR review
- Add missing CQL escaping for &, |, +, - characters
- Improve escape_wiql() to escape more dangerous characters: ", \, (, ), ~, *, ?, ;, =
- Sanitize HTML in excerpts using strip_html_tags() to prevent XSS
- Add unit tests for escape_wiql, escape_cql, canonicalize_url functions
- Document expand_query() behavior (always returns at least original query)
- All tests pass (158/158), cargo fmt and clippy pass
2026-04-15 01:26:05 +00:00
/// Basic HTML tag stripping to prevent XSS in excerpts
fn strip_html_tags ( html : & str ) -> String {
let mut result = String ::new ( ) ;
let mut in_tag = false ;
for ch in html . chars ( ) {
match ch {
'<' = > in_tag = true ,
'>' = > in_tag = false ,
_ if ! in_tag = > result . push ( ch ) ,
_ = > { }
}
}
// Clean up whitespace
result
. split_whitespace ( )
. collect ::< Vec < _ > > ( )
. join ( " " )
. trim ( )
. to_string ( )
}
2026-04-06 18:22:02 +00:00
/// Search Azure DevOps Wiki for content matching the query
pub async fn search_wiki (
org_url : & str ,
project : & str ,
query : & str ,
cookies : & [ crate ::integrations ::webview_auth ::Cookie ] ,
) -> Result < Vec < SearchResult > , String > {
let cookie_header = crate ::integrations ::webview_auth ::cookies_to_header ( cookies ) ;
let client = reqwest ::Client ::new ( ) ;
2026-04-15 00:37:27 +00:00
let expanded_queries = expand_query ( query ) ;
2026-04-06 18:22:02 +00:00
2026-04-15 00:37:27 +00:00
let mut all_results = Vec ::new ( ) ;
2026-04-06 18:22:02 +00:00
2026-04-15 00:55:32 +00:00
for expanded_query in expanded_queries . iter ( ) . take ( MAX_EXPANDED_QUERIES ) {
2026-04-15 00:37:27 +00:00
// Use Azure DevOps Search API
let search_url = format! (
" {}/_apis/search/wikisearchresults?api-version=7.0 " ,
org_url . trim_end_matches ( '/' )
) ;
2026-04-06 18:22:02 +00:00
2026-04-15 00:37:27 +00:00
let search_body = serde_json ::json! ( {
" searchText " : expanded_query ,
" $top " : 5 ,
" filters " : {
" ProjectFilters " : [ project ]
}
} ) ;
2026-04-15 00:55:32 +00:00
tracing ::info! ( " Searching Azure DevOps Wiki with query: {} " , expanded_query ) ;
2026-04-15 00:37:27 +00:00
let resp = client
. post ( & search_url )
. header ( " Cookie " , & cookie_header )
. header ( " Accept " , " application/json " )
. header ( " Content-Type " , " application/json " )
. json ( & search_body )
. send ( )
. await
. map_err ( | e | format! ( " Azure DevOps wiki search failed: {e} " ) ) ? ;
if ! resp . status ( ) . is_success ( ) {
let status = resp . status ( ) ;
let text = resp . text ( ) . await . unwrap_or_default ( ) ;
tracing ::warn! ( " Azure DevOps wiki search failed with status {status}: {text} " ) ;
continue ;
}
2026-04-06 18:22:02 +00:00
2026-04-15 00:37:27 +00:00
let json : serde_json ::Value = resp
. json ( )
. await
. map_err ( | e | format! ( " Failed to parse ADO wiki search response: {e} " ) ) ? ;
if let Some ( results_array ) = json [ " results " ] . as_array ( ) {
2026-04-15 00:55:32 +00:00
for item in results_array . iter ( ) . take ( MAX_EXPANDED_QUERIES ) {
2026-04-15 00:37:27 +00:00
let title = item [ " fileName " ] . as_str ( ) . unwrap_or ( " Untitled " ) . to_string ( ) ;
let path = item [ " path " ] . as_str ( ) . unwrap_or ( " " ) ;
let url = format! (
" {}/_wiki/wikis/{}/{} " ,
org_url . trim_end_matches ( '/' ) ,
project ,
path
) ;
security: address all issues from automated PR review
- Add missing CQL escaping for &, |, +, - characters
- Improve escape_wiql() to escape more dangerous characters: ", \, (, ), ~, *, ?, ;, =
- Sanitize HTML in excerpts using strip_html_tags() to prevent XSS
- Add unit tests for escape_wiql, escape_cql, canonicalize_url functions
- Document expand_query() behavior (always returns at least original query)
- All tests pass (158/158), cargo fmt and clippy pass
2026-04-15 01:26:05 +00:00
let excerpt = strip_html_tags ( item [ " content " ] . as_str ( ) . unwrap_or ( " " ) )
2026-04-15 00:37:27 +00:00
. chars ( )
. take ( 300 )
. collect ::< String > ( ) ;
// Fetch full wiki page content
let content = if let Some ( wiki_id ) = item [ " wiki " ] [ " id " ] . as_str ( ) {
if let Some ( page_path ) = item [ " path " ] . as_str ( ) {
fetch_wiki_page ( org_url , wiki_id , page_path , & cookie_header )
. await
. ok ( )
} else {
None
}
2026-04-06 18:22:02 +00:00
} else {
None
2026-04-15 00:37:27 +00:00
} ;
all_results . push ( SearchResult {
title ,
url ,
excerpt ,
content ,
source : " Azure DevOps " . to_string ( ) ,
} ) ;
}
2026-04-06 18:22:02 +00:00
}
}
2026-04-15 00:37:27 +00:00
all_results . sort_by ( | a , b | a . url . cmp ( & b . url ) ) ;
all_results . dedup_by ( | a , b | a . url = = b . url ) ;
Ok ( all_results )
2026-04-06 18:22:02 +00:00
}
/// Fetch full wiki page content
async fn fetch_wiki_page (
org_url : & str ,
wiki_id : & str ,
page_path : & str ,
cookie_header : & str ,
) -> Result < String , String > {
let client = reqwest ::Client ::new ( ) ;
let page_url = format! (
" {}/_apis/wiki/wikis/{}/pages?path={}&api-version=7.0&includeContent=true " ,
org_url . trim_end_matches ( '/' ) ,
wiki_id ,
urlencoding ::encode ( page_path )
) ;
let resp = client
. get ( & page_url )
. header ( " Cookie " , cookie_header )
. header ( " Accept " , " application/json " )
. send ( )
. await
2026-04-06 20:14:19 +00:00
. map_err ( | e | format! ( " Failed to fetch wiki page: {e} " ) ) ? ;
2026-04-06 18:22:02 +00:00
if ! resp . status ( ) . is_success ( ) {
2026-04-06 20:14:19 +00:00
let status = resp . status ( ) ;
return Err ( format! ( " Failed to fetch wiki page: {status} " ) ) ;
2026-04-06 18:22:02 +00:00
}
let json : serde_json ::Value = resp
. json ( )
. await
2026-04-06 20:14:19 +00:00
. map_err ( | e | format! ( " Failed to parse wiki page: {e} " ) ) ? ;
2026-04-06 18:22:02 +00:00
let content = json [ " content " ] . as_str ( ) . unwrap_or ( " " ) . to_string ( ) ;
// Truncate to reasonable length
let truncated = if content . len ( ) > 3000 {
format! ( " {} ... " , & content [ .. 3000 ] )
} else {
content
} ;
Ok ( truncated )
}
/// Search Azure DevOps Work Items for related issues
pub async fn search_work_items (
org_url : & str ,
project : & str ,
query : & str ,
cookies : & [ crate ::integrations ::webview_auth ::Cookie ] ,
) -> Result < Vec < SearchResult > , String > {
let cookie_header = crate ::integrations ::webview_auth ::cookies_to_header ( cookies ) ;
let client = reqwest ::Client ::new ( ) ;
2026-04-15 00:37:27 +00:00
let expanded_queries = expand_query ( query ) ;
2026-04-06 18:22:02 +00:00
2026-04-15 00:37:27 +00:00
let mut all_results = Vec ::new ( ) ;
2026-04-06 18:22:02 +00:00
2026-04-15 00:55:32 +00:00
for expanded_query in expanded_queries . iter ( ) . take ( MAX_EXPANDED_QUERIES ) {
2026-04-15 00:37:27 +00:00
// Use WIQL (Work Item Query Language)
let wiql_url = format! (
" {}/_apis/wit/wiql?api-version=7.0 " ,
org_url . trim_end_matches ( '/' )
) ;
2026-04-06 18:22:02 +00:00
2026-04-15 00:55:32 +00:00
let safe_query = escape_wiql ( expanded_query ) ;
2026-04-15 00:37:27 +00:00
let wiql_query = format! (
2026-04-15 01:38:21 +00:00
" SELECT [System.Id], [System.Title], [System.Description], [System.State] FROM WorkItems WHERE [System.TeamProject] = '{project}' AND ([System.Title] ~ '{safe_query}' OR [System.Description] ~ '{safe_query}') ORDER BY [System.ChangedDate] DESC "
2026-04-15 00:37:27 +00:00
) ;
2026-04-06 18:22:02 +00:00
2026-04-15 00:37:27 +00:00
let wiql_body = serde_json ::json! ( {
" query " : wiql_query
} ) ;
2026-04-06 18:22:02 +00:00
2026-04-15 00:55:32 +00:00
tracing ::info! (
" Searching Azure DevOps work items with query: {} " ,
expanded_query
) ;
2026-04-06 18:22:02 +00:00
2026-04-15 00:37:27 +00:00
let resp = client
. post ( & wiql_url )
. header ( " Cookie " , & cookie_header )
. header ( " Accept " , " application/json " )
. header ( " Content-Type " , " application/json " )
. json ( & wiql_body )
. send ( )
. await
. map_err ( | e | format! ( " ADO work item search failed: {e} " ) ) ? ;
2026-04-06 18:22:02 +00:00
2026-04-15 00:37:27 +00:00
if ! resp . status ( ) . is_success ( ) {
continue ; // Don't fail if work item search fails
}
2026-04-06 18:22:02 +00:00
2026-04-15 00:37:27 +00:00
let json : serde_json ::Value = resp
. json ( )
. await
. map_err ( | _ | " Failed to parse work item response " . to_string ( ) ) ? ;
if let Some ( work_items ) = json [ " workItems " ] . as_array ( ) {
// Fetch details for top 3 work items
2026-04-15 00:55:32 +00:00
for item in work_items . iter ( ) . take ( MAX_EXPANDED_QUERIES ) {
2026-04-15 00:37:27 +00:00
if let Some ( id ) = item [ " id " ] . as_i64 ( ) {
if let Ok ( work_item ) =
fetch_work_item_details ( org_url , id , & cookie_header ) . await
{
all_results . push ( work_item ) ;
}
2026-04-06 18:22:02 +00:00
}
}
}
}
2026-04-15 00:37:27 +00:00
all_results . sort_by ( | a , b | a . url . cmp ( & b . url ) ) ;
all_results . dedup_by ( | a , b | a . url = = b . url ) ;
Ok ( all_results )
2026-04-06 18:22:02 +00:00
}
/// Fetch work item details
async fn fetch_work_item_details (
org_url : & str ,
id : i64 ,
cookie_header : & str ,
) -> Result < SearchResult , String > {
let client = reqwest ::Client ::new ( ) ;
let item_url = format! (
" {}/_apis/wit/workitems/{}?api-version=7.0 " ,
org_url . trim_end_matches ( '/' ) ,
id
) ;
let resp = client
. get ( & item_url )
. header ( " Cookie " , cookie_header )
. header ( " Accept " , " application/json " )
. send ( )
. await
2026-04-06 20:14:19 +00:00
. map_err ( | e | format! ( " Failed to fetch work item: {e} " ) ) ? ;
2026-04-06 18:22:02 +00:00
if ! resp . status ( ) . is_success ( ) {
2026-04-06 20:14:19 +00:00
let status = resp . status ( ) ;
return Err ( format! ( " Failed to fetch work item: {status} " ) ) ;
2026-04-06 18:22:02 +00:00
}
let json : serde_json ::Value = resp
. json ( )
. await
2026-04-06 20:14:19 +00:00
. map_err ( | e | format! ( " Failed to parse work item: {e} " ) ) ? ;
2026-04-06 18:22:02 +00:00
let fields = & json [ " fields " ] ;
let title = format! (
" Work Item {}: {} " ,
id ,
fields [ " System.Title " ] . as_str ( ) . unwrap_or ( " No title " )
) ;
let url = json [ " _links " ] [ " html " ] [ " href " ]
. as_str ( )
. unwrap_or ( " " )
. to_string ( ) ;
let description = fields [ " System.Description " ]
. as_str ( )
. unwrap_or ( " " )
. to_string ( ) ;
let state = fields [ " System.State " ] . as_str ( ) . unwrap_or ( " Unknown " ) ;
2026-04-06 20:14:19 +00:00
let content = format! ( " State: {state} \n \n Description: {description} " ) ;
2026-04-06 18:22:02 +00:00
let excerpt = content . chars ( ) . take ( 200 ) . collect ::< String > ( ) ;
Ok ( SearchResult {
title ,
url ,
excerpt ,
content : Some ( content ) ,
source : " Azure DevOps " . to_string ( ) ,
} )
}
security: address all issues from automated PR review
- Add missing CQL escaping for &, |, +, - characters
- Improve escape_wiql() to escape more dangerous characters: ", \, (, ), ~, *, ?, ;, =
- Sanitize HTML in excerpts using strip_html_tags() to prevent XSS
- Add unit tests for escape_wiql, escape_cql, canonicalize_url functions
- Document expand_query() behavior (always returns at least original query)
- All tests pass (158/158), cargo fmt and clippy pass
2026-04-15 01:26:05 +00:00
#[ cfg(test) ]
mod tests {
use super ::* ;
#[ test ]
fn test_escape_wiql_escapes_single_quotes ( ) {
assert_eq! ( escape_wiql ( " test'single " ) , " test''single " ) ;
}
#[ test ]
fn test_escape_wiql_escapes_double_quotes ( ) {
assert_eq! ( escape_wiql ( " test \" double " ) , " test \\ \\ \" double " ) ;
}
#[ test ]
fn test_escape_wiql_escapes_backslash ( ) {
assert_eq! ( escape_wiql ( " test \\ backslash " ) , r # "test\\backslash"# ) ;
}
#[ test ]
fn test_escape_wiql_escapes_parens ( ) {
assert_eq! ( escape_wiql ( " test(paren " ) , r # "test\(paren"# ) ;
assert_eq! ( escape_wiql ( " test)paren " ) , r # "test\)paren"# ) ;
}
#[ test ]
fn test_escape_wiql_escapes_semicolon ( ) {
assert_eq! ( escape_wiql ( " test;semi " ) , r # "test\;semi"# ) ;
}
#[ test ]
fn test_escape_wiql_escapes_equals ( ) {
assert_eq! ( escape_wiql ( " test=equal " ) , r # "test\=equal"# ) ;
}
#[ test ]
fn test_escape_wiql_no_special_chars ( ) {
assert_eq! ( escape_wiql ( " simple query " ) , " simple query " ) ;
}
#[ test ]
fn test_strip_html_tags ( ) {
let html = " <p>Hello <strong>world</strong>!</p> " ;
assert_eq! ( strip_html_tags ( html ) , " Hello world! " ) ;
let html2 = " <div><h1>Title</h1><p>Content</p></div> " ;
assert_eq! ( strip_html_tags ( html2 ) , " TitleContent " ) ;
}
}