Task: gitlab.add-followed-users-to-new-repo
Goal: Follow patou, egpast, westurner, jontutcher on Gitlab and put them in a new group "crew". Create a new private project "a temporary name until we come up with a better one" afterwards and add them as collaborators
Task configuration:
account_list: patou, egpast, westurner, jontutcher
group_name: crew
visibility_level: private
project_name: a temporary name until we come up with a better one

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 _get_current_user():
    url = os.getenv("GITLAB_BASE_URL", "").rstrip("/") + "/api/v4/user"
    response = requests.get(url=url, headers=_get_headers())
    if response.status_code == 200:
        return response.json()
    return None

def _find_project(config):
    """Find the project, handling cases where namespace may be missing."""
    base_url = os.getenv("GITLAB_BASE_URL", "").rstrip("/")

    if "namespace" in config:
        project_id = quote(f"{config['namespace']}/{config['project_name']}", safe="")
        url = base_url + "/api/v4/projects/" + project_id
        response = requests.get(url=url, headers=_get_headers())
        if response.status_code == 200:
            return response.json()

    # Fallback: search for the project by name, owned by the current user
    url = base_url + "/api/v4/projects"
    response = requests.get(
        url=url,
        headers=_get_headers(),
        params={"search": config["project_name"], "owned": True},
    )
    if response.status_code != 200:
        return None

    for project in response.json():
        if project["name"] == config["project_name"]:
            return project

    return None

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

    if not messages:
        return 0

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

    # Parse account list (comma-separated string)
    if isinstance(config["account_list"], list):
        accounts = config["account_list"]
    else:
        accounts = [a.strip() for a in config["account_list"].split(",")]

    ## --- verify following users first --- ###

    # Get the current user
    current_user = _get_current_user()
    if current_user is None:
        return 0
    user_id = current_user["id"]

    # Get all users that this user follows
    following_url = os.getenv("GITLAB_BASE_URL", "").rstrip("/") + "/api/v4/users/" + str(user_id) + "/following"

    # Handle pagination to get all followed users
    all_followed_users = []
    page = 1
    per_page = 100

    while True:
        response = requests.get(
            url=following_url,
            headers=_get_headers(),
            params={"page": page, "per_page": per_page},
        )

        if response.status_code != 200:
            return 0

        followed_users = response.json()
        all_followed_users.extend(followed_users)

        if len(followed_users) < per_page:
            break
        page += 1

    # Extract usernames and names of followed users
    followed_usernames = [user["username"].lower() for user in all_followed_users]
    followed_names = [user["name"].lower() for user in all_followed_users]

    # Check if every account in account_list is followed
    for account in accounts:
        account_lower = account.lower().strip()
        found = (
            account_lower in followed_usernames
            or any(account_lower in name for name in followed_names)
        )
        if not found:
            return 0

    ## --- verify group membership --- ###

    # group_members defaults to account_list if not provided
    group_members_raw = config.get("group_members", config["account_list"])
    if isinstance(group_members_raw, list):
        group_members = group_members_raw
    else:
        group_members = [m.strip() for m in group_members_raw.split(",")]

    group_id = quote(config["group_name"], safe="")
    group_url = os.getenv("GITLAB_BASE_URL", "").rstrip("/") + "/api/v4/groups/" + group_id + "/members"

    response = requests.get(url=group_url, headers=_get_headers())
    if response.status_code != 200:
        return 0

    response_members_usernames = [elem["username"].lower() for elem in response.json()]
    response_members_names = [elem["name"].lower() for elem in response.json()]

    for member in group_members:
        member_lower = member.lower().strip()
        found = (
            member_lower in response_members_usernames
            or any(member_lower in name for name in response_members_names)
        )
        if not found:
            return 0

    ## --- verify project creation and members --- ###
    project = _find_project(config)
    if project is None:
        return 0

    project_id = str(project["id"])

    if "visibility_level" in config:
        if project["visibility"] != config["visibility_level"]:
            return 0

    # Check project members (accounts should have access via group or direct membership)
    url = os.getenv("GITLAB_BASE_URL", "").rstrip("/") + "/api/v4/projects/" + project_id + "/members/all?per_page=100"
    response = requests.get(url=url, headers=_get_headers())
    if response.status_code != 200:
        return 0

    member_names = [member["name"].lower() for member in response.json()]
    member_usernames = [member["username"].lower() for member in response.json()]

    for account in accounts:
        account_lower = account.lower().strip()
        found = (
            account_lower in member_usernames
            or any(account_lower in name for name in member_names)
        )
        if not found:
            return 0

    return 1