Алексей Миловидов
Взять данные из Git для анализа процессов разработки.
На примере этой задачи я расскажу про:
— тривиальные факты
— и прописные истины,
которые все и так уже знают.
Чем отличается Git и GitHub?
Git — распределённая система контроля версий.
GitHub — централизованный хостинг Git репозиториев
+ социальная сеть для разработчиков.
— файлы с максимальным и минимальным количеством авторов;
— самые старые строчки кода в репозитории;
— файлы с самой длинной историей;
— любимые файлы для каждого автора;
— большие файлы с маленьким количеством авторов;
— в какие дни недели лучше писать код,
чтобы у него было меньше шансов быть удалённым;
— файлы отсортированные по возрасту кода;
— кто написал больше всего кода;
— кто написал больше всего кода, который не удалили;
— код каких авторов чаще всего переписывают;
— найти пары авторов, что один пишет код, а другой удаляет их код;
— медиана времени удаления строчек кода;
— какой код больше всего переписывали;
— кто больше любит писать комментарии или тесты;
Из чего состоит Git репозиторий?
Репозиторий состоит из blob-ов, деревьев и коммитов.
Всё имеет хэш, уникально идентифицирующий содержимое.
Blob — содержимое файла.
Tree — слепок содержимого директории, ссылается на tree и blob-ы.
Commit — ссылка на tree и родительские коммиты.
Коммиты ссылаются друг на друга и образуют граф.
Если у коммита больше одного родителя — это merge.
Важные замечания:
В git не хранится diff между коммитами.
Хранится слепок содержимого файлов, а diff вычисляется налету.
В git не хранится авторство строчек кода. Оно вычисляется налету просмотром истории подряд, пока не найдётся нужный diff.
В git нет линейной истории. Нет возможности представить репозиторий в виде последовательности patch, сохраняя авторство.
git log — лишь один из способов обхода графа коммитов
Засунуть всё в реляционную БД* и гонять SELECT запросы.
Но модель данных Git не является реляционной
— придётся идти на компромиссы.
* конечно же в ClickHouse.
Взять текущее состояние репозитория.
Вычислить для каждого файла blame (git blame)
— авторство и время коммита каждой строчки.
Каждую строчку каждого файла положить в таблицу.
Объём данных — это просто LOC.
ClickHouse: 732 048 строк (без contrib)
LLVM: 9 809 148 строк
Chromium: 15 063 229 строк (без third_party)
Linux Kernel: 21 645 046 строк
Внутренний репозиторий Яндекса: ??? строк.
— файлы с максимальным и минимальным количеством авторов;
— самые старые строчки кода в репозитории;
— файлы с самой длинной историей;
— любимые файлы для каждого автора;
— большие файлы с маленьким количеством авторов;
— в какие дни недели лучше писать код,
чтобы у него было меньше шансов быть удалённым;
— файлы отсортированные по возрасту кода;
— кто написал больше всего кода;
— кто написал больше всего кода, который не удалили;
— код каких авторов чаще всего переписывают;
— найти пары авторов, что один пишет код, а другой удаляет их код;
— медиана времени удаления строчек кода;
— какой код больше всего переписывали;
— кто больше любит писать комментарии или тесты;
Взять все коммиты.
Для каждого коммита взять состояние репозитория.
Объём данных, оценка сверху:
число строк сейчас * число коммитов / 2
ClickHouse — 732 048 строк * 51 055 коммитов / 2 = 18.6 млрд.
Linux Kernel — 21 645 046 строк * 950 019 коммитов / 2 = 10 трлн.
Всё можно посчитать, но данных слишком много :(
Взять все коммиты.
У каждого коммита получить diff (git show).
Получим добавленные и удалённые строчки кода.
Запишем всё в базу!
Объём данных — в несколько раз больше LOC.
... смотря сколько раз в среднем код переписывался.
Недостатки:
Для merge коммитов diff нетривиален,
так как показывает изменения относительно нескольких родителей.
Решение: просто пропустим merge коммиты.
Изменения для разрешения конфликтов не попадут в статистику.
Статистика будет неточной... ну и ладно.
Зато есть история изменений и всё можно посчитать!
Три таблицы:
commits hash String, author LowCardinality(String), time DateTime, message String, ...
file_changes change_type Enum('Add' = 1, 'Delete' = 2, 'Modify' = 3, 'Rename' = 4...), path LowCardinality(String), old_path LowCardinality(String), file_extension LowCardinality(String), ...
line_changes sign Int8, line_number_old UInt32, line_number_new UInt32, line LowCardinality(String), indent UInt8, line_type Enum('Empty' = 0, 'Comment' = 1, 'Punct' = 2, 'Code' = 3), ...
Принципы:
1. Денормализация.
В каждую строчку file_changes продублируем все свойства коммита.
В каждую строчку line_changes продублируем все свойства file_changes и двух коммитов — текущего и предыдущего из blame.
2. Обогащение статистикой.
В каждую строчку commits также добавим:
- количество добавленных и удалённых файлов;
- количество добавленных и удалённых строк;
3. Предвычисление отдельных деталей.
- вычислим уровень отступа строчки и положим в отдельный столбец;
- вычислим, является ли строчка пустой, комментарием, кодом...;
- расширение файла тоже сохраним в отдельный столбец.
Принципы:
1. Денормализация.
2. Обогащение статистикой.
3. Предвычисление отдельных деталей.
Эти принципы противопоказаны для транзакционных БД
и очень хороши для аналитических БД.
CREATE TABLE git.line_changes ( sign Int8, line_number_old UInt32, line_number_new UInt32, hunk_num UInt32, hunk_start_line_number_old UInt32, hunk_start_line_number_new UInt32, hunk_lines_added UInt32, hunk_lines_deleted UInt32, hunk_context LowCardinality(String), line LowCardinality(String), indent UInt8, line_type Enum('Empty' = 0, 'Comment' = 1, 'Punct' = 2, 'Code' = 3), prev_commit_hash String, prev_author LowCardinality(String), prev_time DateTime, file_change_type Enum('Add' = 1, 'Delete' = 2, 'Modify' = 3, 'Rename' = 4, 'Copy' = 5, 'Type' = 6), path LowCardinality(String), old_path LowCardinality(String), file_extension LowCardinality(String), file_lines_added UInt32, file_lines_deleted UInt32, file_hunks_added UInt32, file_hunks_removed UInt32, file_hunks_changed UInt32, commit_hash String, author LowCardinality(String), time DateTime, commit_message String, commit_files_added UInt32, commit_files_deleted UInt32, commit_files_renamed UInt32, commit_files_modified UInt32, commit_lines_added UInt32, commit_lines_deleted UInt32, commit_hunks_added UInt32, commit_hunks_removed UInt32, commit_hunks_changed UInt32 ) ENGINE = MergeTree ORDER BY time;
Я попытался написать скрипт на bash, но потерпел крах.
Написал скрипт на C++ :) (а мог бы на Python).
Это не является рекомендацией.
Вы — аналитики. Пишите на чём угодно.
clickhouse-git-import --help
Обработка репозитория и размер таблиц в ClickHouse:
- ClickHouse: 31 сек; 6 млн строк; 122 МБ; - LLVM: 8 мин; 62 млн строк; 1.2 ГБ; - Linux: 12 мин; 85 млн строк; 1.7 ГБ; - Chromium: 67 мин; 343 млн строк; 6.8 ГБ.
Копеечный объём данных! Можно анализировать на ноутбуке.
Количество авторов, которые написали больше всего строк кода.
LOC — самая тупая метрика из всех :(
Варианты:
— всё, что написали или только те строчки, которые не удалены?
— только добавленные строчки кода или удаление кода тоже важно?
— как учитывать код, тесты, документацию?
SELECT author AS k, count() AS c
FROM line_changes
WHERE file_extension IN ('h', 'cpp')
GROUP BY k ORDER BY c DESC LIMIT 20
┌─k────────────────────┬───────c─┐
│ Alexey Milovidov │ 1061697 │
│ proller │ 200704 │ <-- коммиты third-party кода
│ Nikolai Kochetov │ 183370 │
│ Alexey Arno │ 107018 │
│ Vitaly Baranov │ 93296 │
│ Andrey Mironov │ 92973 │
│ Guillaume Tassery │ 89530 │ <-- коммит и удаление
│ alesapin │ 86999 │ автогенерированных файлов
│ Vitaliy Lyudvichenko │ 85609 │
│ Michael Kolupaev │ 69178 │
│ CurtizJ │ 62607 │
│ chertus │ 53425 │
│ Alexey Zatelepin │ 49331 │
│ zhang2014 │ 48352 │
│ Alexander Tokmakov │ 46371 │
│ alexey-milovidov │ 41518 │ <-- дубликат
│ peshkurov │ 36525 │ <-- испорчена история git,
│ Nikita Mikhaylov │ 30226 │ закоммичен snapshot без parent.
│ Nikita Vasilev │ 28182 │
│ Artem Zuikov │ 25176 │
└──────────────────────┴─────────┘
Запрос выполнен за 0.009 сек. А чтобы разобраться, почему результат является мусором, потребуются часы разбирательств.
Нельзя считать производительность разработчиков
по физическим свойствам репозитория.
Количество строк кода — очень плохая метрика.
Если вы агрегируете данные и считаете топ
— вы будете видеть, в первую очередь, выбросы данных.
В выбросах придётся разбираться, а данные придётся чистить.
Один агрегированный отчёт не является результатом.
Авторы и доля их кода, удалённая другими авторами:
SELECT k, written_code.c, removed_code.c,
round(removed_code.c * 100 / written_code.c) AS remove_ratio
FROM (
SELECT author AS k, count() AS c
FROM line_changes
WHERE sign = 1 AND file_extension IN ('h', 'cpp')
AND line_type NOT IN ('Punct', 'Empty')
GROUP BY k
) AS written_code
INNER JOIN (
SELECT prev_author AS k, count() AS c
FROM line_changes
WHERE sign = -1 AND file_extension IN ('h', 'cpp')
AND line_type NOT IN ('Punct', 'Empty')
AND author != prev_author
GROUP BY k
) AS removed_code USING (k)
WHERE written_code.c > 1000
ORDER BY c DESC LIMIT 500
Авторы и доля их кода, удалённая другими авторами:
┌─k───────────────────────┬──────c─┬─removed_code.c─┬─remove_ratio─┐
│ Alexey Milovidov │ 426656 │ 113306 │ 27 │
│ Nikolai Kochetov │ 83479 │ 16999 │ 20 │
│ Andrey Mironov │ 55097 │ 43989 │ 80 │
│ Alexey Arno │ 49474 │ 22664 │ 46 │
│ Vitaliy Lyudvichenko │ 47603 │ 31890 │ 67 │
│ Vitaly Baranov │ 44032 │ 3010 │ 7 │
│ alesapin │ 40617 │ 8505 │ 21 │
│ proller │ 36638 │ 19471 │ 53 │
│ Michael Kolupaev │ 31523 │ 21182 │ 67 │
│ peshkurov │ 25442 │ 24556 │ 97 │
│ chertus │ 24013 │ 6961 │ 29 │
│ CurtizJ │ 23684 │ 7604 │ 32 │
│ Alexey Zatelepin │ 22892 │ 11105 │ 49 │
│ Alexander Tokmakov │ 21147 │ 2524 │ 12 │
│ zhang2014 │ 20476 │ 3900 │ 19 │
│ alexey-milovidov │ 16513 │ 10415 │ 63 │
│ Nikita Vasilev │ 14099 │ 2633 │ 19 │
│ Guillaume Tassery │ 12431 │ 1199 │ 10 │
│ Nikita Mikhaylov │ 11673 │ 1447 │ 12 │
Полученный инструмент является хорошей игрушкой.
Метрики из этих данных считать можно...
но слепо ориентироваться на них — нет.
Инструмент можно давать в руки человеку, который будет
сомневаться, думать и перепроверять данные.
GitHub — централизованный хостинг Git репозиториев;
+ социальная сеть для разработчиков;
+ инструменты совместной работы.
В данных Git репозитория нет:
— issues (таск трекера);
— pull requests;
— code review, звёзд, лайков и комментариев.
Это реализует сам GitHub.
Я хочу все данные достать и проанализировать!
— GitHub API;
— GH Archive: https://www.gharchive.org/.
Данные о событиях, которые происходили в репозиториях.
GH Archive — обновляемый архив данных о всех репозиториях GitHub!
Типы событий GitHub API:
— CommitCommentEvent;
— CreateEvent;
— DeleteEvent;
— ForkEvent;
— GollumEvent;
— IssueCommentEvent;
— IssuesEvent;
— MemberEvent;
— PublicEvent;
— PullRequestEvent;
— PullRequestReviewCommentEvent;
— PushEvent;
— ReleaseEvent;
— SponsorshipEvent;
— WatchEvent;
wget --continue \ https://data.gharchive.org/{2015..2020}-{01..12}-{01..31}-{0..23}.json.gz
Все данные открыты и вы можете их скачать!
Просто ждём несколько дней пока скачается
84 264 файла размером 1.2 TB.
Можно ускорить, разбив по серверам, но зачем...
когда можно просто оставить на выходные и забыть.
Как анализировать 1.2 TB .json.gz файлов?
Способ 1. Прямо как есть. Никуда не загружаем.
Анализируем с помощью clickhouse-local:
clickhouse-local --query "
SELECT count() FROM file('*.json.gz', TSV, 'data String')
WHERE JSONExtractString(data, 'actor', 'login') = 'alexey-milovidov'"
Запрос упирается в диск и выполняется ~30 минут.
Узкое место — чтение всех имеющихся данных.
Это не оптимально!
Как анализировать 1.2 TB .json.gz файлов?
Способ 2. Переложить в таблицу в ClickHouse.
Но у данных очень сложная структура.
— 15 разных событий, у каждого много своих атрибутов.
Решение: упростить структуру!
CREATE TABLE github_events ( event_type Enum('CommitCommentEvent' = 1, 'CreateEvent' = 2, 'DeleteEvent' = 3, 'ForkEvent' = 4, 'GollumEvent' = 5, 'IssueCommentEvent' = 6, 'IssuesEvent' = 7, 'MemberEvent' = 8, 'PublicEvent' = 9, 'PullRequestEvent' = 10, 'PullRequestReviewCommentEvent' = 11, 'PushEvent' = 12, 'ReleaseEvent' = 13, 'SponsorshipEvent' = 14, 'WatchEvent' = 15, 'GistEvent' = 16, 'FollowEvent' = 17, 'DownloadEvent' = 18, 'PullRequestReviewEvent' = 19), actor_login LowCardinality(String), repo_name LowCardinality(String), created_at DateTime, updated_at DateTime, action Enum('none' = 0, 'created' = 1, 'added' = 2, 'edited' = 3, 'deleted' = 4, 'opened' = 5, 'closed' = 6, 'reopened' = 7, 'assigned' = 8, 'unassigned' = 9, 'labeled' = 10, 'unlabeled' = 11, 'review_requested' = 12, 'review_request_removed' = 13, 'synchronize' = 14, 'started' = 15, 'published' = 16), comment_id UInt64, body String, path String, position UInt32, line UInt32, ref LowCardinality(String), ref_type Enum('none' = 0, 'branch' = 1, 'tag' = 2, 'repository' = 3), creator_user_login LowCardinality(String), number UInt32, title String, labels Array(LowCardinality(String)), state Enum('none' = 0, 'open' = 1, 'closed' = 2), locked UInt8, assignee LowCardinality(String), assignees Array(LowCardinality(String)), comments UInt32, author_association Enum('NONE' = 0, 'CONTRIBUTOR' = 1, 'OWNER' = 2, 'COLLABORATOR' = 3, 'MEMBER' = 4), closed_at DateTime, merged_at DateTime, merge_commit_sha String, requested_reviewers Array(LowCardinality(String)), requested_teams Array(LowCardinality(String)), head_ref LowCardinality(String), head_sha String, base_ref LowCardinality(String), base_sha String, merged UInt8, mergeable UInt8, rebaseable UInt8, mergeable_state Enum('unknown' = 0, 'dirty' = 1, 'clean' = 2, 'unstable' = 3, 'draft' = 4), merged_by LowCardinality(String), review_comments UInt32, maintainer_can_modify UInt8, ... ) ENGINE = MergeTree ORDER BY (event_type, repo_name, created_at);
find . -name '*.json.gz' | xargs -P16 -I{} bash -c " gzip -cd {} | jq -c ' [ .type, .actor.login, .repo.name, .created_at, .payload.updated_at // .payload.comment.updated_at // .payload.issue.updated_at // .payload.pull_request.updated_at, .payload.action, .payload.comment.id, .payload.review.body // .payload.comment.body // .payload.issue.body // .payload.pull_request.body // .payload.release.body, .payload.comment.path, .payload.comment.position, .payload.comment.line, .payload.ref, .payload.ref_type, .payload.comment.user.login // .payload.issue.user.login // .payload.pull_request.user.login, .payload.issue.number // .payload.pull_request.number, .payload.issue.title // .payload.pull_request.title, [.payload.issue.labels[]?.name // .payload.pull_request.labels[]?.name], .payload.issue.state // .payload.pull_request.state, .payload.issue.locked // .payload.pull_request.locked, .payload.issue.assignee.login // .payload.pull_request.assignee.login, [.payload.issue.assignees[]?.login // .payload.pull_request.assignees[]?.login], .payload.issue.comments // .payload.pull_request.comments, .payload.review.author_association // .payload.issue.author_association // .payload.pull_request.author_association, .payload.issue.closed_at // .payload.pull_request.closed_at, .payload.pull_request.merged_at, .payload.pull_request.merge_commit_sha, [.payload.pull_request.requested_reviewers[]?.login], [.payload.pull_request.requested_teams[]?.name], ... .payload.pull_request.additions, .payload.pull_request.deletions, .payload.pull_request.changed_files, .payload.comment.diff_hunk, .payload.comment.original_position, .payload.comment.commit_id, .payload.comment.original_commit_id, .payload.size, .payload.distinct_size, .payload.member.login, .payload.release.tag_name, .payload.release.name, .payload.review.state ]' | clickhouse-client --input_format_null_as_default 1 --date_time_input_format best_effort --query ' INSERT INTO github_events FORMAT JSONCompactEachRow' || echo 'File {} has issues' "
Что получили:
Одна плоская таблица со всеми типами событий.
Много столбцов — разные свойства разных событий.
Легко анализировать разные события без JOIN.
SELECT sum(event_type = 'WatchEvent') AS stars, sum(event_type = 'PullRequestEvent' AND action = 'opened') AS prs FROM github_events GROUP BY repo_name
Что получили:
— 2.5 млрд строк;
— 150 ГБ в сжатом виде;
— 140 млн репозиториев;
— запросы выполняются за секунды.
Вывод:
Все события за всю историю во всех репозиториях на GitHub:
— это не «Big Data»;
— это «Fits on Your Laptop» Data.
Могу быстро ответить почти на любой вопрос
про Open-Source экосистему*:
— топ репозиториев по звёздам, за всё время, за этот год;
— самые выросшие за квартал или стагнирующие из крупных репозиториев;
— графики числа звёзд, issues, contributors для любого репозитория;
— наиболее плодовитые авторы кода;
— кто больше всего проводит code review;
— репозитории с наибольшей активностью в комментариях;
— все репозитории связанные с ClickHouse;
— все мои комментарии... и чужие тоже.
* про её часть, доступную на GitHub.
Чтобы анализировать данные, надо:
— изучить исходную модель и природу данных;
— выбрать правильное представление для их анализа;
— выполнить подготовку и очистку данных;
— срезать углы где можно;
— уметь написать простой скрипт на любом языке;
— никогда не бояться больших данных;
— исследовать выбросы и внутренности данных;
— быть готовым всё выкинуть и начать сначала;
— никогда не останавливаться на одном отчёте;
... и ещё понадобится отвага.