Visualizando centenas de repositórios usando a ferramenta Gource

Visualizar um repositório com a ferramenta Gource é bastante simples. Basta rodar o comando gource dentro do repositório e voilà. Mas e se quisermos gerar uma visualização de centenas de repositórios de uma vez? É este cenário que exploro nesse post.

Visualização gerada com Gource
Figura 1. Visualização com Gource

Organizações que trabalham com microsserviços tendem a distribuir o desenvolvimento em um grande número de pequenos repositórios. Cada microsserviço e cada microfrontend reside em um repositório próprio. Isso sem contar com bibliotecas e outros repositórios de apoio.

Renderizando múltiplos repositórios

No ano passado, escrevi um post sobre o uso básico do Gource. A seguir, mostro como expandir a abordagem para centenas de repositórios.

Custom logs

O Gource permite gerar visualizações de um repositório facilmente. Mas se quisermos que ele renderize vários repositórios simultaneamente, precisamos "enganá-lo", criando um histórico unificado, simulando um Mega Repositório.

Esta estratégia está prevista na documentação do Gource.
Com o comando a seguir, podemos exportar o histórico de modificações de cada repositório num formato fácil de manipular:

gource --output-custom-log log1.txt repo (1)
1 log1.txt é o arquivo de log customizado que será gerado a partir do histórico do repositório na pasta repo

Será necessário rodar esse comando individualmente para cada repositório que desejemos incluir na visualização. Isso implica que será necessário clonar cada um deles. Essa é a parte chata do processo, e que mais vale ser automatizada.

Isso pode ser feito com a linguagem de script de sua preferência. No meu caso, meu colega Gustavo Baroni Bruder e eu automatizamos esse processo em Python, usando a API do Azure DevOps.

Clonando 165 repositórios do Azure DevOps

Na Ambev Tech utilizamos o Azure DevOps para organizar nosso trabalho. A título de exemplo vou apresentar o passo-a-passo que usamos para clonar todos os 165 repositórios necessários para gerar a visualização usando a API do Azure DevOps.

Primeiro, precisamos dos IDs dos projetos envolvidos que serão utilizados. Se você trabalha com apenas um projeto, pode obter essa informação facilmente na URL do projeto ao acessá-lo no portal do Azure DevOps. Caso tenha vários, essa seção serve de exemplo de como automatizar esse processo.

Lista todos os projetos de uma organização no Azure DevOps
projects_url f'https://dev.azure.com/{organization}/_apis/projects?api-version=6.0' (1)
result = get_from_azure_devops(projects_url)
organization_data = json.loads(result) (2)

def encode_PAT(pat: str):
    ### PAT = Personal Access Token

    pat_bytes = pat.encode('ascii')
    base64_bytes = base64.b64encode(pat_bytes)
    base64_message = base64_bytes.decode('ascii')
    return base64_message

def get_from_azure_devops(url: str):
    personal_access_token = os.getenv('PERSONAL_ACCESS_TOKEN') (3)
    encoded_token = encode_PAT(f':{PERSONAL_ACCESS_TOKEN}')
    authorization = f'Authorization: Basic {ENCODED_TOKEN}'

    req = urllib.request.Request(url, headers={'Authorization': f'Basic {ENCODED_TOKEN}'})
    with urllib.request.urlopen(req) as response:
        return response.read()
1 Este endpoint vai retornar a lista de projetos da organização, respeitando as permissões do token fornecido
2 O formato do JSON retornado pode ser visto mais abaixo
3 O PAT, personal access token pode ser gerado no portal do Azure DevOps, conforme imagem abaixo:
Opção para gerar Personal Access Tokens no Portal do Azure, no menu do usuário
Figura 2. Geração do PAT no Azure DevOps
{
  "count": 5,
  "value": [
    {
      "id": "6525b4fe-aa1c-4391-8e8d-1d400506f1ab",
      "name": "PROJETO-X",
      "description": "Descrição do projeto",
      "url": "https://dev.azure.com/ORGANIZATION-NAME/_apis/projects/6525b4fe-aa1c-4391-8e8d-1d400506f1ab",
      "state": "wellFormed",
      "revision": 25130,
      "visibility": "private",
      "lastUpdateTime": "2021-06-28T14:54:17.09Z"
    }
    //...
  ]
}

Com base nessa informação, podemos listar todos os repositórios abaixo de cada projeto da organização:

Lista todos os repositórios de um projeto no Azure DevOps
repositories_by_project = {}

projects = list(organization_data["value"])
for project in projects:
    project_name = project["name"]
    project_repositories_url = f'https://dev.azure.com/{organization}/{project}/_apis/git/repositories?api-version=6.0'
    result = get_from_azure_devops(project_repositories_url)
    repositories_data = json.loads(result)

    repositories = [ (1)
        {'name': repository['name'], 'url': repository['remoteUrl']}
        for repository in repositories_data["value"]
    ]
    repositories_by_project[project_name] = repositories (2)
1 Extraímos do payload retornado apenas as informações necessárias para clonar os repositórios: nome e url
2 Guardamos toda a informação relevante num dicionário com a estrutura abaixo
Dicionário mapeando projetos e repositórios Git
{
  "PROJETO-1": [
    {
      "name": "repositorio1",
      "url": "https://NOME-ORGANIZACAO@dev.azure.com/NOME-ORGANIZACAO/NOME-PROJETO/_git/repositorio1"
    },
    {
      "name": "repositorio2",
      "url": "https://NOME-ORGANIZACAO@dev.azure.com/NOME-ORGANIZACAO/NOME-PROJETO/_git/repositorio2"
    },
  ]
}

Com isso, temos toda a informação necessária para clonar todos os repositórios. Continuando nossa automação com Python, podemos executar utilizar o CLI do Git como um subprocesso.

Clonagem dos repositórios
# continuando os trechos anteriores
for project in repositories_by_project:
    for repository in repositories_by_project[project]:
        remote_url = repository["url"]
        name = repository["name"]
        try_clone_repository(remote_url, name)


def execute_in_shell(command: str): (1)
    popen = Popen(command, stdout=PIPE, universal_newlines=True)
    for stdout_line in iter(popen.stdout.readline, ""):
        yield stdout_line
    popen.stdout.close()
    return_code = popen.wait()
    if return_code:
        raise CalledProcessError(return_code, command)


def try_clone_repository(git_url: str, repository_name: str):
    try:
        target_directory = f'./repositories/{repository_name}' (2)
        if os.path.isdir(target_directory) and len(os.listdir(target_directory)) > 0:
            print(f'{target_directory} already cloned. Skipping.')
            return

        git_command = f'git clone "{git_url}" {target_directory}'
        print(f'Executing command: {git_command}')

        for path in execute_in_shell(git_command):
            print(path, end="")
    except Exception as exception:
        print(exception)
1 Retorna a saída do processo e facilita a depuração
2 Vai clonar cada um dos repositórios na pasta repositories

Feito isso, teremos todos os repositórios clonados. Agora podemos seguir com a manipulação do histórico.

Processando os históricos de modificação do Git

Ao rodar o comando gource --output-custom-log log1.txt repo, será gerado um CSV num formato bem simples.

Histórico de um repositório em formato customizado
**1629995348**|Nome usuário 1|A|/.gitignore (1)
1629995348|**Nome usuário 1**|A|/README.md (2)
1630016091|usuario.2|**M**|/.gitignore (3)
1630016091|usuario.2|A|**/backend/.editorconfig** (4)
1630016091|usuario.2|A|/backend/.gitignore
1630016091|usuario.2|A|/backend/ServiceX.sln
1630016091|usuario.2|A|/backend/NuGet.Config
1630016091|usuario.2|A|/backend/devops/docker/Dockerfile
1630016091|usuario.2|D|/backend/devops/helm/servicex/.helmignore
1630016091|usuario.2|D|/backend/devops/helm/servicex/Chart.yaml
1 A primeira coluna é o timestamp do commit
2 A segunda coluna é o nome do usuário. Uma mesma pessoa pode aparecer várias vezes se utilizar diferentes configurações no Git ao longo do tempo.
3 A terceira coluna descreve a ação: A=adição, M=modificação, D=deleção. Essa informação é usada pelo Gource para colorizar os feixes de atuação dos usuários.
4 A última coluna traz o caminho do arquivo alterado, relativo à raiz do repositório. Esse caminho determina como a árvore de arquivos será representada na visualiação gerada pelo Gource.

A próxima etapa é juntar todos os repositórios. Uma vez que temos todos os repositórios clonados, podemos adicionar ao nosso script Python a renderização dos logs de cada repositório, para depois juntar.

Renderização dos logs com base nos repositórios
# continuando os trechos anteriores
for project in repositories_by_project:
    for repository in repositories_by_project[project]:
        remote_url = repository["url"]
        name = repository["name"]
        try_clone_repository(remote_url, name)
        render_custom_log(repository, project) (1)

def render_custom_log(repository_name, project_name):
    print(f'Generating log file for repository {repository_name}.txt')
    try:
        target_file = f'./render/{repository_name}.txt'
        if os.path.isfile(target_file):
            print(f'Repository {repository} already processed')
            return

        if not os.path.isdir(repository_directory):
            raise Exception(f'Unable to render custom log for repository {repository_name}. {repository_directory} not found.')
        else:
            files = os.listdir(repository_directory)
            has_files = len(files) > 1  # every repository has at least .git
            if not has_files:
                print(f'Skipping log genereation for repository {repository_name}. Repository is empty')
                return

        gource_command = f'gource --output-custom-log "{target_file}" "./repositories/{repository_name}"' (2)
        proc = Popen(gource_command, stdout=PIPE, stderr=PIPE, shell=True)
        out, err = proc.communicate()

        if proc.returncode == 0:
            print(f'Command executed successfully: {gource_command}')
            sleep(1000)  # necessary to ensure that the generated file is closed

            inject_repository_name(target_file, project_name)
        else:
            raise Exception(f"Error executing command. Exitcode: {proc.returncode}\n {err.decode('utf-8')}\n Command: {gource_command}")
    except Exception as e:
        print(e)
1 Após clonar os repositórios, podemos renderizar os logs customizados que serão utilizados nas próximas etapas
2 Podemos invocar o gource como um subprocesso

Criando um histórico unificado (aka Mega Repositório)

Se tudo der certo, teremos uma série de arquivos texto, com o histórico de modificação de cada repositório. Nessa hora, temos de tomar uma decisão:

  1. Simplesmente juntar o histórico de todos os repositórios: será como se fosse um monorepo. Por exemplo, todas as pastas /src serão mescladas. A desvantagem dessa abordagem é que não corresponde à realidade.

  2. Editar cada um dos arquivos de histórico, adicionando o nome do repositório como se fosse uma pasta raiz do Mega Repositório. Assim, garantimos que cada repositório será representado individualmente.

Optamos pela opção 2 porque gera uma visualização bem mais fácil de compreender e que representa mais fielmente o trabalho realizado.

A documentação do Gource indica que podemos usar o sed para fazer essa edição, como visto abaixo:

Adição de uma raiz usando sed
sed -i -r "s#(.+)\|#\1|/repo1#" log1.txt

Apesar de essa estratégia funcionar, não é tão legível, e podemos fazer o mesmo com Python:

Ajuste na função render_custom_log
# ...
if proc.returncode == 0:
    print(f'Command executed successfully: {gource_command}')

    sleep(1)  # ensure that the generated file is closed
    inject_repository_name(target_file, project_name) (1)


def inject_repository_name(file_path, project_name): (2)
    with open(file_path, 'r') as file_read:
        content = file_read.read()
        with open(file_path, 'w') as file:
            new_content = content \
                .replace('|A|', f'|A|/{project_name}') \
                .replace('|M|', f'|M|/{project_name}') \
                .replace('|D|', f'|D|/{project_name}')
            file.write(new_content)
1 Após confirmar a geração do arquivo, podemos manipulá-lo
2 Esta função faz o mesmo que aquele comando sed, adicionando o nome do repositório como pasta raiz de todas as modificações do arquivo.

Agora estamos prontos para juntar tudo um único repositório, criar nosso Mega Repositório. Uma forma de fazer isso é imprimir o conteúdo de todos os arquivos, ordená-los e jogar o resultado num arquivo combinado contendo o histórico de todos repositórios.

Algo nessa linha, em que ocorre um processamento em três fases:

  1. Todos os logs são impressos

  2. São ordenados numericamente (a primeira coluna ajuda, sendo um timestamp)

  3. Os logs são combinados num único histórico coerente

Juntando o histórico via shell
cat log1.txt log2.txt log3.txt | sort -n > combined.txt

Mas seguindo na linha de automatizar todo o processo em Python, vamos seguir com nossa automação.

Geração do histórico combinado
  custom_log_files = os.listdir('./render') (1)
  all_lines = []
  for log_file in custom_log_files:
      lines = read_lines(f'./render/{log_file}')
      all_lines.extend(lines) (2)
  all_lines.sort() (3)

  combined_file_path = './combined.txt'
  print(f'Generating file {combined_file_path}')
  with open(f'{combined_file_path}', mode='w', newline='') as file:
      file.writelines(all_lines) (4)
      print(f'File {combined_file_path} successfully generated')
1 Primeiro, encontramos todos os arquivos de log customizado gerados na etapa anterior.
2 Então adicionamos o conteúdo de todos eles em uma mesma lista.
3 Com todo o histórico em memória podemos ordená-lo.
4 Por fim, salvamos o histórico combinado em um novo arquivo combined.txt. Este arquivo será a entrada da próxima etapa.

Renderizando o vídeo

A próxima etapa é gerar nossa visualização utilizando o Gource.

É interessante que essa etapa seja executada em um computador com bastante disco, e de preferência, bem potente. A título de exemplo, a visualização de 5 minutos que geramos com base em um ano de histórico e 165 repositórios resultou num arquivo de 54GB.

Os parâmetros utilizados vão variar bastante, a depender do tamanho da visualização, do que quisermos priorizar e demais características que queiramos atingir.

Comando para gerar a visualização com Gource no modo interativo
gource "combined.txt" -1920x1080 \
  --seconds-per-day 0.4 \ (1)
  --camera-mode track \
  --multi-sampling \
  --padding 1.1 \
  --elasticity 0.005 \
  --bloom-multiplier 1 --bloom-intensity 0.1 \
  --stop-at-end \
  --highlight-users \
  --hide mouse,progress,filenames \
  --file-idle-time 13 \
  --max-files 0 \
  --background-colour 000000 \
  --start-date '2021-01-01 00:00:00' \
  --title "Retrospectiva Hercules - 01/2021 a 12/2021" \
  --date-format "%d/%m/%y" \
  --font-size 18 \
  --dir-name-depth 3 \
  --logo "logo.jpg" \
  --output-framerate 30 \ (2)
  --file-font-size 4 \
  --highlight-colour 0bc7ed \ (3)
  --max-user-speed 500 \
  --output-ppm-stream ./output.ppm (4)
1 O parâmetro é mais importante talvez seja o seconds-per-day. É ele que determinará a duração do vídeo.
2 É importante se atentar ao framerate, pois ele influenciará a qualidade do vídeo.
3 É interessante destacar o nome dos usuários usando uma cor de fonte diferente do sistema de arquivos. Nesse caso, foi usado o azul.
4 A saída é gerada com o codec PPM, também conhecido como Portable Pixel Map

Provavelmente serão necessárias várias iterações e ajustes até chegar a um resultado satisfatório. Essa é a parte divertida e exige bastante exploração dos inúmeros parâmetros de configuração do Gource.

O comando gource -H exibe a lista completa de configurações, e a Wiki do Gource trás vários exemplos e explicações sobre essas configurações.

A última etapa é converter o vídeo para um formato mais fácil de trabalhar.

Convertendo o vídeo para .mp4

Para converter o vídeo para o formato .mp4, a forma mais prática que encontrei foi utilizar uma imagem Docker do avconv.

Comando para converter .ppm para .mp4
docker run -it \
  -v .:/files \ (1)
  -v .:/home/docker \
  --rm=true -u="1000" \
  jedimonkey/avconv avconv -y \
  -r 30 \ (2)
  -f image2pipe \
  -vcodec ppm \
  -i /files/output.ppm \
  -b 32768k \
  /files/output.mp4 (3)
1 Precisamos mapear num volume o diretório que contém nosso arquivo .ppm
2 O framerate aqui deve ser o mesmo informado na geração do .ppm
3 Este será o arquivo de saída

Se tudo der certo, teremos nosso arquivo finalizado.

Resultados

Abaixo, podemos ver a visualização gerada no final desse processo.

A grande vantagem de ter este processo automatizado, é que isso o torna repetível. Numa empresa grande como a Ambev Tech, com centenas de times e sistemas, outros times podem tirar proveito dessa automação.

Gostou desse post? Deixe um comentário. Vamos compartilhar experiências!

Outras opções para compartilhar:
comments powered by Disqus