Task: gitlab.invite-user
Goal: Invite Jakub Klinkovsky and Benoit Blanchon as collaborator to gimmiethat.space repo
Task configuration:
collaborator_account_list: Jakub Klinkovsky and Benoit Blanchon
display_repo: gimmiethat.space
namespace: byteblaze
project_name: gimmiethat.space

Validation code:
from typing import Any, Dict, List
from urllib.parse import quote

import requests
import json
import os

def _get_headers():
    return {
        "Content-Type": "application/json",
        "Accept": "application/json",
        **json.loads(os.getenv("GITLAB_EXTRA_HTTP_HEADERS", "{}")),
    }

def _parse_user_list(value):
    """Parse a user list from various formats: comma-separated, 'and'-separated, or single."""
    # Replace " and " with comma for uniform splitting
    value = value.replace(" and ", ", ")
    return [name.strip() for name in value.split(",") if name.strip()]

def validate(messages: List[Dict[str, str]], config: Dict[str, Any]) -> int:

    if not messages:
        raise ValueError("Messages cannot be empty")

    # Check whether we have the necessary information to validate
    if not "namespace" in config or not "project_name" in config:
        raise ValueError("Target must contain 'namespace' and 'project_name'")

    # Get user list from whichever field is available
    if "user_list" in config:
        user_list_raw = config["user_list"]
    elif "collaborator_account_list" in config:
        user_list_raw = config["collaborator_account_list"]
    elif "name" in config:
        user_list_raw = config["name"]
    else:
        raise ValueError(
            "Target must contain 'user_list', 'collaborator_account_list', or 'name'"
        )

    user_list = [name.lower() for name in _parse_user_list(user_list_raw)]

    # Build the project id
    project_id = quote(f"{config['namespace']}/{config['project_name']}", safe="")

    # Build the url
    url = os.getenv("GITLAB_BASE_URL", "").rstrip("/") + "/api/v4/projects/" + project_id + "/members"

    response = requests.get(url=url, headers=_get_headers())
    if not response.status_code == 200:
        return 0
    result = response.json()

    # Role to access level mapping
    role_to_access_level = {
        "guest": 10,
        "planner": 15,
        "reporter": 20,
        "developer": 30,
        "maintainer": 40,
        "owner": 50,
        "admin": 60,
    }

    expected_role = config.get("role")
    expected_access_level = role_to_access_level.get(expected_role) if expected_role else None

    count = 0
    for user_name in user_list:
        found = False
        for elem in result:
            # Check both username and display name (substring match for flexibility)
            if (user_name in elem["username"].lower()
                or user_name in elem["name"].lower()
                or elem["username"].lower() in user_name
                or elem["name"].lower() in user_name):
                # If role is specified, also check access level
                if expected_access_level is not None:
                    if elem["access_level"] == expected_access_level:
                        found = True
                        break
                else:
                    found = True
                    break
        if found:
            count += 1

    return 1 if count == len(user_list) else 0