GitLab でアクセスできないプロジェクトを探す

# 概要

退職等でブロックしていくときに発生する、次のいずれかを満たすプロジェクトを探す。
名前空間はユーザーとグループのいずれでもよい。

- 所属メンバーがすべてブロック済である。
- ブロックされていないメンバーもいるが、Maintainer 権限以上を持つものがいない。

その他：

- 出力は YAML 的ななにか。
- これからブロックするユーザーについてあらかじめ確認する場合は、 `--block` オプションで模擬できる。
- ユーザーの名前空間だけテストする場合は、 `--user` オプションで模擬できる。

# 利用例

```bash
./find_missing  # 現状をチェックする
./find_missing --block foo --block bar  # foo と bar をブロックした状況をチェックする
./find_missing --block foo --user foo  # foo をブロックした状況について、 foo/* をチェックする
```

# ソースコード

```python:find_missing
#!/usr/bin/env python3

import argparse
import os

import requests


class GitLabAPI:
    def __init__(self, endpoint, token):
        self.endpoint = endpoint
        self.session = requests.Session()
        self.session.headers.update({'PRIVATE-TOKEN': token})

    def fetch(self, path, verbose=False, **kwargs):
        url = self.endpoint + path if path.startswith('/') else path
        res = None
        while True:
            params = dict(kwargs)
            if res is not None:
                params['page'] = res.headers['X-Next-Page']
            res = self.session.get(url, params=params)
            if verbose:
                current_page = res.headers.get('X-Page', '?')
                total_pages = res.headers.get('X-Total-Pages', '?')
                print(f'# [{current_page}/{total_pages}] {res.url}')
            res.raise_for_status()
            for item in res.json():
                yield item
            if res.headers.get('X-Next-Page', '') == '':
                break

    def get_users(self, **kwargs):
        return self.fetch('/users', **kwargs)

    def get_projects(self, **kwargs):
        return self.fetch('/projects', **kwargs)

    def get_user_projects(self, id_, **kwargs):
        return self.fetch(f'/users/{id_}/projects', **kwargs)

    def get_project_members(self, id_, **kwargs):
        return self.fetch(f'/projects/{id_}/members/all', **kwargs)

def report_project(project, members, notifications):
    roles = {
        0: 'No access',
        5: 'Minimal access',
        10: 'Guest',
        20: 'Reporter',
        30: 'Developer',
        40: 'Maintainer',
        50: 'Owner',
    }
    print('- project:', project['path_with_namespace'])
    print('  visibility:', project['visibility'])
    print('  description:', project['description'].replace('\r\n', ' '))
    print('  members:')
    for member in members:
        name = member['username']
        role = roles.get(member['access_level'], member['access_level'])
        state = member['state'] if member['state'] == 'active' else member['state'].upper()
        print(f'    - {name} ({role}, {state})')
    if len(notifications) > 0:
        print('  notifications:')
        for message in notifications:
            print(f'    - {message}')

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--endpoint',
        default=os.getenv('GITLAB_API_ENDPOINT', None),
        help='GitLab API endpoint [https://example.com/api/v4]')
    parser.add_argument('--token',
        default=os.getenv('GITLAB_API_PRIVATE_TOKEN', None),
        help='GitLab private token')
    parser.add_argument('--user', help='Check for the user project')
    parser.add_argument('--block', action='append', help='Assume the user is blocked')
    args = parser.parse_args()

    gitlab = GitLabAPI(args.endpoint, args.token)

    # check username
    if args.block:
        for name in args.block:
            for obj in gitlab.get_users(username=name):
                break
            else:
                raise RuntimeError(f'User {name} not found')

    if args.user:
        iterable = gitlab.get_user_projects(args.user, verbose=True)
    else:
        iterable = gitlab.get_projects(verbose=True)

    for project in iterable:
        no_active_member = True
        owner_is_blocked = True
        users = []
        for user in gitlab.get_project_members(project['id']):
            if args.block and user['username'] in args.block:
                user['state'] = 'block_simulated'
            if user['state'] == 'active':
                no_active_member = False
                if user['access_level'] >= 40:
                    owner_is_blocked = False
            users.append(user)

        notifications = []
        if no_active_member:
            notifications.append('no active member')
        elif owner_is_blocked:
            notifications.append('owner is blocked')

        if len(notifications) > 0 or args.user:
            report_project(project, users, notifications)


if __name__ == '__main__':
    main()
```