将 GitLab 指定群组下的所有项目设置为内部可见

#!/usr/bin/env python3
"""
将 GitLab 指定群组下的所有项目设置为内部可见。

Usage:
    python3 set-internal.py <group>
    python3 set-internal.py <group> --yes   # 跳过确认

Environment variables:
    GITLAB_HOST     GitLab 地址 (required)
    GITLAB_TOKEN    GitLab Personal Access Token (required, scope: api)
"""

import json
import os
import sys

from urllib.parse import urlencode
from urllib.request import Request, urlopen


def main() -> None:
    host = os.environ.get("GITLAB_HOST")
    if not host:
        sys.exit("错误: 请设置 GITLAB_HOST 环境变量")

    token = os.environ.get("GITLAB_TOKEN")
    if not token:
        sys.exit("错误: 请设置 GITLAB_TOKEN 环境变量")

    if len(sys.argv) < 2:
        sys.exit(f"用法: {sys.argv[0]} <group> [--yes]")

    group = sys.argv[1]
    auto_yes = "--yes" in sys.argv

    api_base = f"https://{host}/api/v4"

    # 1. 查找群组
    group_id = _find_group(api_base, token, group)
    if not group_id:
        sys.exit(f"错误: 未找到群组 '{group}'")

    print(f"==> 群组: {group} (ID: {group_id})\n")

    # 2. 获取所有项目
    projects = _list_projects(api_base, token, group_id)
    if not projects:
        print(f"群组 '{group}' 下没有项目。")
        return

    # 3. 列出需要修改的项目
    to_change = [p for p in projects if p["visibility"] != "internal"]
    skipped = [p for p in projects if p["visibility"] == "internal"]

    print("==> 以下项目将被设置为 internal:\n")
    for p in to_change:
        print(f"  {p['id']}\t{p['path_with_namespace']}\t{p['visibility']}\t-> internal")

    if skipped:
        print(f"\n  (另外 {len(skipped)} 个项目已是 internal, 将跳过)")

    if not to_change:
        print("\n所有项目已是 internal 可见性,无需修改。")
        return

    print(f"\n共 {len(to_change)} 个项目待修改。")

    # 4. 确认
    if not auto_yes:
        answer = input("\n确认继续? (y/N) ")
        if answer.lower() != "y":
            print("已取消。")
            return

    # 5. 执行修改
    print("\n==> 开始修改...")
    success = 0
    failed = 0

    for p in to_change:
        name = p["path_with_namespace"]
        print(f"  {name}: ", end="", flush=True)
        try:
            _api_put(api_base, token, f"/projects/{p['id']}", {"visibility": "internal"})
            print("OK")
            success += 1
        except ApiError as e:
            print(f"失败 (HTTP {e.status})")
            failed += 1

    print(f"\n==> 完成: 成功 {success}, 失败 {failed}")


class ApiError(Exception):
    def __init__(self, status: int, body: str) -> None:
        super().__init__(f"HTTP {status}")
        self.status = status
        self.body = body


def _api_get(api_base: str, token: str, path: str, params: dict | None = None) -> list | dict:
    url = f"{api_base}{path}"
    if params:
        url += "?" + urlencode(params)
    req = Request(url, headers={"PRIVATE-TOKEN": token})
    with urlopen(req) as resp:
        return json.loads(resp.read())


def _api_put(api_base: str, token: str, path: str, data: dict) -> None:
    url = f"{api_base}{path}"
    body = urlencode(data).encode()
    req = Request(url, data=body, method="PUT", headers={"PRIVATE-TOKEN": token})
    with urlopen(req) as resp:
        if resp.status != 200:
            raise ApiError(resp.status, resp.read().decode(errors="replace"))


def _find_group(api_base: str, token: str, group: str) -> int | None:
    """按 path 或 full_path 查找群组 ID."""
    groups = _api_get(api_base, token, "/groups", {"search": group, "per_page": 100})
    for g in groups:
        if g.get("full_path") == group or g.get("path") == group:
            return g["id"]
    return None


def _list_projects(api_base: str, token: str, group_id: int) -> list[dict]:
    """分页获取群组下所有项目 (含子群组)."""
    projects: list[dict] = []
    page = 1

    while True:
        batch = _api_get(
            api_base, token, f"/groups/{group_id}/projects",
            {"per_page": 100, "page": page, "include_subgroups": True},
        )
        if not batch:
            break
        projects.extend(batch)
        page += 1

    return projects


if __name__ == "__main__":
    main()