-
Notifications
You must be signed in to change notification settings - Fork 10
Closed
Description
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.rstests/users_comprehensive_test.rstests/groups_test.rstests/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 userGET /rest/api/2/user/search- Search usersGET /rest/api/2/user/assignable/search- Get assignable usersGET /rest/api/2/user/groups- Get user groupsGET /rest/api/2/user/permission/search- Get users with permission
Group Endpoints
GET /rest/api/2/groups/picker- List groupsGET /rest/api/2/group- Get groupGET /rest/api/2/group/member- Get group membersPOST /rest/api/2/group- Create groupDELETE /rest/api/2/group- Delete groupPOST /rest/api/2/group/user- Add user to groupDELETE /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
# Panicssections - 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
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels