Commit e4368bf4 authored by Daniele Rossetti's avatar Daniele Rossetti
Browse files

feat: support parent field in work items

parent 4d955ada
Loading
Loading
Loading
Loading
Loading
+3 −2
Original line number Diff line number Diff line
@@ -26,6 +26,7 @@ impl SourceAnalyzer for IssuesSourceAnalyzer {
                | Status
                | Label
                | Epic
                | Parent
                | Created
                | Updated
                | Assignee
@@ -114,8 +115,8 @@ impl SourceAnalyzer for IssuesSourceAnalyzer {
                    | EnumLike(vec!["current".into()])
                    | ReferenceLike(IterationRef)
            }
            Epic => {
                // Use the new field mapping abstraction for Epic field
            Epic | Parent => {
                // Use the new field mapping abstraction for Epic and Parent fields
                let context = Context {
                    source: Some(crate::types::Source::Issues),
                    ..Context::default()
+34 −2
Original line number Diff line number Diff line
@@ -85,7 +85,7 @@ impl GraphQLFilters {
            constraints.insert("excludeProjects".to_string(), Bool(true));
        }

        if query.contains_field(&Epic) {
        if query.contains_field(&Epic) || query.contains_field(&Parent) {
            constraints.insert(
                match FeatureFlag::GlqlWorkItems.get() {
                    true => "includeDescendantWorkItems".to_string(),
@@ -217,7 +217,7 @@ fn to_graphql_filter_value(expr: &Expression, context: &mut Context) -> Value {
        (Assignee, Number(n)) => Quoted(n.to_string()),
        (Label | Approver, Token(s)) => Quoted(s.clone()),
        (Label | Assignee | Approver, Null) => Quoted("NONE".to_string()),
        (Weight | Epic, Null) => Token("NONE".to_string()),
        (Weight | Epic | Parent, Null) => Token("NONE".to_string()),
        (Weight, Number(n)) => Quoted(n.to_string()),
        (State, Token(s)) => Token(s.to_lowercase()),
        (Status, Quoted(s)) => Token(format!("{{name: \"{s}\"}}")),
@@ -257,6 +257,27 @@ fn to_graphql_filter_value(expr: &Expression, context: &mut Context) -> Value {
                v.clone()
            }
        }
        (Parent, v) => {
            if let Some(mapping) = get_field_mapping(&Parent, context) {
                use crate::field_mapping::transform_epic_subquery_with_variable_type;
                let subquery = transform_epic_subquery_with_variable_type(
                    &Parent,
                    v,
                    context,
                    &mapping.variable_type,
                );

                // graphql variables are not automatically coerced to lists,
                // so we need to wrap the subquery in a list, if it isn't already
                if matches!(subquery, Subquery(..)) {
                    List(vec![subquery])
                } else {
                    subquery
                }
            } else {
                v.clone()
            }
        }
        (_, v) => v.clone(),
    }
}
@@ -335,6 +356,17 @@ fn to_graphql_filter_key(expr: &Expression, context: &Context) -> String {
                }
            }
        }
        (Parent, _, _) => {
            if let Some(mapping) = get_field_mapping(&Parent, context) {
                (mapping.get_attribute)(&mapping.field_type, &expr.value)
            } else {
                // Fallback
                match &expr.value {
                    Null | Token(_) => "parentWildcardId".to_string(),
                    _ => "parentIds".to_string(),
                }
            }
        }
        (Label, In, _) => pluralize_attribute("labelName").to_string(),
        (Label, _, _) => "labelName".to_string(),
        (Health, _, _) => "healthStatusFilter".to_string(),
+86 −25
Original line number Diff line number Diff line
@@ -3,7 +3,6 @@ use crate::types::{
    Context, Field, Field::*, FieldMapping, FieldType, FieldType::*, ReferenceType::*,
    RelationshipType::*, Source::*, Value,
};
use crate::utils::common::parse_epic_reference;
use crate::utils::feature_flags::FeatureFlag;

/// Attribute function for work items epic mapping (parent work items)
@@ -19,6 +18,19 @@ fn epic_work_items_attribute(_field_type: &FieldType, value: &Value) -> String {
    }
}

/// Attribute function for parent work items mapping
fn parent_work_items_attribute(_field_type: &FieldType, value: &Value) -> String {
    use crate::types::Value::*;

    // For parent field, wildcard values (null/tokens) use wildcard attribute
    // All other values (including lists) use parentIds
    if matches!(value, Null | Token(_)) {
        "parentWildcardId".to_string()
    } else {
        "parentIds".to_string()
    }
}

/// Attribute function for legacy epic mapping
fn epic_legacy_attribute(field_type: &FieldType, value: &Value) -> String {
    use crate::types::Value::*;
@@ -58,13 +70,25 @@ pub fn get_field_mapping(field: &Field, context: &Context) -> Option<FieldMappin
            get_attribute: epic_legacy_attribute,
        }),

        // Future: Parent field implementation would go here
        // (Field::Parent, true, Some(Issues)) => Some(FieldMapping {
        //     field_type: StringLike | NumberLike | ReferenceLike(WorkItemRef) |
        //                ListLike(HasOne, Box::new(StringLike | NumberLike | ReferenceLike(WorkItemRef))),
        //     variable_type: "WorkItemID!".to_string(),
        //     get_attribute: parent_work_items_attribute, // Different logic - no group derivation
        // }),
        // Parent field with work items API
        (Field::Parent, true, Some(Issues)) => Some(FieldMapping {
            field_type: StringLike
                | NumberLike
                | ReferenceLike(WorkItemRef)
                | ReferenceLike(EpicRef)
                | ListLike(
                    HasOne,
                    Box::new(
                        StringLike
                            | NumberLike
                            | ReferenceLike(WorkItemRef)
                            | ReferenceLike(EpicRef),
                    ),
                ),
            variable_type: "WorkItemID!".to_string(),
            get_attribute: parent_work_items_attribute,
        }),

        _ => None,
    }
}
@@ -72,40 +96,51 @@ pub fn get_field_mapping(field: &Field, context: &Context) -> Option<FieldMappin
/// Transforms a value for work items epic references
/// Handles epic references like "&123" or "group&123" and generates work items API subqueries
pub fn transform_to_work_item_epic_subquery(v: &Value, context: &mut Context) -> Value {
    transform_epic_subquery_with_variable_type(v, context, "WorkItemID!")
    transform_epic_subquery_with_variable_type(&Epic, v, context, "WorkItemID!")
}

/// Transforms a value for legacy epic references
/// Handles epic references for legacy API and generates legacy epics API subqueries
pub fn transform_to_legacy_epic_subquery(v: &Value, context: &mut Context) -> Value {
    transform_epic_subquery_with_variable_type(v, context, "String")
    transform_epic_subquery_with_variable_type(&Epic, v, context, "String")
}

/// Common epic subquery transformation logic with configurable variable type
fn transform_epic_subquery_with_variable_type(
pub fn transform_epic_subquery_with_variable_type(
    field: &Field,
    v: &Value,
    context: &mut Context,
    variable_type: &str,
) -> Value {
    use crate::types::{Value::*, Variable};
    use crate::utils::common::unique_id;
    use crate::utils::common::{parse_work_item_reference, unique_id};
    use std::path::Path;

    let id = match v {
        Number(n) => n.to_string(),
        Quoted(s) => s.clone(),
        Reference(_, s) => s.clone(),
        Reference(ref_type, s) => {
            let id_str = s.clone();
            // Add prefix if not present
            match ref_type {
                EpicRef if !id_str.contains('&') => format!("&{}", id_str),
                WorkItemRef if !id_str.contains('#') => format!("#{}", id_str),
                _ => id_str,
            }
        }
        List(arr) => {
            return List(
                arr.iter()
                    .map(|v| transform_epic_subquery_with_variable_type(v, context, variable_type))
                    .map(|v| {
                        transform_epic_subquery_with_variable_type(field, v, context, variable_type)
                    })
                    .collect(),
            );
        }
        _ => return v.clone(),
    };

    let epic_group = match &context.group {
    let parent_group = match &context.group {
        Some(group) => group.clone(),
        None => {
            let project_path = context.project.clone().unwrap_or("".to_string());
@@ -126,27 +161,53 @@ fn transform_epic_subquery_with_variable_type(
        ..Context::default()
    };

    let variable_key = unique_id("epicId");
    let default_query = |group: &str, id: &str| {
        format!("type = Epic AND id = {id} AND includeSubgroups = false AND group = \"{group}\"")
    };
    let is_epic = matches!(field.dealias(), Epic);
    let variable_key = unique_id(if is_epic { "epicId" } else { "parentId" });

    let subquery = |query| {
    let subquery = |query: String| {
        context.variables.push(
            Variable::new(&variable_key, variable_type)
                .with_value(&compile_glql(query, &mut subquery_context).output),
                .with_value(&compile_glql(&query, &mut subquery_context).output),
        );

        Subquery(
            query.to_string(),
            query,
            Variable::new(&variable_key, variable_type),
            Box::new(subquery_context),
        )
    };

    // Parse the epic reference using the utility function
    let (group_path, epic_id) = parse_epic_reference(&id, &epic_group);
    subquery(&default_query(&group_path, &epic_id))
    // Parse the work item reference using the utility function
    if is_epic {
        use crate::utils::common::parse_epic_reference;
        let (group_path, epic_id) = parse_epic_reference(&id, &parent_group);
        subquery(format!(
            "type = Epic AND id = {epic_id} AND includeSubgroups = false AND group = \"{group_path}\""
        ))
    } else {
        // Parent field - can be either epic or work item reference
        let (group_path, project_path, work_item_id) =
            parse_work_item_reference(&id, &parent_group);

        let query = if let Some(proj_path) = project_path {
            if !proj_path.is_empty() {
                format!("id = {work_item_id} AND project = \"{proj_path}\"")
            } else if let Some(grp_path) = group_path {
                format!(
                    "type = Epic AND id = {work_item_id} AND group = \"{grp_path}\" AND includeSubgroups = false"
                )
            } else {
                format!("id = {work_item_id} AND group = \"{parent_group}\"")
            }
        } else if let Some(grp_path) = group_path {
            format!(
                "type = Epic AND id = {work_item_id} AND group = \"{grp_path}\" AND includeSubgroups = false"
            )
        } else {
            format!("id = {work_item_id} AND group = \"{parent_group}\"")
        };
        subquery(query)
    }
}

#[cfg(test)]
+65 −7
Original line number Diff line number Diff line
use super::helpers::parse_reference;
use crate::errors::{GlqlError, GlqlErrorKind::*};
use crate::types::{
    ReferenceType,
    ReferenceType::*,
    Value::{self, *},
};
@@ -100,27 +101,60 @@ pub fn iteration(input: &str) -> IResult<&str, Value, GlqlError> {
    parse_reference(IterationRef, matcher, InvalidIteration, false)(input)
}

// Parser for epic references
// Format: [group_path]&<epic_id>
// Generic parser for work item references (epics and issues)
// Format: [path]<separator><id>
// Examples:
//   &123 - Epic with ID 123 in the current context
//   gitlab-org&123 - Epic with ID 123 in the gitlab-org group
//   #456 - Issue with ID 456 in the current context
//   gitlab-org/gitlab#456 - Issue with ID 456 in the gitlab-org/gitlab project
//
// Constraints:
// - Path can only contain alphanumeric characters, hyphens, and forward slashes
// - Path can only contain alphanumeric characters, hyphens, underscores, dots, and forward slashes
// - ID must be numeric
pub fn epic(input: &str) -> IResult<&str, Value, GlqlError> {
fn work_item(
    input: &str,
    separator: char,
    ref_type: ReferenceType,
) -> IResult<&str, Value, GlqlError> {
    let (input, path) = take_while(|c: char| c.is_alphanumeric() || "/-_.".contains(c))(input)?;
    let (input, _) = tag("&")(input)?;
    let separator_str = separator.to_string();
    let (input, _) = tag(separator_str.as_str())(input)?;
    let (input, id) = take_while1(|c: char| c.is_numeric())(input)?;

    let reference = if path.is_empty() {
        id.to_string()
    } else {
        format!("{path}&{id}")
        format!("{path}{separator}{id}")
    };

    Ok((input, Reference(EpicRef, reference)))
    Ok((input, Reference(ref_type, reference)))
}

// Parser for epic references
// Format: [group_path]&<epic_id>
// Examples:
//   &123 - Epic with ID 123 in the current context
//   gitlab-org&123 - Epic with ID 123 in the gitlab-org group
//
// Constraints:
// - Path can only contain alphanumeric characters, hyphens, and forward slashes
// - ID must be numeric
pub fn epic(input: &str) -> IResult<&str, Value, GlqlError> {
    work_item(input, '&', EpicRef)
}

// Parser for issue/work item references
// Format: [project_path]#<issue_id>
// Examples:
//   #123 - Issue with ID 123 in the current context
//   gitlab-org/gitlab#123 - Issue with ID 123 in the gitlab-org/gitlab project
//
// Constraints:
// - Path can only contain alphanumeric characters, hyphens, underscores, dots, and forward slashes
// - ID must be numeric
pub fn issue(input: &str) -> IResult<&str, Value, GlqlError> {
    work_item(input, '#', WorkItemRef)
}

#[cfg(test)]
@@ -282,4 +316,28 @@ mod tests {
        test_parser_error(epic, "path/to/project with spaces");
        test_parser_error(epic, "path/to/project-without-id");
    }

    #[test]
    fn test_issue() {
        test_parser_result(
            issue,
            "path/to/project-with_hyphens-underscores.and.dots#123",
            Reference(
                WorkItemRef,
                "path/to/project-with_hyphens-underscores.and.dots#123".to_string(),
            ),
        );
        test_parser_result(
            issue,
            "gitlab-org/gitlab#123",
            Reference(WorkItemRef, "gitlab-org/gitlab#123".to_string()),
        );
        test_parser_result(issue, "#123", Reference(WorkItemRef, "123".to_string()));
        test_parser_error(issue, "");
        test_parser_error(issue, "#");
        test_parser_error(issue, "#\"\"");
        test_parser_error(issue, "123");
        test_parser_error(issue, "path/to/project with spaces");
        test_parser_error(issue, "path/to/project-without-id");
    }
}
+2 −1
Original line number Diff line number Diff line
use super::{
    literals::{array, bool, function, null, number, string},
    special::{date, epic, iteration, label, milestone, relative_date, token, username},
    special::{date, epic, issue, iteration, label, milestone, relative_date, token, username},
};
use crate::errors::GlqlError;
use crate::types::Value;
@@ -22,6 +22,7 @@ pub fn non_array_value(input: &str) -> IResult<&str, Value, GlqlError> {
        label,
        iteration,
        epic,
        issue,
        string,
        number,
        function,
Loading