Task: gitlab.count-commits
Goal: How many commits did Steven Woodson make to a11y-webring.club on 2/6/2023?
Task configuration:
date: 2/6/2023
repo: a11y-webring.club
user: Steven Woodson

Validation code:
import os
import re
from calendar import monthrange
from collections import Counter
from datetime import datetime, timedelta
from typing import Any, Dict, List
from urllib.parse import quote

import requests
import json


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


def _get_base_url():
    return os.getenv("GITLAB_BASE_URL", "").rstrip("/")


MONTH_NAMES = {
    "jan": 1, "january": 1,
    "feb": 2, "february": 2,
    "mar": 3, "march": 3,
    "apr": 4, "april": 4,
    "may": 5,
    "jun": 6, "june": 6,
    "jul": 7, "july": 7,
    "aug": 8, "august": 8,
    "sep": 9, "september": 9,
    "oct": 10, "october": 10,
    "nov": 11, "november": 11,
    "dec": 12, "december": 12,
}


def _parse_users(user_str):
    """Parse user string like 'Eric and Kilian' into a list of names."""
    user_str = user_str.replace(" and ", ", ")
    return [u.strip() for u in user_str.split(",") if u.strip()]


def _parse_date(date_str):
    """Parse M/D/YYYY or M/D into (since, until) ISO 8601 strings for that single day."""
    parts = date_str.strip().split("/")
    if len(parts) == 3:
        month, day, year = int(parts[0]), int(parts[1]), int(parts[2])
    elif len(parts) == 2:
        month, day = int(parts[0]), int(parts[1])
        year = 2023
    else:
        raise ValueError(f"Cannot parse date: {date_str}")

    dt = datetime(year, month, day)
    dt_next = dt + timedelta(days=1)
    return dt.strftime("%Y-%m-%dT00:00:00Z"), dt_next.strftime("%Y-%m-%dT00:00:00Z")


def _parse_period(period_str):
    """Parse a period string into (since, until) ISO 8601 strings."""
    period = period_str.strip().lower()

    # "during YYYY" or "in YYYY" (handles typo "durring")
    m = re.match(r"(?:during|durring|in)\s+(\d{4})$", period)
    if m:
        year = int(m.group(1))
        return f"{year}-01-01T00:00:00Z", f"{year + 1}-01-01T00:00:00Z"

    # "in Month YYYY"
    m = re.match(r"in\s+(\w+)\s+(\d{4})$", period)
    if m:
        month = MONTH_NAMES.get(m.group(1).lower())
        year = int(m.group(2))
        if month:
            _, last_day = monthrange(year, month)
            return (
                f"{year}-{month:02d}-01T00:00:00Z",
                f"{year}-{month:02d}-{last_day:02d}T23:59:59Z",
            )

    # "in YYYY/M"
    m = re.match(r"in\s+(\d{4})/(\d{1,2})$", period)
    if m:
        year = int(m.group(1))
        month = int(m.group(2))
        _, last_day = monthrange(year, month)
        return (
            f"{year}-{month:02d}-01T00:00:00Z",
            f"{year}-{month:02d}-{last_day:02d}T23:59:59Z",
        )

    # "between MonthName YYYY and MonthName YYYY"
    m = re.match(r"between\s+(\w+)\s+(\d{4})\s+and\s+(\w+)\s+(\d{4})$", period)
    if m:
        start_month = MONTH_NAMES.get(m.group(1).lower())
        start_year = int(m.group(2))
        end_month = MONTH_NAMES.get(m.group(3).lower())
        end_year = int(m.group(4))
        if start_month and end_month:
            _, end_last_day = monthrange(end_year, end_month)
            return (
                f"{start_year}-{start_month:02d}-01T00:00:00Z",
                f"{end_year}-{end_month:02d}-{end_last_day:02d}T23:59:59Z",
            )

    # "between MM/YYYY-MM/YYYY"
    m = re.match(r"between\s+(\d{1,2})/(\d{4})\s*-\s*(\d{1,2})/(\d{4})$", period)
    if m:
        start_month, start_year = int(m.group(1)), int(m.group(2))
        end_month, end_year = int(m.group(3)), int(m.group(4))
        _, end_last_day = monthrange(end_year, end_month)
        return (
            f"{start_year}-{start_month:02d}-01T00:00:00Z",
            f"{end_year}-{end_month:02d}-{end_last_day:02d}T23:59:59Z",
        )

    raise ValueError(f"Cannot parse period: {period_str}")


def _find_project(repo_name):
    """Find a project by name or namespace/name."""
    base_url = _get_base_url()

    # Try as namespace/project first
    if "/" in repo_name:
        pid = quote(repo_name, safe="").replace(".", "%2E")
        r = requests.get(f"{base_url}/api/v4/projects/{pid}", headers=_get_headers())
        if r.status_code == 200:
            return r.json()

    # Search by name, pick the most popular
    r = requests.get(
        f"{base_url}/api/v4/projects",
        headers=_get_headers(),
        params={"search": repo_name, "per_page": 100},
    )
    if r.status_code != 200 or not r.json():
        return None
    results = r.json()
    results.sort(key=lambda x: x.get("star_count", 0), reverse=True)
    return results[0]


def _get_all_projects():
    """Get all projects from GitLab."""
    base_url = _get_base_url()
    projects = []
    page = 1
    while True:
        r = requests.get(
            f"{base_url}/api/v4/projects",
            headers=_get_headers(),
            params={"per_page": 100, "page": page},
        )
        if r.status_code != 200:
            break
        batch = r.json()
        projects.extend(batch)
        if len(batch) < 100:
            break
        page += 1
    return projects


def _get_commits(project_id, since=None, until=None):
    """Get all commits for a project, optionally filtered by date range."""
    base_url = _get_base_url()
    commits = []
    page = 1
    while True:
        params = {"all": True, "per_page": 100, "page": page}
        if since:
            params["since"] = since
        if until:
            params["until"] = until
        r = requests.get(
            f"{base_url}/api/v4/projects/{project_id}/repository/commits",
            headers=_get_headers(),
            params=params,
        )
        if r.status_code != 200:
            break
        batch = r.json()
        commits.extend(batch)
        if len(batch) < 100:
            break
        page += 1
    return commits


def _count_user_commits(commits, user_names):
    """Count commits whose author matches any of the given names (case-insensitive substring)."""
    count = 0
    for commit in commits:
        author = commit.get("author_name", "").lower()
        for name in user_names:
            if name.lower() in author or author in name.lower():
                count += 1
                break
    return count


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

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

    has_user = "user" in config and config.get("user")
    has_repo = "repo" in config and config.get("repo")
    has_date = "date" in config and config.get("date")
    has_period = "period" in config and config.get("period")

    if not has_user and not has_repo:
        raise ValueError("Config must contain 'user' and/or 'repo'")

    last_content = messages[-1]["content"]

    if has_user:
        # --- Count commits question ---
        user_names = _parse_users(config["user"])

        if has_date:
            since, until = _parse_date(config["date"])
        elif has_period:
            since, until = _parse_period(config["period"])
        else:
            raise ValueError("Config must contain 'date' or 'period' when 'user' is specified")

        if has_repo:
            project = _find_project(config["repo"])
            if not project:
                return 0
            commits = _get_commits(project["id"], since, until)
        else:
            # Count across all projects
            commits = []
            for p in _get_all_projects():
                commits.extend(_get_commits(p["id"], since, until))

        expected_count = _count_user_commits(commits, user_names)

        # Check if the expected count appears in the agent's message
        if str(expected_count) in last_content:
            return 1
        return 0

    else:
        # --- Top contributor question ---
        project = _find_project(config["repo"])
        if not project:
            return 0

        # Use the contributors API (matches GitLab contributors page)
        base_url = _get_base_url()
        r = requests.get(
            f"{base_url}/api/v4/projects/{project['id']}/repository/contributors",
            headers=_get_headers(),
            params={"order_by": "commits", "sort": "desc", "per_page": 100},
        )
        if r.status_code != 200:
            return 0
        contributors = r.json()
        if not contributors:
            return 0

        # Aggregate by name (same person may appear with different emails)
        bot_patterns = ["[bot]", "dependabot"]
        name_counts = Counter()
        for c in contributors:
            name = c["name"]
            if not any(bp in name.lower() for bp in bot_patterns):
                name_counts[name] += c["commits"]

        if not name_counts:
            # All contributors are bots, fall back to unfiltered
            for c in contributors:
                name_counts[c["name"]] += c["commits"]

        top_author = max(name_counts, key=name_counts.get)

        # Check if the top author's name appears in the message (case-insensitive)
        if top_author.lower() in last_content.lower():
            return 1
        return 0