diff --git a/README.md b/README.md index 298544e..dadfb78 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - 📋 **Управление проектами** - добавление и просмотр ваших проектов - 🎯 **Создание задач** - интерактивное создание задач с выбором реальных типов из API - 🔍 **Проверка соединения** - тестирование подключения к Jira +- 📌 **Мои задачи** - просмотр всех задач, назначенных на вас, прямо в терминале - ⚡ **Быстрота** - мгновенное создание задач без ожидания загрузки веб-интерфейса ## 🚀 Установка @@ -69,6 +70,7 @@ fast-task create | `fast-task list-projects` | Просмотр настроенных проектов | | `fast-task test` | Проверка соединения с Jira | | `fast-task create` | Создание новой задачи | +| `fast-task my-issues` | Просмотр задач, назначенных на вас | ## 💡 Примеры использования @@ -90,6 +92,32 @@ Configured projects: MOBILE - Mobile App ``` +### Просмотр назначенных задач +```bash +$ fast-task my-issues +🔍 Fetching your assigned issues... +Found 3 issue(s): + +WEB-42 | In Progress | Fix responsive layout on mobile | https://company.atlassian.net/browse/WEB-42 +WEB-57 | To Do | Add dark mode support | https://company.atlassian.net/browse/WEB-57 +API-103 | In Review | Refactor auth middleware | https://company.atlassian.net/browse/API-103 +``` + +Доступные флаги: +- `--project ` — фильтр по конкретному проекту +- `--all` — включить завершённые задачи (по умолчанию показываются только активные) + +```bash +# Только задачи проекта WEB +$ fast-task my-issues --project WEB + +# Все задачи, включая завершённые +$ fast-task my-issues --all + +# Комбинация флагов +$ fast-task my-issues --project WEB --all +``` + ### Создание задачи ```bash $ fast-task create diff --git a/src/jira_client.rs b/src/jira_client.rs index 9d5cacc..8a38e36 100644 --- a/src/jira_client.rs +++ b/src/jira_client.rs @@ -1,10 +1,65 @@ use thiserror::Error; use crate::config::Config; +use crate::jql::{self, Field, JqlBuilder, SortOrder}; use reqwest::{Client, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::json; +#[derive(Serialize, Deserialize, Debug)] +pub struct SearchResponse { + #[serde(rename = "startAt")] + pub start_at: u16, + #[serde(rename = "maxResults")] + pub max_results: u16, + pub total: u16, + pub issues: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Issue { + pub key: String, + pub fields: IssueFields, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct IssueFields { + pub summary: String, + pub status: IssueStatus, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct IssueStatus { + pub name: String, + #[serde(rename = "statusCategory")] + pub status_category: IssueStatusCategory, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct IssueStatusCategory { + pub key: String, +} + +#[derive(Debug, Clone, Default)] +#[allow(dead_code)] +pub enum IssueStatusFilter { + /// Exclude done/resolved issues (default behavior). + #[default] + ActiveOnly, + /// Include all issues regardless of status. + All, + /// Show only issues with this specific status name. + Only(String), +} + +#[derive(Debug, Clone, Default)] +pub struct MyIssuesQuery { + pub status_filter: IssueStatusFilter, + pub project: Option, +} + +const SEARCH_PAGE_SIZE: u16 = 50; + pub struct JiraClient { client: Client, config: Config, @@ -134,6 +189,80 @@ pub async fn test_connection(client: &JiraClient) -> Result<(), JiraClientError> } } +pub async fn get_my_issues( + jira_client: &JiraClient, + query: &MyIssuesQuery, +) -> Result, JiraClientError> { + let mut builder = JqlBuilder::new().assignee_is_current_user(); + match &query.status_filter { + IssueStatusFilter::ActiveOnly => { + builder = builder.exclude_done(); + } + IssueStatusFilter::All => {} + IssueStatusFilter::Only(status) => { + builder = builder.and( + Field::Status, + jql::Operator::Eq, + jql::Value::Str(status.clone()), + ); + } + } + if let Some(project_key) = &query.project { + builder = builder + .project(project_key) + .map_err(|e| JiraClientError::Request(e.to_string()))?; + } + let jql = builder.order_by(Field::Updated, SortOrder::Desc).build(); + + let api_url = format!( + "{}/rest/api/2/search", + jira_client.config.jira_url.trim_end_matches('/') + ); + + let mut all_issues = Vec::new(); + let max_results = SEARCH_PAGE_SIZE; + let mut start_at = 0; + + loop { + let body = json!({ + "jql": jql, + "startAt": start_at, + "maxResults": max_results, + "fields": ["summary", "status"] + }); + + let response = jira_client + .client + .post(&api_url) + .header("Authorization", &jira_client.auth_header) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|err| JiraClientError::Request(err.to_string()))?; + + if !response.status().is_success() { + return Err(JiraClientError::Response( + response.status(), + response.text().await.unwrap_or_default(), + )); + } + + let search_response: SearchResponse = + response.json().await.map_err(|_| JiraClientError::Parse)?; + + let fetched = search_response.issues.len() as u16; + all_issues.extend(search_response.issues); + start_at += fetched; + + if start_at >= search_response.total || fetched == 0 { + break; + } + } + + Ok(all_issues) +} + pub async fn get_project_issue_types( jira_client: &JiraClient, project_key: &str, diff --git a/src/jql.rs b/src/jql.rs new file mode 100644 index 0000000..26779d1 --- /dev/null +++ b/src/jql.rs @@ -0,0 +1,400 @@ +use std::fmt; +use std::fmt::Write; + +/// JQL field names supported by the builder. +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] +pub enum Field { + Assignee, + Reporter, + Status, + StatusCategory, + Project, + Priority, + Type, + Labels, + Sprint, + Resolution, + Created, + Updated, + Due, +} + +impl fmt::Display for Field { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Field::Assignee => "assignee", + Field::Reporter => "reporter", + Field::Status => "status", + Field::StatusCategory => "statusCategory", + Field::Project => "project", + Field::Priority => "priority", + Field::Type => "type", + Field::Labels => "labels", + Field::Sprint => "sprint", + Field::Resolution => "resolution", + Field::Created => "created", + Field::Updated => "updated", + Field::Due => "due", + }; + f.write_str(name) + } +} + +/// Comparison operators for JQL conditions. +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] +pub enum Operator { + Eq, + NotEq, + Gt, + Gte, + Lt, + Lte, + Contains, + NotContains, + Is, + IsNot, + In, + NotIn, +} + +impl fmt::Display for Operator { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let op = match self { + Operator::Eq => "=", + Operator::NotEq => "!=", + Operator::Gt => ">", + Operator::Gte => ">=", + Operator::Lt => "<", + Operator::Lte => "<=", + Operator::Contains => "~", + Operator::NotContains => "!~", + Operator::Is => "IS", + Operator::IsNot => "IS NOT", + Operator::In => "IN", + Operator::NotIn => "NOT IN", + }; + f.write_str(op) + } +} + +/// Right-hand side value in a JQL condition. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum Value { + /// A quoted string value, e.g. `"Done"` + Str(String), + /// A JQL function call, e.g. `currentUser()` + Func(String), + /// EMPTY / NULL keyword + Empty, + /// A list of values for IN / NOT IN, e.g. `("a", "b")` + List(Vec), +} + +impl fmt::Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Value::Str(s) => write!(f, "\"{}\"", s), + Value::Func(func) => f.write_str(func), + Value::Empty => f.write_str("EMPTY"), + Value::List(items) => { + f.write_char('(')?; + for (i, item) in items.iter().enumerate() { + if i > 0 { + f.write_str(", ")?; + } + write!(f, "\"{}\"", item)?; + } + f.write_char(')') + } + } + } +} + +/// Sort direction. +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] +pub enum SortOrder { + Asc, + Desc, +} + +impl fmt::Display for SortOrder { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SortOrder::Asc => f.write_str("ASC"), + SortOrder::Desc => f.write_str("DESC"), + } + } +} + +/// Boolean conjunction between conditions. +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] +enum Conjunction { + And, + Or, +} + +impl fmt::Display for Conjunction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Conjunction::And => f.write_str(" AND "), + Conjunction::Or => f.write_str(" OR "), + } + } +} + +#[derive(Debug, Clone)] +struct Condition { + conjunction: Option, + field: Field, + operator: Operator, + value: Value, +} + +#[derive(Debug, Clone)] +struct OrderByClause { + field: Field, + order: SortOrder, +} + +/// Builder for constructing JQL query strings. +/// +/// Validates project keys to prevent JQL injection. +#[derive(Debug, Clone)] +#[must_use = "JqlBuilder does nothing until .build() is called"] +pub struct JqlBuilder { + conditions: Vec, + order_by: Vec, +} + +impl JqlBuilder { + pub fn new() -> Self { + Self { + conditions: Vec::new(), + order_by: Vec::new(), + } + } + + /// Add a condition joined by AND (or as the first condition). + pub fn and(mut self, field: Field, op: Operator, value: Value) -> Self { + let conjunction = if self.conditions.is_empty() { + None + } else { + Some(Conjunction::And) + }; + self.conditions.push(Condition { + conjunction, + field, + operator: op, + value, + }); + self + } + + /// Add a condition joined by OR. + #[allow(dead_code)] + pub fn or(mut self, field: Field, op: Operator, value: Value) -> Self { + let conjunction = if self.conditions.is_empty() { + None + } else { + Some(Conjunction::Or) + }; + self.conditions.push(Condition { + conjunction, + field, + operator: op, + value, + }); + self + } + + /// Shortcut: `assignee = currentUser()` + pub fn assignee_is_current_user(self) -> Self { + self.and( + Field::Assignee, + Operator::Eq, + Value::Func("currentUser()".into()), + ) + } + + /// Shortcut: `statusCategory != "Done"` + pub fn exclude_done(self) -> Self { + self.and( + Field::StatusCategory, + Operator::NotEq, + Value::Str("Done".into()), + ) + } + + /// Shortcut: `project = "KEY"` with validation. + /// + /// Returns `Err` if the project key contains invalid characters. + pub fn project(self, key: &str) -> Result { + validate_project_key(key)?; + Ok(self.and(Field::Project, Operator::Eq, Value::Str(key.into()))) + } + + /// Add ORDER BY clause. + pub fn order_by(mut self, field: Field, order: SortOrder) -> Self { + self.order_by.push(OrderByClause { field, order }); + self + } + + /// Build the final JQL string. + pub fn build(self) -> String { + let mut jql = String::new(); + + for cond in &self.conditions { + if let Some(conj) = &cond.conjunction { + let _ = write!(jql, "{}", conj); + } + let _ = write!(jql, "{} {} {}", cond.field, cond.operator, cond.value); + } + + if !self.order_by.is_empty() { + jql.push_str(" ORDER BY "); + for (i, clause) in self.order_by.iter().enumerate() { + if i > 0 { + jql.push_str(", "); + } + let _ = write!(jql, "{} {}", clause.field, clause.order); + } + } + + jql + } +} + +impl Default for JqlBuilder { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone)] +pub struct JqlBuildError { + pub message: String, +} + +impl fmt::Display for JqlBuildError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "JQL build error: {}", self.message) + } +} + +impl std::error::Error for JqlBuildError {} + +/// Project keys must be uppercase alphanumeric + underscore only. +fn validate_project_key(key: &str) -> Result<(), JqlBuildError> { + if key.is_empty() { + return Err(JqlBuildError { + message: "project key cannot be empty".into(), + }); + } + if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(JqlBuildError { + message: format!( + "project key '{}' contains invalid characters (only A-Z, 0-9, _ allowed)", + key + ), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_assignee_query() { + let jql = JqlBuilder::new().assignee_is_current_user().build(); + assert_eq!(jql, "assignee = currentUser()"); + } + + #[test] + fn test_assignee_exclude_done() { + let jql = JqlBuilder::new() + .assignee_is_current_user() + .exclude_done() + .build(); + assert_eq!( + jql, + "assignee = currentUser() AND statusCategory != \"Done\"" + ); + } + + #[test] + fn test_with_project_and_order() { + let jql = JqlBuilder::new() + .assignee_is_current_user() + .exclude_done() + .project("WEB") + .unwrap() + .order_by(Field::Updated, SortOrder::Desc) + .build(); + assert_eq!( + jql, + "assignee = currentUser() AND statusCategory != \"Done\" AND project = \"WEB\" ORDER BY updated DESC" + ); + } + + #[test] + fn test_project_key_validation_rejects_injection() { + let result = + JqlBuilder::new().project("WEB\" OR assignee != currentUser() OR project = \"X"); + assert!(result.is_err()); + } + + #[test] + fn test_project_key_validation_rejects_empty() { + let result = JqlBuilder::new().project(""); + assert!(result.is_err()); + } + + #[test] + fn test_multiple_order_by() { + let jql = JqlBuilder::new() + .assignee_is_current_user() + .order_by(Field::Priority, SortOrder::Desc) + .order_by(Field::Updated, SortOrder::Desc) + .build(); + assert_eq!( + jql, + "assignee = currentUser() ORDER BY priority DESC, updated DESC" + ); + } + + #[test] + fn test_or_conjunction() { + let jql = JqlBuilder::new() + .and(Field::Status, Operator::Eq, Value::Str("Open".into())) + .or(Field::Status, Operator::Eq, Value::Str("Reopened".into())) + .build(); + assert_eq!(jql, "status = \"Open\" OR status = \"Reopened\""); + } + + #[test] + fn test_in_operator() { + let jql = JqlBuilder::new() + .and( + Field::Project, + Operator::In, + Value::List(vec!["WEB".into(), "API".into()]), + ) + .build(); + assert_eq!(jql, "project IN (\"WEB\", \"API\")"); + } + + #[test] + fn test_is_empty() { + let jql = JqlBuilder::new() + .and(Field::Labels, Operator::Is, Value::Empty) + .build(); + assert_eq!(jql, "labels IS EMPTY"); + } +} diff --git a/src/main.rs b/src/main.rs index 6dd198a..0789145 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,13 +8,17 @@ use validator::{ValidateEmail, ValidateUrl}; mod config; mod jira_client; +mod jql; use config::Config; use jira_client::JiraClient; use crate::{ config::{CONFIG_PATH, LoadConfigError, load_config, save_config}, - jira_client::{create_issue, get_project_issue_types, test_connection}, + jira_client::{ + IssueStatusFilter, MyIssuesQuery, create_issue, get_my_issues, get_project_issue_types, + test_connection, + }, }; #[derive(Parser)] @@ -39,6 +43,16 @@ enum Commands { Test, /// Create a new issue Create, + /// List issues assigned to you + MyIssues { + /// Filter by project key + #[arg(short, long)] + project: Option, + + /// Include done/closed issues + #[arg(long)] + all: bool, + }, } #[derive(Debug, Error)] @@ -106,6 +120,45 @@ async fn main() { } } + Commands::MyIssues { project, all } => { + if !config.is_configured() { + println!("❌ Please configure Jira connection first:"); + println!("fast-task config"); + return; + } + + let query = MyIssuesQuery { + status_filter: if all { + IssueStatusFilter::All + } else { + IssueStatusFilter::ActiveOnly + }, + project, + }; + + println!("🔍 Fetching your assigned issues..."); + match get_my_issues(&JiraClient::new(&config), &query).await { + Ok(issues) if issues.is_empty() => { + println!("No issues found."); + } + Ok(issues) => { + println!("Found {} issue(s):\n", issues.len()); + for issue in &issues { + println!( + "{} | {} | {} | {}", + issue.key, + issue.fields.status.name, + issue.fields.summary, + config.issue_url(&issue.key) + ); + } + } + Err(e) => { + println!("❌ Failed to fetch issues: {}", e); + } + } + } + Commands::Create => { if !config.is_configured() { println!("❌ Please configure Jira connection first:");