diff --git a/crates/api-support/assets/support_chat.md.jinja b/crates/api-support/assets/support_chat.md.jinja index d092db7b6a..775ee47536 100644 --- a/crates/api-support/assets/support_chat.md.jinja +++ b/crates/api-support/assets/support_chat.md.jinja @@ -4,10 +4,10 @@ Your role is to help users with: - Troubleshooting issues with the application - Explaining features and how to use them -- Collecting bug reports and feature requests using the available tools +- Managing bug reports and feature requests via GitHub issues -When a user reports a bug, use the `submit_bug_report` tool to create a GitHub issue. -When a user requests a feature, use the `submit_feature_request` tool to create a GitHub discussion. -When you need to look up existing issues or pull requests, use the `read_github_data` tool. +When a user reports a bug or requests a feature, first use `search_issues` to check if a similar issue already exists. +If a matching issue exists, use `add_comment` to add relevant details to it. +If no matching issue exists, use `create_issue` to create a new one. -Keep your responses concise and friendly. If you need device information from the user (platform, architecture, OS version, app version), ask for it before submitting a report. +Keep your responses concise and friendly. diff --git a/crates/api-support/src/github.rs b/crates/api-support/src/github.rs index 8f68c1322a..06052164bc 100644 --- a/crates/api-support/src/github.rs +++ b/crates/api-support/src/github.rs @@ -145,7 +145,7 @@ async fn attach_log_analysis(state: &AppState, issue_number: u64, log_text: &str let _ = add_issue_comment(state, issue_number, &log_comment).await; } -async fn create_issue( +pub(crate) async fn create_issue( state: &AppState, title: &str, body: &str, @@ -164,13 +164,62 @@ async fn create_issue( Ok((issue.html_url.to_string(), issue.number)) } -async fn add_issue_comment(state: &AppState, issue_number: u64, comment: &str) -> Result<()> { +pub(crate) async fn add_issue_comment( + state: &AppState, + issue_number: u64, + comment: &str, +) -> Result { let client = state.installation_client().await?; - client + let comment = client .issues(GITHUB_OWNER, GITHUB_REPO) .create_comment(issue_number, comment) .await?; - Ok(()) + Ok(comment.html_url.to_string()) +} + +pub(crate) async fn search_issues( + state: &AppState, + query: &str, + state_filter: Option<&str>, + limit: u8, +) -> Result> { + let client = state.installation_client().await?; + + let mut q = format!("repo:{GITHUB_OWNER}/{GITHUB_REPO} is:issue {query}"); + if let Some(s) = state_filter { + match s { + "open" | "closed" => q.push_str(&format!(" is:{s}")), + _ => { + return Err(SupportError::Internal( + "Invalid state filter: must be 'open' or 'closed'".to_string(), + )); + } + } + } + + let result = client + .search() + .issues_and_pull_requests(&q) + .per_page(limit) + .send() + .await?; + + let items: Vec = result + .items + .into_iter() + .map(|issue| { + serde_json::json!({ + "number": issue.number, + "title": issue.title, + "state": format!("{:?}", issue.state).to_lowercase(), + "url": issue.html_url.to_string(), + "created_at": issue.created_at.to_rfc3339(), + "labels": issue.labels.iter().map(|l| &l.name).collect::>(), + }) + }) + .collect(); + + Ok(items) } async fn create_discussion( diff --git a/crates/api-support/src/mcp/server.rs b/crates/api-support/src/mcp/server.rs index b282ec46ff..b8b4c4c4b3 100644 --- a/crates/api-support/src/mcp/server.rs +++ b/crates/api-support/src/mcp/server.rs @@ -7,7 +7,7 @@ use rmcp::{ use crate::state::AppState; use super::prompts; -use super::tools::{self, ReadGitHubDataParams, SubmitBugReportParams, SubmitFeatureRequestParams}; +use super::tools::{self, AddCommentParams, CreateIssueParams, SearchIssuesParams}; #[derive(Clone)] pub(crate) struct SupportMcpServer { @@ -26,32 +26,30 @@ impl SupportMcpServer { #[tool_router] impl SupportMcpServer { - #[tool( - description = "Submit a bug report. Creates a GitHub issue with device information and optional log analysis." - )] - async fn submit_bug_report( + #[tool(description = "Create a new GitHub issue.")] + async fn create_issue( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> Result { - tools::submit_bug_report(&self.state, params).await + tools::create_issue(&self.state, params).await } - #[tool(description = "Submit a feature request. Creates a GitHub discussion.")] - async fn submit_feature_request( + #[tool(description = "Add a new comment to an existing GitHub issue.")] + async fn add_comment( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> Result { - tools::submit_feature_request(&self.state, params).await + tools::add_comment(&self.state, params).await } #[tool( - description = "Read GitHub data (issues, pull requests, comments, tags) from the database. Data is synced from GitHub via Airbyte." + description = "Search for GitHub issues by keywords, error messages, or other criteria." )] - async fn read_github_data( + async fn search_issues( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> Result { - tools::read_github_data(&self.state, params).await + tools::search_issues(&self.state, params).await } } @@ -72,8 +70,7 @@ impl ServerHandler for SupportMcpServer { website_url: None, }, instructions: Some( - "Hyprnote support server. Provides tools for submitting bug reports and feature requests." - .to_string(), + "Hyprnote support server. Provides tools for managing GitHub issues.".to_string(), ), } } diff --git a/crates/api-support/src/mcp/tools/add_comment.rs b/crates/api-support/src/mcp/tools/add_comment.rs new file mode 100644 index 0000000000..1fba277f2b --- /dev/null +++ b/crates/api-support/src/mcp/tools/add_comment.rs @@ -0,0 +1,34 @@ +use rmcp::{ + ErrorData as McpError, + model::*, + schemars::{self, JsonSchema}, +}; +use serde::Deserialize; + +use crate::github; +use crate::state::AppState; + +#[derive(Debug, Deserialize, JsonSchema)] +pub(crate) struct AddCommentParams { + #[schemars(description = "The issue number to comment on")] + pub issue_number: u64, + #[schemars(description = "The comment body in markdown")] + pub body: String, +} + +pub(crate) async fn add_comment( + state: &AppState, + params: AddCommentParams, +) -> Result { + let url = github::add_issue_comment(state, params.issue_number, ¶ms.body) + .await + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ + "success": true, + "comment_url": url, + }) + .to_string(), + )])) +} diff --git a/crates/api-support/src/mcp/tools/bug_report.rs b/crates/api-support/src/mcp/tools/bug_report.rs deleted file mode 100644 index b6988f7b78..0000000000 --- a/crates/api-support/src/mcp/tools/bug_report.rs +++ /dev/null @@ -1,53 +0,0 @@ -use rmcp::{ - ErrorData as McpError, - model::*, - schemars::{self, JsonSchema}, -}; -use serde::Deserialize; - -use crate::github::{self, BugReportInput}; -use crate::state::AppState; - -#[derive(Debug, Deserialize, JsonSchema)] -pub(crate) struct SubmitBugReportParams { - #[schemars(description = "Description of the bug")] - pub description: String, - #[schemars(description = "Platform (e.g. macos, windows, linux)")] - pub platform: String, - #[schemars(description = "Architecture (e.g. aarch64, x86_64)")] - pub arch: String, - #[schemars(description = "OS version")] - pub os_version: String, - #[schemars(description = "Application version")] - pub app_version: String, - #[schemars(description = "Optional application logs for analysis")] - pub logs: Option, -} - -pub(crate) async fn submit_bug_report( - state: &AppState, - params: SubmitBugReportParams, -) -> Result { - let url = github::submit_bug_report( - state, - BugReportInput { - description: ¶ms.description, - platform: ¶ms.platform, - arch: ¶ms.arch, - os_version: ¶ms.os_version, - app_version: ¶ms.app_version, - source: "via MCP", - logs: params.logs.as_deref(), - }, - ) - .await - .map_err(|e| McpError::internal_error(e.to_string(), None))?; - - Ok(CallToolResult::success(vec![Content::text( - serde_json::json!({ - "success": true, - "issue_url": url, - }) - .to_string(), - )])) -} diff --git a/crates/api-support/src/mcp/tools/create_issue.rs b/crates/api-support/src/mcp/tools/create_issue.rs new file mode 100644 index 0000000000..4a3c15a235 --- /dev/null +++ b/crates/api-support/src/mcp/tools/create_issue.rs @@ -0,0 +1,39 @@ +use rmcp::{ + ErrorData as McpError, + model::*, + schemars::{self, JsonSchema}, +}; +use serde::Deserialize; + +use crate::github; +use crate::state::AppState; + +#[derive(Debug, Deserialize, JsonSchema)] +pub(crate) struct CreateIssueParams { + #[schemars(description = "Title of the issue")] + pub title: String, + #[schemars(description = "Body/description of the issue in markdown")] + pub body: String, + #[schemars(description = "Labels to apply to the issue")] + pub labels: Option>, +} + +pub(crate) async fn create_issue( + state: &AppState, + params: CreateIssueParams, +) -> Result { + let labels = params.labels.unwrap_or_default(); + + let (url, number) = github::create_issue(state, ¶ms.title, ¶ms.body, &labels) + .await + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ + "success": true, + "issue_url": url, + "issue_number": number, + }) + .to_string(), + )])) +} diff --git a/crates/api-support/src/mcp/tools/feature_request.rs b/crates/api-support/src/mcp/tools/feature_request.rs deleted file mode 100644 index 5135f1decd..0000000000 --- a/crates/api-support/src/mcp/tools/feature_request.rs +++ /dev/null @@ -1,50 +0,0 @@ -use rmcp::{ - ErrorData as McpError, - model::*, - schemars::{self, JsonSchema}, -}; -use serde::Deserialize; - -use crate::github::{self, FeatureRequestInput}; -use crate::state::AppState; - -#[derive(Debug, Deserialize, JsonSchema)] -pub(crate) struct SubmitFeatureRequestParams { - #[schemars(description = "Description of the feature request")] - pub description: String, - #[schemars(description = "Platform (e.g. macos, windows, linux)")] - pub platform: String, - #[schemars(description = "Architecture (e.g. aarch64, x86_64)")] - pub arch: String, - #[schemars(description = "OS version")] - pub os_version: String, - #[schemars(description = "Application version")] - pub app_version: String, -} - -pub(crate) async fn submit_feature_request( - state: &AppState, - params: SubmitFeatureRequestParams, -) -> Result { - let url = github::submit_feature_request( - state, - FeatureRequestInput { - description: ¶ms.description, - platform: ¶ms.platform, - arch: ¶ms.arch, - os_version: ¶ms.os_version, - app_version: ¶ms.app_version, - source: "via MCP", - }, - ) - .await - .map_err(|e| McpError::internal_error(e.to_string(), None))?; - - Ok(CallToolResult::success(vec![Content::text( - serde_json::json!({ - "success": true, - "discussion_url": url, - }) - .to_string(), - )])) -} diff --git a/crates/api-support/src/mcp/tools/mod.rs b/crates/api-support/src/mcp/tools/mod.rs index bdba04b8cc..647d9e5218 100644 --- a/crates/api-support/src/mcp/tools/mod.rs +++ b/crates/api-support/src/mcp/tools/mod.rs @@ -1,7 +1,7 @@ -mod bug_report; -mod feature_request; -mod read_github_data; +mod add_comment; +mod create_issue; +mod search_issues; -pub(crate) use bug_report::{SubmitBugReportParams, submit_bug_report}; -pub(crate) use feature_request::{SubmitFeatureRequestParams, submit_feature_request}; -pub(crate) use read_github_data::{ReadGitHubDataParams, read_github_data}; +pub(crate) use add_comment::{AddCommentParams, add_comment}; +pub(crate) use create_issue::{CreateIssueParams, create_issue}; +pub(crate) use search_issues::{SearchIssuesParams, search_issues}; diff --git a/crates/api-support/src/mcp/tools/read_github_data.rs b/crates/api-support/src/mcp/tools/read_github_data.rs deleted file mode 100644 index 94b675e87d..0000000000 --- a/crates/api-support/src/mcp/tools/read_github_data.rs +++ /dev/null @@ -1,123 +0,0 @@ -use rmcp::{ - ErrorData as McpError, - model::*, - schemars::{self, JsonSchema}, -}; -use serde::Deserialize; - -use crate::state::AppState; - -#[derive(Debug, Clone, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub(crate) enum GitHubTable { - #[schemars(description = "GitHub issues")] - Issues, - #[schemars(description = "GitHub pull requests")] - PullRequests, - #[schemars(description = "GitHub comments")] - Comments, - #[schemars(description = "GitHub tags")] - Tags, -} - -impl GitHubTable { - fn as_str(&self) -> &'static str { - match self { - Self::Issues => "issues", - Self::PullRequests => "pull_requests", - Self::Comments => "comments", - Self::Tags => "tags", - } - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub(crate) struct ReadGitHubDataParams { - #[schemars(description = "The table to read from")] - pub table: GitHubTable, - #[schemars(description = "Maximum number of rows to return (default: 50, max: 500)")] - pub limit: Option, - #[schemars(description = "Number of rows to skip (default: 0)")] - pub offset: Option, - #[schemars( - description = "Filter by state (e.g. 'open', 'closed'). Applicable to issues and pull_requests." - )] - pub state: Option, -} - -pub(crate) async fn read_github_data( - state: &AppState, - params: ReadGitHubDataParams, -) -> Result { - let table_name = params.table.as_str(); - let limit = params.limit.unwrap_or(50).clamp(0, 500); - let offset = params.offset.unwrap_or(0).max(0); - - if params.state.is_some() { - match params.table { - GitHubTable::Comments | GitHubTable::Tags => { - return Err(McpError::invalid_params( - "The 'state' filter is only applicable to 'issues' and 'pull_requests' tables", - None, - )); - } - _ => {} - } - } - - let query = if let Some(ref state_filter) = params.state { - let q = format!( - "SELECT to_jsonb(t.*) AS row_data FROM hyprnote_github.{} t WHERE t.state = $1 ORDER BY t._airbyte_extracted_at DESC LIMIT $2 OFFSET $3", - table_name - ); - sqlx::query_scalar::<_, serde_json::Value>(&q) - .bind(state_filter) - .bind(limit) - .bind(offset) - .fetch_all(&state.db_pool) - .await - } else { - let q = format!( - "SELECT to_jsonb(t.*) AS row_data FROM hyprnote_github.{} t ORDER BY t._airbyte_extracted_at DESC LIMIT $1 OFFSET $2", - table_name - ); - sqlx::query_scalar::<_, serde_json::Value>(&q) - .bind(limit) - .bind(offset) - .fetch_all(&state.db_pool) - .await - }; - - let rows = query.map_err(|e| McpError::internal_error(e.to_string(), None))?; - - let total_count: i64 = if let Some(ref state_filter) = params.state { - let count_query = format!( - "SELECT COUNT(*) FROM hyprnote_github.{} t WHERE t.state = $1", - table_name - ); - sqlx::query_scalar(&count_query) - .bind(state_filter) - .fetch_one(&state.db_pool) - .await - .unwrap_or(0) - } else { - let count_query = format!("SELECT COUNT(*) FROM hyprnote_github.{}", table_name); - sqlx::query_scalar(&count_query) - .fetch_one(&state.db_pool) - .await - .unwrap_or(0) - }; - - let result = serde_json::json!({ - "table": table_name, - "total_count": total_count, - "returned_count": rows.len(), - "limit": limit, - "offset": offset, - "rows": rows, - }); - - Ok(CallToolResult::success(vec![Content::text( - result.to_string(), - )])) -} diff --git a/crates/api-support/src/mcp/tools/search_issues.rs b/crates/api-support/src/mcp/tools/search_issues.rs new file mode 100644 index 0000000000..774da56d37 --- /dev/null +++ b/crates/api-support/src/mcp/tools/search_issues.rs @@ -0,0 +1,38 @@ +use rmcp::{ + ErrorData as McpError, + model::*, + schemars::{self, JsonSchema}, +}; +use serde::Deserialize; + +use crate::github; +use crate::state::AppState; + +#[derive(Debug, Deserialize, JsonSchema)] +pub(crate) struct SearchIssuesParams { + #[schemars(description = "Search query string (e.g. keywords, error messages)")] + pub query: String, + #[schemars(description = "Filter by state: 'open' or 'closed'")] + pub state: Option, + #[schemars(description = "Maximum number of results to return (default: 20, max: 100)")] + pub limit: Option, +} + +pub(crate) async fn search_issues( + state: &AppState, + params: SearchIssuesParams, +) -> Result { + let limit = params.limit.unwrap_or(20).min(100); + + let items = github::search_issues(state, ¶ms.query, params.state.as_deref(), limit) + .await + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::json!({ + "total_results": items.len(), + "issues": items, + }) + .to_string(), + )])) +}