Skip to content

feat: Add User and Group management modules #96

@avrabe

Description

@avrabe

Summary

Add comprehensive User and Group management modules to provide user administration capabilities required for MCP integration and automation scenarios.

Background

User and group management is essential for:

  • MCP servers that need to assign issues or check permissions
  • Automation workflows that interact with users and groups
  • Administrative operations and user discovery
  • Permission-based filtering and operations

Proposed Module Structure

New File: src/users.rs

//! Interfaces for accessing and managing users

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use url::form_urlencoded;

use crate::{Jira, Result, User, Group};

/// User management interface
#[derive(Debug)]
pub struct Users {
    jira: Jira,
}

impl Users {
    pub fn new(jira: &Jira) -> Users {
        Users { jira: jira.clone() }
    }

    /// Get user by username or account ID
    /// # Panics
    /// This function will panic if the user ID is invalid
    pub fn get<I>(&self, id: I) -> Result<User>
    where
        I: Into<String>,
    {
        let id = id.into();
        self.jira.get::<User>("api", &format!("/user?username={}", id))
    }

    /// Get user by account ID (Jira Cloud)
    /// # Panics
    /// This function will panic if the account ID is invalid
    pub fn get_by_account_id<I>(&self, account_id: I) -> Result<User>
    where
        I: Into<String>,
    {
        let account_id = account_id.into();
        self.jira.get::<User>("api", &format!("/user?accountId={}", account_id))
    }

    /// Search for users by query
    pub fn search(&self, options: &UserSearchOptions) -> Result<Vec<User>> {
        let mut path = vec!["/user/search".to_owned()];
        let query_options = options.serialize().unwrap_or_default();
        if !query_options.is_empty() {
            let query = form_urlencoded::Serializer::new(query_options).finish();
            path.push(query);
        }
        self.jira.get::<Vec<User>>("api", path.join("?").as_ref())
    }

    /// Get users assignable to a project
    pub fn get_assignable_users<P>(&self, project: P, options: &AssignableUserOptions) -> Result<Vec<User>>
    where
        P: Into<String>,
    {
        let project = project.into();
        let mut query_options = options.serialize().unwrap_or_default();
        query_options.push(("project".to_owned(), project));
        
        let query = form_urlencoded::Serializer::new(query_options).finish();
        self.jira.get::<Vec<User>>("api", &format!("/user/assignable/search?{}", query))
    }

    /// Get users assignable to an issue
    pub fn get_assignable_users_for_issue<I>(&self, issue_key: I, options: &AssignableUserOptions) -> Result<Vec<User>>
    where
        I: Into<String>,
    {
        let issue_key = issue_key.into();
        let mut query_options = options.serialize().unwrap_or_default();
        query_options.push(("issueKey".to_owned(), issue_key));
        
        let query = form_urlencoded::Serializer::new(query_options).finish();
        self.jira.get::<Vec<User>>("api", &format!("/user/assignable/search?{}", query))
    }

    /// Get groups for a user
    /// # Panics
    /// This function will panic if the user is not found
    pub fn get_user_groups<I>(&self, user_id: I) -> Result<Vec<Group>>
    where
        I: Into<String>,
    {
        let user_id = user_id.into();
        self.jira.get::<GroupsResponse>("api", &format!("/user/groups?username={}", user_id))
            .map(|response| response.groups)
    }

    /// Find users with browse permission for a project
    pub fn get_users_with_browse_permission<P>(&self, project_key: P, options: &UserSearchOptions) -> Result<Vec<User>>
    where
        P: Into<String>,
    {
        let project_key = project_key.into();
        let mut query_options = options.serialize().unwrap_or_default();
        query_options.push(("project".to_owned(), project_key));
        query_options.push(("permission".to_owned(), "BROWSE".to_owned()));
        
        let query = form_urlencoded::Serializer::new(query_options).finish();
        self.jira.get::<Vec<User>>("api", &format!("/user/permission/search?{}", query))
    }
}

New File: src/groups.rs

//! Interfaces for accessing and managing groups

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use url::form_urlencoded;

use crate::{Jira, Result, User, Group};

/// Group management interface  
#[derive(Debug)]
pub struct Groups {
    jira: Jira,
}

impl Groups {
    pub fn new(jira: &Jira) -> Groups {
        Groups { jira: jira.clone() }
    }

    /// List all groups
    pub fn list(&self, options: &GroupSearchOptions) -> Result<GroupSearchResults> {
        let mut path = vec!["/groups/picker".to_owned()];
        let query_options = options.serialize().unwrap_or_default();
        if !query_options.is_empty() {
            let query = form_urlencoded::Serializer::new(query_options).finish();
            path.push(query);
        }
        self.jira.get::<GroupSearchResults>("api", path.join("?").as_ref())
    }

    /// Get group by name
    /// # Panics
    /// This function will panic if the group name is invalid
    pub fn get<N>(&self, name: N) -> Result<Group>
    where
        N: Into<String>,
    {
        let name = name.into();
        self.jira.get::<Group>("api", &format!("/group?groupname={}", name))
    }

    /// Get members of a group
    /// # Panics  
    /// This function will panic if the group is not found
    pub fn get_members<N>(&self, group_name: N, options: &GroupMemberOptions) -> Result<GroupMembersResponse>
    where
        N: Into<String>,
    {
        let group_name = group_name.into();
        let mut query_options = options.serialize().unwrap_or_default();
        query_options.push(("groupname".to_owned(), group_name));
        
        let query = form_urlencoded::Serializer::new(query_options).finish();
        self.jira.get::<GroupMembersResponse>("api", &format!("/group/member?{}", query))
    }

    /// Create a new group
    /// # Panics
    /// This function will panic if group creation fails
    pub fn create(&self, group: CreateGroup) -> Result<Group> {
        self.jira.post::<CreateGroup, Group>("api", "/group", group)
    }

    /// Delete a group
    /// # Panics
    /// This function will panic if group deletion fails
    pub fn delete<N>(&self, group_name: N, swap_group: Option<String>) -> Result<()>
    where
        N: Into<String>,
    {
        let group_name = group_name.into();
        let mut query = format!("groupname={}", group_name);
        if let Some(swap) = swap_group {
            query.push_str(&format!("&swapGroup={}", swap));
        }
        self.jira.delete::<()>("api", &format!("/group?{}", query))
    }

    /// Add user to group
    /// # Panics
    /// This function will panic if the user cannot be added to the group
    pub fn add_user<G, U>(&self, group_name: G, username: U) -> Result<Group>
    where
        G: Into<String>,
        U: Into<String>,
    {
        let group_name = group_name.into();
        let add_user_request = AddUserToGroup {
            name: username.into(),
        };
        self.jira.post::<AddUserToGroup, Group>(
            "api", 
            &format!("/group/user?groupname={}", group_name), 
            add_user_request
        )
    }

    /// Remove user from group
    /// # Panics
    /// This function will panic if the user cannot be removed from the group
    pub fn remove_user<G, U>(&self, group_name: G, username: U) -> Result<()>
    where
        G: Into<String>,
        U: Into<String>,
    {
        let group_name = group_name.into();
        let username = username.into();
        self.jira.delete::<()>(
            "api", 
            &format!("/group/user?groupname={}&username={}", group_name, username)
        )
    }
}

New Data Structures

Add to src/rep.rs or create new files:

User-related Types

#[derive(Serialize, Debug)]
pub struct UserSearchOptions {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub username: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub query: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_at: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_results: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub include_active: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub include_inactive: Option<bool>,
}

#[derive(Serialize, Debug)]
pub struct AssignableUserOptions {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub username: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_at: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_results: Option<u64>,
}

#[derive(Deserialize, Debug)]
pub struct GroupsResponse {
    pub groups: Vec<Group>,
}

Group-related Types

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Group {
    pub name: String,
    #[serde(rename = "self")]
    pub self_link: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub users: Option<GroupUsers>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expand: Option<String>,
}

#[derive(Deserialize, Debug)]
pub struct GroupUsers {
    pub size: u32,
    pub items: Vec<User>,
    #[serde(rename = "max-results")]
    pub max_results: u32,
    #[serde(rename = "start-index")]
    pub start_index: u32,
    #[serde(rename = "end-index")]
    pub end_index: u32,
}

#[derive(Serialize, Debug)]
pub struct GroupSearchOptions {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub query: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exclude: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_results: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user_name: Option<String>,
}

#[derive(Deserialize, Debug)]
pub struct GroupSearchResults {
    pub header: String,
    pub total: u64,
    pub groups: Vec<GroupResult>,
}

#[derive(Deserialize, Debug)]
pub struct GroupResult {
    pub name: String,
    pub html: String,
    pub labels: Vec<GroupLabel>,
}

#[derive(Deserialize, Debug)]
pub struct GroupLabel {
    pub text: String,
    pub title: String,
    #[serde(rename = "type")]
    pub label_type: String,
}

#[derive(Serialize, Debug)]
pub struct GroupMemberOptions {
    #[serde(rename = "includeInactiveUsers", skip_serializing_if = "Option::is_none")]
    pub include_inactive_users: Option<bool>,
    #[serde(rename = "startAt", skip_serializing_if = "Option::is_none")]
    pub start_at: Option<u64>,
    #[serde(rename = "maxResults", skip_serializing_if = "Option::is_none")]
    pub max_results: Option<u64>,
}

#[derive(Deserialize, Debug)]
pub struct GroupMembersResponse {
    #[serde(rename = "self")]
    pub self_link: String,
    #[serde(rename = "maxResults")]
    pub max_results: u32,
    #[serde(rename = "startAt")]
    pub start_at: u32,
    pub total: u32,
    #[serde(rename = "isLast")]
    pub is_last: bool,
    pub values: Vec<User>,
}

#[derive(Serialize, Debug)]
pub struct CreateGroup {
    pub name: String,
}

#[derive(Serialize, Debug)]
pub struct AddUserToGroup {
    pub name: String,
}

Integration Requirements

Update src/lib.rs

pub mod users;
pub mod groups;
pub use crate::users::*;
pub use crate::groups::*;

Update src/sync.rs

impl Jira {
    /// Access to user operations
    pub fn users(&self) -> Users {
        Users::new(self)
    }

    /// Access to group operations
    pub fn groups(&self) -> Groups {
        Groups::new(self)
    }
}

Update src/async.rs (for async feature)

impl Jira {
    /// Access to user operations
    pub fn users(&self) -> AsyncUsers {
        AsyncUsers::new(self)
    }

    /// Access to group operations
    pub fn groups(&self) -> AsyncGroups {
        AsyncGroups::new(self)
    }
}

Testing Requirements

Create comprehensive test files:

  • tests/users_test.rs
  • tests/users_comprehensive_test.rs
  • tests/groups_test.rs
  • tests/groups_comprehensive_test.rs
#[cfg(test)]
mod users_tests {
    use super::*;
    use mockito::{mock, Matcher};

    #[test]
    fn test_get_user() {
        let _m = mock("GET", "/rest/api/2/user?username=testuser")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"name":"testuser","displayName":"Test User"}"#)
            .create();
            
        // Test implementation
    }

    #[test]
    fn test_search_users() {
        // Test implementation
    }

    #[test]
    fn test_get_assignable_users() {
        // Test implementation
    }
}

API Endpoints Covered

User Endpoints

  • GET /rest/api/2/user - Get user
  • GET /rest/api/2/user/search - Search users
  • GET /rest/api/2/user/assignable/search - Get assignable users
  • GET /rest/api/2/user/groups - Get user groups
  • GET /rest/api/2/user/permission/search - Get users with permission

Group Endpoints

  • GET /rest/api/2/groups/picker - List groups
  • GET /rest/api/2/group - Get group
  • GET /rest/api/2/group/member - Get group members
  • POST /rest/api/2/group - Create group
  • DELETE /rest/api/2/group - Delete group
  • POST /rest/api/2/group/user - Add user to group
  • DELETE /rest/api/2/group/user - Remove user from group

Implementation Guidelines

Consistency Requirements

  • Follow existing module patterns (Issues, Boards, etc.)
  • Use same error handling approach
  • Implement both sync and async versions
  • Use existing parameter patterns (I: Into<String>)
  • Follow same documentation style with # Panics sections
  • Use existing search options patterns

Error Handling

  • Use existing Result<T> type
  • Map appropriate HTTP status codes
  • Handle user/group not found scenarios
  • Handle permission errors appropriately

Examples

Create examples/user_management.rs

//! User and group management example

use gouqi::{Credentials, Jira, UserSearchOptions, GroupSearchOptions};
use std::env;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let jira = Jira::new(
        env::var("JIRA_HOST")?,
        Credentials::Basic(env::var("JIRA_USER")?, env::var("JIRA_PASS")?),
    )?;

    // Search for users
    let search_options = UserSearchOptions {
        query: Some("john".to_string()),
        max_results: Some(10),
        ..Default::default()
    };
    
    let users = jira.users().search(&search_options)?;
    println!("Found {} users", users.len());

    // List groups
    let group_options = GroupSearchOptions {
        query: Some("developers".to_string()),
        max_results: Some(5),
        ..Default::default()
    };
    
    let groups = jira.groups().list(&group_options)?;
    println!("Found {} groups", groups.total);

    Ok(())
}

Acceptance Criteria

  • All user management operations implemented
  • All group management operations implemented
  • Both sync and async versions available
  • Comprehensive test coverage (>90%)
  • Documentation with examples
  • Follows existing code patterns
  • No breaking changes to existing API
  • Error handling consistent with other modules
  • Search functionality with proper pagination

Priority

High - Essential for MCP integration and user-based automation

Labels

enhancement, api, mcp-preparation, module-creation, user-management

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions