gitlogstats 1.2.0__tar.gz → 1.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/PKG-INFO +8 -6
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/README.md +7 -5
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/docs/SPEC.md +9 -3
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/pyproject.toml +1 -1
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/scripts/board/manifest.yaml +28 -12
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/src/gitlogstats/__main__.py +6 -4
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/src/gitlogstats/github.py +3 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/src/gitlogstats/github_stats.py +30 -3
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/src/gitlogstats/identity.py +42 -9
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/test_github.py +4 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/test_identity.py +65 -10
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/test_integration.py +215 -9
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/CLAUDE.md +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/agents/developer-agent.md +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/agents/spec-reviewer.md +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/hooks/format-edited.sh +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/hooks/guard-paths.sh +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/hooks/guard-paths.test.mjs +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/hooks/verify.sh +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/settings.json +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/skills/phase-handoff/SKILL.md +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/skills/stale-check/SKILL.md +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.github/workflows/build.yaml +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.gitignore +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.prettierrc.json +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/LICENSE +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/Pipfile +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/Pipfile.lock +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/package-lock.json +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/package.json +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/repos.txt +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/requirements.txt +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/scripts/board/config.mjs +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/scripts/board/derive.mjs +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/scripts/board/derive.test.mjs +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/scripts/board/sync.mjs +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/src/gitlogstats/__init__.py +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/src/gitlogstats/git_logs_parser.py +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fake_gh.py +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/commit_linked.json +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/commit_unlinked.json +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/contributors.json +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/contributors_page2.json +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/pulls_page1.json +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/pulls_page2.json +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/user_full.json +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/user_private.json +0 -0
- {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/test_git_logs_parser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gitlogstats
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Track developers' contributions to one or more git repositories within a particular time range.
|
|
5
5
|
Project-URL: Homepage, https://github.com/bloombar/gitlogstats
|
|
6
6
|
Project-URL: Repository, https://github.com/bloombar/gitlogstats.git
|
|
@@ -752,9 +752,9 @@ options:
|
|
|
752
752
|
-v, --verbose Whether to output debugging info
|
|
753
753
|
-c, --clean Remove contributors without any contribuition
|
|
754
754
|
--github Also report GitHub pull requests opened, merged,
|
|
755
|
-
closed and self-merged per contributor,
|
|
756
|
-
GitHub
|
|
757
|
-
(gh), signed in
|
|
755
|
+
closed and self-merged per contributor, with their git
|
|
756
|
+
and GitHub emails, and match GitHub users to git
|
|
757
|
+
authors. Needs the GitHub CLI (gh), signed in
|
|
758
758
|
```
|
|
759
759
|
|
|
760
760
|
In case `gitlogstats` is not found as a command, even after installing, try `python -m gitlogstats` or `python3 -m gitlogstats` instead.
|
|
@@ -837,7 +837,7 @@ gitlogstats -rf repos.txt -c
|
|
|
837
837
|
|
|
838
838
|
### GitHub pull requests
|
|
839
839
|
|
|
840
|
-
Add `--github` to also count each contributor's pull requests.
|
|
840
|
+
Add `--github` to also count each contributor's pull requests. Seven columns are appended to every output format:
|
|
841
841
|
|
|
842
842
|
| Column | Meaning |
|
|
843
843
|
| --------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
@@ -846,6 +846,8 @@ Add `--github` to also count each contributor's pull requests. Five columns are
|
|
|
846
846
|
| `prs_merged` | pull requests they authored that were merged in the date range, by anyone and in any way (squash too) |
|
|
847
847
|
| `prs_closed_unmerged` | pull requests they authored that were closed without merging in the date range |
|
|
848
848
|
| `prs_self_merged` | pull requests they authored and also merged themselves, merged in the date range (also in `prs_merged`) |
|
|
849
|
+
| `git_email` | every distinct author email of the git names combined into the row, sorted alphabetically (ignoring case) and separated by `; `; empty for a GitHub-only row |
|
|
850
|
+
| `github_email` | the public email on the matched GitHub profile; empty when the profile has none (GitHub does not reveal private emails) or no account matched |
|
|
849
851
|
|
|
850
852
|
```
|
|
851
853
|
gitlogstats -r https://github.com/bloombar/gitlogstats.git --github
|
|
@@ -858,7 +860,7 @@ gitlogstats: --github needs the GitHub CLI (gh), which is not installed. Install
|
|
|
858
860
|
gitlogstats: --github needs the GitHub CLI (gh) to be signed in. Run 'gh auth login', or run without --github.
|
|
859
861
|
```
|
|
860
862
|
|
|
861
|
-
**Empty versus zero.** A zero means GitHub was asked and the person has none. An empty value (`null` in JSON) means GitHub could not be asked for that repository: it is not on github.com, or the request failed (no network, rate limit, a private repository you cannot see). The git statistics are still reported, the reason is printed to standard error, and the other repositories carry on. A contributor with no GitHub account matched has an empty `github_username` and zero pull requests. A GitHub user who opened pull requests but has no commits is listed in a row of their own with an empty `username` and zero git statistics.
|
|
863
|
+
**Empty versus zero.** A zero means GitHub was asked and the person has none. An empty value (`null` in JSON) means GitHub could not be asked for that repository: it is not on github.com, or the request failed (no network, rate limit, a private repository you cannot see). The git statistics are still reported, the reason is printed to standard error, and the other repositories carry on. A contributor with no GitHub account matched has an empty `github_username` and zero pull requests. A GitHub user who opened pull requests but has no commits is listed in a row of their own with an empty `username` and zero git statistics. When GitHub could not be asked, `git_email` is still filled in and `github_email` is empty. The emails come from the whole history of the branch, not only from commits in the date range.
|
|
862
864
|
|
|
863
865
|
**Matching GitHub users to git authors.** Each git author name is compared with the GitHub users active in the repository, trying these in order and stopping at the first that finds exactly one user:
|
|
864
866
|
|
|
@@ -55,9 +55,9 @@ options:
|
|
|
55
55
|
-v, --verbose Whether to output debugging info
|
|
56
56
|
-c, --clean Remove contributors without any contribuition
|
|
57
57
|
--github Also report GitHub pull requests opened, merged,
|
|
58
|
-
closed and self-merged per contributor,
|
|
59
|
-
GitHub
|
|
60
|
-
(gh), signed in
|
|
58
|
+
closed and self-merged per contributor, with their git
|
|
59
|
+
and GitHub emails, and match GitHub users to git
|
|
60
|
+
authors. Needs the GitHub CLI (gh), signed in
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
In case `gitlogstats` is not found as a command, even after installing, try `python -m gitlogstats` or `python3 -m gitlogstats` instead.
|
|
@@ -140,7 +140,7 @@ gitlogstats -rf repos.txt -c
|
|
|
140
140
|
|
|
141
141
|
### GitHub pull requests
|
|
142
142
|
|
|
143
|
-
Add `--github` to also count each contributor's pull requests.
|
|
143
|
+
Add `--github` to also count each contributor's pull requests. Seven columns are appended to every output format:
|
|
144
144
|
|
|
145
145
|
| Column | Meaning |
|
|
146
146
|
| --------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
@@ -149,6 +149,8 @@ Add `--github` to also count each contributor's pull requests. Five columns are
|
|
|
149
149
|
| `prs_merged` | pull requests they authored that were merged in the date range, by anyone and in any way (squash too) |
|
|
150
150
|
| `prs_closed_unmerged` | pull requests they authored that were closed without merging in the date range |
|
|
151
151
|
| `prs_self_merged` | pull requests they authored and also merged themselves, merged in the date range (also in `prs_merged`) |
|
|
152
|
+
| `git_email` | every distinct author email of the git names combined into the row, sorted alphabetically (ignoring case) and separated by `; `; empty for a GitHub-only row |
|
|
153
|
+
| `github_email` | the public email on the matched GitHub profile; empty when the profile has none (GitHub does not reveal private emails) or no account matched |
|
|
152
154
|
|
|
153
155
|
```
|
|
154
156
|
gitlogstats -r https://github.com/bloombar/gitlogstats.git --github
|
|
@@ -161,7 +163,7 @@ gitlogstats: --github needs the GitHub CLI (gh), which is not installed. Install
|
|
|
161
163
|
gitlogstats: --github needs the GitHub CLI (gh) to be signed in. Run 'gh auth login', or run without --github.
|
|
162
164
|
```
|
|
163
165
|
|
|
164
|
-
**Empty versus zero.** A zero means GitHub was asked and the person has none. An empty value (`null` in JSON) means GitHub could not be asked for that repository: it is not on github.com, or the request failed (no network, rate limit, a private repository you cannot see). The git statistics are still reported, the reason is printed to standard error, and the other repositories carry on. A contributor with no GitHub account matched has an empty `github_username` and zero pull requests. A GitHub user who opened pull requests but has no commits is listed in a row of their own with an empty `username` and zero git statistics.
|
|
166
|
+
**Empty versus zero.** A zero means GitHub was asked and the person has none. An empty value (`null` in JSON) means GitHub could not be asked for that repository: it is not on github.com, or the request failed (no network, rate limit, a private repository you cannot see). The git statistics are still reported, the reason is printed to standard error, and the other repositories carry on. A contributor with no GitHub account matched has an empty `github_username` and zero pull requests. A GitHub user who opened pull requests but has no commits is listed in a row of their own with an empty `username` and zero git statistics. When GitHub could not be asked, `git_email` is still filled in and `github_email` is empty. The emails come from the whole history of the branch, not only from commits in the date range.
|
|
165
167
|
|
|
166
168
|
**Matching GitHub users to git authors.** Each git author name is compared with the GitHub users active in the repository, trying these in order and stopping at the first that finds exactly one user:
|
|
167
169
|
|
|
@@ -120,6 +120,12 @@ With `--github`, for each contributor, the number of pull requests they authored
|
|
|
120
120
|
|
|
121
121
|
**Status:** Done
|
|
122
122
|
|
|
123
|
+
#### STAT-13 Contributor email addresses
|
|
124
|
+
|
|
125
|
+
With `--github`, every row also reports the contributor's email addresses in two columns appended after `prs_self_merged`: `git_email` lists every distinct author email of the git identities combined into the row (STAT-11), sorted alphabetically (ignoring case) and separated by `; `, and is empty for a GitHub-only row; `github_email` is the public email on the matched GitHub user's profile, and is empty when the profile has none (GitHub does not reveal private emails) or the row has no GitHub user. Emails come from the whole history of the analyzed branch, like the contributor list, not only from commits in the date range. When GitHub statistics are unavailable for a repository (CLI-3), `git_email` is still filled in and `github_email` is empty (`null` in JSON). Without `--github`, neither column appears.
|
|
126
|
+
|
|
127
|
+
**Status:** Done
|
|
128
|
+
|
|
123
129
|
### 3. Filtering
|
|
124
130
|
|
|
125
131
|
#### FILT-1 Date range
|
|
@@ -150,7 +156,7 @@ By default, contributors with no activity in the date range are listed with zero
|
|
|
150
156
|
|
|
151
157
|
#### OUT-1 CSV output
|
|
152
158
|
|
|
153
|
-
`-f csv` (the default) prints a header row followed by one row per contributor per repository, with fields `username, repository, start_date, end_date, commits, insertions, deletions, files, merges`. New fields are added at the end so existing column positions do not change. Values containing commas, quotes or line breaks are quoted. The header is printed even when there are no rows, and the output ends with a single newline. With `--github`, the columns `github_username, prs_opened, prs_merged, prs_closed_unmerged, prs_self_merged` are appended, and are in the header even when there are no rows (CLI-3).
|
|
159
|
+
`-f csv` (the default) prints a header row followed by one row per contributor per repository, with fields `username, repository, start_date, end_date, commits, insertions, deletions, files, merges`. New fields are added at the end so existing column positions do not change. Values containing commas, quotes or line breaks are quoted. The header is printed even when there are no rows, and the output ends with a single newline. With `--github`, the columns `github_username, prs_opened, prs_merged, prs_closed_unmerged, prs_self_merged, git_email, github_email` are appended, and are in the header even when there are no rows (CLI-3).
|
|
154
160
|
|
|
155
161
|
**Status:** Done
|
|
156
162
|
|
|
@@ -194,12 +200,12 @@ Invalid dates, and a start date after the end date, are rejected with a usage er
|
|
|
194
200
|
|
|
195
201
|
#### CLI-3 GitHub pull-request statistics
|
|
196
202
|
|
|
197
|
-
`--github` adds GitHub pull-request statistics (STAT-8 to STAT-12) for repositories hosted on github.com, appending the columns `github_username, prs_opened, prs_merged, prs_closed_unmerged, prs_self_merged` to every output format. Without `--github`, the output and behaviour are unchanged, GitHub is never contacted and the GitHub CLI (`gh`) is not needed. With `--github`, the GitHub API is called through `gh api`, so `gh` must be installed and signed in. This is checked before any repository is cloned; if it fails, nothing is printed to standard output, the tool exits with status 1, and one plain message on standard error says why and what to do — no stack trace:
|
|
203
|
+
`--github` adds GitHub pull-request statistics (STAT-8 to STAT-12) and contributor emails (STAT-13) for repositories hosted on github.com, appending the columns `github_username, prs_opened, prs_merged, prs_closed_unmerged, prs_self_merged, git_email, github_email` to every output format. Without `--github`, the output and behaviour are unchanged, GitHub is never contacted and the GitHub CLI (`gh`) is not needed. With `--github`, the GitHub API is called through `gh api`, so `gh` must be installed and signed in. This is checked before any repository is cloned; if it fails, nothing is printed to standard output, the tool exits with status 1, and one plain message on standard error says why and what to do — no stack trace:
|
|
198
204
|
|
|
199
205
|
- `gh` not found: `gitlogstats: --github needs the GitHub CLI (gh), which is not installed. Install it from https://cli.github.com and run 'gh auth login', or run without --github.`
|
|
200
206
|
- `gh` not signed in (`gh auth status` fails): `gitlogstats: --github needs the GitHub CLI (gh) to be signed in. Run 'gh auth login', or run without --github.`
|
|
201
207
|
|
|
202
|
-
The other options apply to pull requests as follows: `-s/-e` filter each count by its own timestamp, using the same inclusive local-time range as FILT-1; `-u` selects the combined row whose author names, emails or GitHub username match exactly (the username ignoring case); `-b` counts only pull requests whose base branch is that branch, and without `-b` every pull request counts; `-c` removes only rows with neither git nor pull-request activity; `-x` does not apply. For a repository not on github.com, or when GitHub cannot be reached for one repository (no network, rate limit, private repository without access), the git statistics are still reported, the
|
|
208
|
+
The other options apply to pull requests as follows: `-s/-e` filter each count by its own timestamp, using the same inclusive local-time range as FILT-1; `-u` selects the combined row whose author names, emails or GitHub username match exactly (the username ignoring case); `-b` counts only pull requests whose base branch is that branch, and without `-b` every pull request counts; `-c` removes only rows with neither git nor pull-request activity; `-x` does not apply. For a repository not on github.com, or when GitHub cannot be reached for one repository (no network, rate limit, private repository without access), the git statistics are still reported, the GitHub columns (including `github_email`) are empty (`null` in JSON) rather than zero while `git_email` is still filled in (STAT-13), and the reason is printed to standard error.
|
|
203
209
|
|
|
204
210
|
**Status:** Done
|
|
205
211
|
|
|
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitlogstats"
|
|
7
7
|
description = "Track developers' contributions to one or more git repositories within a particular time range."
|
|
8
|
-
version = "1.
|
|
8
|
+
version = "1.3.0"
|
|
9
9
|
authors = [
|
|
10
10
|
{ name="Tobody Tothing", email="nobody.nothing@nowhere.com" },
|
|
11
11
|
]
|
|
@@ -19,16 +19,16 @@
|
|
|
19
19
|
status: Done
|
|
20
20
|
- id: CLI-3
|
|
21
21
|
title: GitHub pull-request statistics
|
|
22
|
-
full:
|
|
23
|
-
repositories hosted on github.com,
|
|
24
|
-
prs_opened, prs_merged,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
GitHub
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
full: --github adds GitHub pull-request statistics (STAT-8 to STAT-12) and
|
|
23
|
+
contributor emails (STAT-13) for repositories hosted on github.com,
|
|
24
|
+
appending the columns github_username, prs_opened, prs_merged,
|
|
25
|
+
prs_closed_unmerged, prs_self_merged, git_email, github_email to every
|
|
26
|
+
output format. Without --github, the output and behaviour are unchanged,
|
|
27
|
+
GitHub is never contacted and the GitHub CLI (gh) is not needed. With
|
|
28
|
+
--github, the GitHub API is called through gh api, so gh must be installed
|
|
29
|
+
and signed in. This is checked before any repository is cloned; if it fails,
|
|
30
|
+
nothing is printed to standard output, the tool exits with status 1, and one
|
|
31
|
+
plain message on standard error says why and what to do…
|
|
32
32
|
section: §5 Command-Line Interface
|
|
33
33
|
family: CLI
|
|
34
34
|
phase: 1
|
|
@@ -89,8 +89,8 @@
|
|
|
89
89
|
containing commas, quotes or line breaks are quoted. The header is printed
|
|
90
90
|
even when there are no rows, and the output ends with a single newline. With
|
|
91
91
|
--github, the columns github_username, prs_opened, prs_merged,
|
|
92
|
-
prs_closed_unmerged, prs_self_merged
|
|
93
|
-
even when there are no rows (CLI-3).
|
|
92
|
+
prs_closed_unmerged, prs_self_merged, git_email, github_email are appended,
|
|
93
|
+
and are in the header even when there are no rows (CLI-3).
|
|
94
94
|
section: §4 Output
|
|
95
95
|
family: OUT
|
|
96
96
|
phase: 1
|
|
@@ -348,6 +348,22 @@
|
|
|
348
348
|
family: STAT
|
|
349
349
|
phase: 1
|
|
350
350
|
status: Done
|
|
351
|
+
- id: STAT-13
|
|
352
|
+
title: Contributor email addresses
|
|
353
|
+
full: "With --github, every row also reports the contributor's email addresses
|
|
354
|
+
in two columns appended after prs_self_merged: git_email lists every
|
|
355
|
+
distinct author email of the git identities combined into the row (STAT-11),
|
|
356
|
+
sorted alphabetically (ignoring case) and separated by ; , and is empty for
|
|
357
|
+
a GitHub-only row; github_email is the public email on the matched GitHub
|
|
358
|
+
user's profile, and is empty when the profile has none (GitHub does not
|
|
359
|
+
reveal private emails) or the row has no GitHub user. Emails come from the
|
|
360
|
+
whole history of the analyzed branch, like the contributor list, not only
|
|
361
|
+
from commits in the date range. When GitHub statistics are unavailable for a
|
|
362
|
+
repository (CLI-3), git_email is…"
|
|
363
|
+
section: §2 Contribution Statistics
|
|
364
|
+
family: STAT
|
|
365
|
+
phase: 1
|
|
366
|
+
status: Done
|
|
351
367
|
- id: FUTURE-author-date-filtering
|
|
352
368
|
title: Author-date filtering
|
|
353
369
|
full: Author-date filtering — offer filtering by author date, so rebased or
|
|
@@ -6,7 +6,7 @@ import re
|
|
|
6
6
|
import sys
|
|
7
7
|
from . import GitLogsParser
|
|
8
8
|
from .git_logs_parser import FIELDNAMES
|
|
9
|
-
from .github import GITHUB_FIELDNAMES, GitHubUnavailable, check_gh
|
|
9
|
+
from .github import EMAIL_FIELDNAMES, GITHUB_FIELDNAMES, GitHubUnavailable, check_gh
|
|
10
10
|
from .github_stats import github_rows
|
|
11
11
|
|
|
12
12
|
|
|
@@ -142,8 +142,8 @@ def main():
|
|
|
142
142
|
parser.add_argument(
|
|
143
143
|
"--github",
|
|
144
144
|
help="Also report GitHub pull requests opened, merged, closed and "
|
|
145
|
-
"self-merged per contributor,
|
|
146
|
-
"Needs the GitHub CLI (gh), signed in",
|
|
145
|
+
"self-merged per contributor, with their git and GitHub emails, and "
|
|
146
|
+
"match GitHub users to git authors. Needs the GitHub CLI (gh), signed in",
|
|
147
147
|
default=False,
|
|
148
148
|
action="store_true",
|
|
149
149
|
)
|
|
@@ -233,7 +233,9 @@ def main():
|
|
|
233
233
|
# emit a single table (or JSON array) covering all repositories
|
|
234
234
|
if last_parser is not None:
|
|
235
235
|
# an empty CSV table still gets the GitHub columns with --github
|
|
236
|
-
fieldnames =
|
|
236
|
+
fieldnames = (
|
|
237
|
+
FIELDNAMES + GITHUB_FIELDNAMES + EMAIL_FIELDNAMES if args.github else None
|
|
238
|
+
)
|
|
237
239
|
output = last_parser.format_results(all_results, args.format, fieldnames)
|
|
238
240
|
# end with exactly one newline: CSV and Markdown output already has one
|
|
239
241
|
print(output, end="" if output.endswith("\n") else "\n")
|
|
@@ -21,6 +21,9 @@ GITHUB_FIELDNAMES = [
|
|
|
21
21
|
"prs_self_merged",
|
|
22
22
|
]
|
|
23
23
|
|
|
24
|
+
# STAT-13: the email columns that follow the pull-request columns
|
|
25
|
+
EMAIL_FIELDNAMES = ["git_email", "github_email"]
|
|
26
|
+
|
|
24
27
|
# CLI-3: the two user-facing messages, verbatim from the SPEC
|
|
25
28
|
GH_NOT_INSTALLED = (
|
|
26
29
|
"gitlogstats: --github needs the GitHub CLI (gh), which is not installed. "
|
|
@@ -87,10 +87,35 @@ def combine(parser, rows, identities, github_repo, args):
|
|
|
87
87
|
parser.verboseprint(f"GitHub ambiguous: {name} ({', '.join(tiers)})")
|
|
88
88
|
for name in sorted(set(identities) - set(matches)):
|
|
89
89
|
parser.verboseprint(f"GitHub unmatched: {name}")
|
|
90
|
-
parser.verboseprint(f"GitHub API calls: {api_calls}")
|
|
91
90
|
|
|
91
|
+
# STAT-13: public emails of the matched users; a matched user who was not
|
|
92
|
+
# a candidate (found through a linked email) has a profile fetched now
|
|
93
|
+
github_emails = {
|
|
94
|
+
login: (info or {}).get("email") for login, info in candidates.items()
|
|
95
|
+
}
|
|
96
|
+
known = {login.casefold() for login in candidates}
|
|
97
|
+
for login, _tier in matches.values():
|
|
98
|
+
if login.casefold() in known:
|
|
99
|
+
continue
|
|
100
|
+
known.add(login.casefold())
|
|
101
|
+
api_calls += 1
|
|
102
|
+
try:
|
|
103
|
+
github_emails[login] = user_profile(login)["email"]
|
|
104
|
+
except GitHubError as exc:
|
|
105
|
+
if "(HTTP 404)" not in str(exc):
|
|
106
|
+
raise
|
|
107
|
+
parser.verboseprint(f"GitHub API calls: {api_calls}")
|
|
92
108
|
repository = GitLogsParser.repo_name_from_url(parser.repository)
|
|
93
|
-
combined = consolidate(
|
|
109
|
+
combined = consolidate(
|
|
110
|
+
rows,
|
|
111
|
+
matches,
|
|
112
|
+
pr_counts,
|
|
113
|
+
repository,
|
|
114
|
+
args.start,
|
|
115
|
+
args.end,
|
|
116
|
+
identities,
|
|
117
|
+
github_emails,
|
|
118
|
+
)
|
|
94
119
|
return combined, matches
|
|
95
120
|
|
|
96
121
|
|
|
@@ -143,7 +168,9 @@ def github_rows(parser, rows, repo_url, identities, args):
|
|
|
143
168
|
result, matches = combine(parser, rows, identities, github_repo, args)
|
|
144
169
|
except GitHubError as exc:
|
|
145
170
|
warn(repo_url, exc)
|
|
146
|
-
result = [
|
|
171
|
+
result = [
|
|
172
|
+
unavailable_row(row, identities.get(row["username"], ())) for row in rows
|
|
173
|
+
]
|
|
147
174
|
|
|
148
175
|
if args.user:
|
|
149
176
|
result = [r for r in result if keep_user(r, args.user, matches, identities)]
|
|
@@ -9,7 +9,7 @@ import re
|
|
|
9
9
|
import unicodedata
|
|
10
10
|
|
|
11
11
|
from gitlogstats.git_logs_parser import FIELDNAMES
|
|
12
|
-
from gitlogstats.github import GITHUB_FIELDNAMES
|
|
12
|
+
from gitlogstats.github import EMAIL_FIELDNAMES, GITHUB_FIELDNAMES
|
|
13
13
|
|
|
14
14
|
NOREPLY_RE = re.compile(
|
|
15
15
|
r"^(?:\d+\+)?([^@+\s]+)@users\.noreply\.github\.com$", re.IGNORECASE
|
|
@@ -165,16 +165,34 @@ def match_identities(identities, candidates, linked_emails):
|
|
|
165
165
|
return matches, ambiguous
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
def join_emails(emails):
|
|
169
|
+
"""
|
|
170
|
+
STAT-13: list email addresses for the git_email column.
|
|
171
|
+
@param emails: an iterable of email addresses (empty ones are ignored).
|
|
172
|
+
@returns: the distinct addresses (ignoring case; the first in plain
|
|
173
|
+
sorted order is kept) sorted ignoring case and joined with "; ".
|
|
174
|
+
"""
|
|
175
|
+
unique = {}
|
|
176
|
+
for email in sorted(set(emails)):
|
|
177
|
+
if email:
|
|
178
|
+
unique.setdefault(email.casefold(), email)
|
|
179
|
+
return "; ".join(unique[key] for key in sorted(unique))
|
|
180
|
+
|
|
181
|
+
|
|
168
182
|
def _blank_row(row):
|
|
169
183
|
"""Return a copy of a git row in output column order, GitHub columns unset."""
|
|
170
184
|
out = {field: row[field] for field in FIELDNAMES}
|
|
171
185
|
out["github_username"] = ""
|
|
172
186
|
for field in _PR_FIELDS:
|
|
173
187
|
out[field] = 0
|
|
188
|
+
out["git_email"] = ""
|
|
189
|
+
out["github_email"] = ""
|
|
174
190
|
return out
|
|
175
191
|
|
|
176
192
|
|
|
177
|
-
def consolidate(
|
|
193
|
+
def consolidate(
|
|
194
|
+
rows, matches, pr_counts, repository, start, end, identities, github_emails
|
|
195
|
+
):
|
|
178
196
|
"""
|
|
179
197
|
Merge git rows that belong to one GitHub user and add pull-request counts.
|
|
180
198
|
|
|
@@ -188,9 +206,13 @@ def consolidate(rows, matches, pr_counts, repository, start, end):
|
|
|
188
206
|
@param repository: kept for GitHub-only rows.
|
|
189
207
|
@param start: start date kept for GitHub-only rows.
|
|
190
208
|
@param end: end date kept for GitHub-only rows.
|
|
191
|
-
@
|
|
209
|
+
@param identities: {author_name: emails} (STAT-13); for git_email.
|
|
210
|
+
@param github_emails: {login: public profile email or None} (STAT-13).
|
|
211
|
+
@returns: new rows with keys FIELDNAMES + GITHUB_FIELDNAMES +
|
|
212
|
+
EMAIL_FIELDNAMES.
|
|
192
213
|
"""
|
|
193
214
|
counts = {login.casefold(): value for login, value in pr_counts.items()}
|
|
215
|
+
profile_emails = {login.casefold(): e for login, e in github_emails.items()}
|
|
194
216
|
|
|
195
217
|
# group rows by login, remembering where each group first appears
|
|
196
218
|
slots = [] # entries: ("row", row) or ("group", key)
|
|
@@ -213,12 +235,15 @@ def consolidate(rows, matches, pr_counts, repository, start, end):
|
|
|
213
235
|
found = counts.get(login.casefold(), {})
|
|
214
236
|
for field in _PR_FIELDS:
|
|
215
237
|
out[field] = found.get(field, 0)
|
|
238
|
+
out["github_email"] = profile_emails.get(login.casefold()) or ""
|
|
216
239
|
return out
|
|
217
240
|
|
|
218
241
|
result = []
|
|
219
242
|
for kind, item in slots:
|
|
220
243
|
if kind == "row":
|
|
221
|
-
|
|
244
|
+
out = _blank_row(item)
|
|
245
|
+
out["git_email"] = join_emails(identities.get(item["username"], ()))
|
|
246
|
+
result.append(out)
|
|
222
247
|
continue
|
|
223
248
|
members = groups[item]
|
|
224
249
|
best = min(members, key=lambda r: (-r["commits"], r["username"]))
|
|
@@ -226,26 +251,34 @@ def consolidate(rows, matches, pr_counts, repository, start, end):
|
|
|
226
251
|
out["username"] = best["username"]
|
|
227
252
|
for field in _SUMMED_FIELDS:
|
|
228
253
|
out[field] = sum(member[field] for member in members)
|
|
254
|
+
out["git_email"] = join_emails(
|
|
255
|
+
email
|
|
256
|
+
for member in members
|
|
257
|
+
for email in identities.get(member["username"], ())
|
|
258
|
+
)
|
|
229
259
|
result.append(with_counts(out, spellings[item]))
|
|
230
260
|
|
|
231
261
|
# STAT-11 tier 4: GitHub users that no git identity matched
|
|
232
262
|
for login in sorted(pr_counts, key=str.casefold):
|
|
233
263
|
if login.casefold() in groups:
|
|
234
264
|
continue
|
|
235
|
-
out = {field: 0 for field in FIELDNAMES}
|
|
265
|
+
out = _blank_row({field: 0 for field in FIELDNAMES})
|
|
236
266
|
out.update({"username": "", "repository": repository})
|
|
237
267
|
out.update({"start_date": start, "end_date": end})
|
|
238
268
|
result.append(with_counts(out, login))
|
|
239
269
|
return result
|
|
240
270
|
|
|
241
271
|
|
|
242
|
-
def unavailable_row(row):
|
|
272
|
+
def unavailable_row(row, emails=()):
|
|
243
273
|
"""
|
|
244
|
-
Copy a git row with the GitHub columns marked unavailable (CLI-3).
|
|
274
|
+
Copy a git row with the GitHub columns marked unavailable (CLI-3). The
|
|
275
|
+
git_email column is still filled in (STAT-13).
|
|
245
276
|
@param row: a git row.
|
|
246
|
-
@
|
|
277
|
+
@param emails: the author emails of the row's git identity.
|
|
278
|
+
@returns: a new row whose GitHub columns are None, except git_email.
|
|
247
279
|
"""
|
|
248
280
|
out = {field: row[field] for field in FIELDNAMES}
|
|
249
|
-
for field in GITHUB_FIELDNAMES:
|
|
281
|
+
for field in GITHUB_FIELDNAMES + EMAIL_FIELDNAMES:
|
|
250
282
|
out[field] = None
|
|
283
|
+
out["git_email"] = join_emails(emails)
|
|
251
284
|
return out
|
|
@@ -79,6 +79,10 @@ def test_fieldnames():
|
|
|
79
79
|
]
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
def test_email_fieldnames():
|
|
83
|
+
assert github.EMAIL_FIELDNAMES == ["git_email", "github_email"]
|
|
84
|
+
|
|
85
|
+
|
|
82
86
|
def test_check_gh_signed_in(tmp_path, monkeypatch):
|
|
83
87
|
calls = install_fake_gh(tmp_path, monkeypatch)
|
|
84
88
|
assert check_gh() is None
|
|
@@ -6,9 +6,10 @@ All names, emails and logins here are invented.
|
|
|
6
6
|
import copy
|
|
7
7
|
|
|
8
8
|
from gitlogstats.git_logs_parser import FIELDNAMES
|
|
9
|
-
from gitlogstats.github import GITHUB_FIELDNAMES
|
|
9
|
+
from gitlogstats.github import EMAIL_FIELDNAMES, GITHUB_FIELDNAMES
|
|
10
10
|
from gitlogstats.identity import (
|
|
11
11
|
consolidate,
|
|
12
|
+
join_emails,
|
|
12
13
|
login_from_noreply,
|
|
13
14
|
match_identities,
|
|
14
15
|
normalize_display_name,
|
|
@@ -295,7 +296,7 @@ def test_consolidate_sums_and_picks_busiest_username():
|
|
|
295
296
|
git_row("Zed Full", commits=7, insertions=100, deletions=20, files=9, merges=3),
|
|
296
297
|
]
|
|
297
298
|
matches = {"zed": ("zhub", "email"), "Zed Full": ("ZHUB", "username")}
|
|
298
|
-
out = consolidate(rows, matches, {"zhub": pr(4, 3, 1, 2)}, "repo", "s", "e")
|
|
299
|
+
out = consolidate(rows, matches, {"zhub": pr(4, 3, 1, 2)}, "repo", "s", "e", {}, {})
|
|
299
300
|
assert [r["username"] for r in out] == ["Zed Full", "Alone"]
|
|
300
301
|
merged = out[0]
|
|
301
302
|
assert merged["commits"] == 9
|
|
@@ -316,7 +317,7 @@ def test_consolidate_sums_and_picks_busiest_username():
|
|
|
316
317
|
def test_consolidate_tie_broken_alphabetically():
|
|
317
318
|
rows = [git_row("beta", commits=3), git_row("alpha", commits=3)]
|
|
318
319
|
matches = {"beta": ("u", "email"), "alpha": ("u", "email")}
|
|
319
|
-
out = consolidate(rows, matches, {}, "repo", "s", "e")
|
|
320
|
+
out = consolidate(rows, matches, {}, "repo", "s", "e", {}, {})
|
|
320
321
|
assert len(out) == 1
|
|
321
322
|
assert out[0]["username"] == "alpha"
|
|
322
323
|
assert out[0]["commits"] == 6
|
|
@@ -325,15 +326,15 @@ def test_consolidate_tie_broken_alphabetically():
|
|
|
325
326
|
def test_consolidate_position_of_first_member():
|
|
326
327
|
rows = [git_row("a"), git_row("b"), git_row("c"), git_row("d")]
|
|
327
328
|
matches = {"a": ("one", "email"), "c": ("one", "email")}
|
|
328
|
-
out = consolidate(rows, matches, {}, "repo", "s", "e")
|
|
329
|
+
out = consolidate(rows, matches, {}, "repo", "s", "e", {}, {})
|
|
329
330
|
assert [r["username"] for r in out] == ["a", "b", "d"]
|
|
330
331
|
assert out[0]["commits"] == 2
|
|
331
332
|
|
|
332
333
|
|
|
333
334
|
def test_consolidate_column_order_and_zero_counts():
|
|
334
335
|
rows = [git_row("a")]
|
|
335
|
-
out = consolidate(rows, {"a": ("ahub", "email")}, {}, "repo", "s", "e")
|
|
336
|
-
assert list(out[0]) == FIELDNAMES + GITHUB_FIELDNAMES
|
|
336
|
+
out = consolidate(rows, {"a": ("ahub", "email")}, {}, "repo", "s", "e", {}, {})
|
|
337
|
+
assert list(out[0]) == FIELDNAMES + GITHUB_FIELDNAMES + EMAIL_FIELDNAMES
|
|
337
338
|
assert out[0]["github_username"] == "ahub"
|
|
338
339
|
assert out[0]["prs_opened"] == out[0]["prs_merged"] == 0
|
|
339
340
|
assert out[0]["prs_self_merged"] == 0
|
|
@@ -342,7 +343,7 @@ def test_consolidate_column_order_and_zero_counts():
|
|
|
342
343
|
def test_consolidate_github_only_rows_sorted_after_git_rows():
|
|
343
344
|
rows = [git_row("a")]
|
|
344
345
|
counts = {"Zed": pr(1), "ahub": pr(2, 1), "bob": pr(0, 1, 3, 1)}
|
|
345
|
-
out = consolidate(rows, {"a": ("AHUB", "email")}, counts, "repo", "S", "E")
|
|
346
|
+
out = consolidate(rows, {"a": ("AHUB", "email")}, counts, "repo", "S", "E", {}, {})
|
|
346
347
|
assert [r["github_username"] for r in out] == ["AHUB", "bob", "Zed"]
|
|
347
348
|
assert out[0]["prs_opened"] == 2
|
|
348
349
|
only = out[1]
|
|
@@ -357,7 +358,7 @@ def test_consolidate_github_only_rows_sorted_after_git_rows():
|
|
|
357
358
|
for field in ["commits", "insertions", "deletions", "files", "merges"]:
|
|
358
359
|
assert only[field] == 0
|
|
359
360
|
for r in out:
|
|
360
|
-
assert list(r) == FIELDNAMES + GITHUB_FIELDNAMES
|
|
361
|
+
assert list(r) == FIELDNAMES + GITHUB_FIELDNAMES + EMAIL_FIELDNAMES
|
|
361
362
|
|
|
362
363
|
|
|
363
364
|
def test_consolidate_does_not_mutate_inputs():
|
|
@@ -365,14 +366,68 @@ def test_consolidate_does_not_mutate_inputs():
|
|
|
365
366
|
matches = {"a": ("u", "email"), "b": ("u", "email")}
|
|
366
367
|
counts = {"u": pr(1), "v": pr(2)}
|
|
367
368
|
before = copy.deepcopy((rows, matches, counts))
|
|
368
|
-
consolidate(rows, matches, counts, "repo", "s", "e")
|
|
369
|
+
consolidate(rows, matches, counts, "repo", "s", "e", {}, {})
|
|
369
370
|
assert (rows, matches, counts) == before
|
|
370
371
|
|
|
371
372
|
|
|
372
373
|
def test_unavailable_row():
|
|
373
374
|
row = git_row("a")
|
|
374
375
|
out = unavailable_row(row)
|
|
375
|
-
assert list(out) == FIELDNAMES + GITHUB_FIELDNAMES
|
|
376
|
+
assert list(out) == FIELDNAMES + GITHUB_FIELDNAMES + EMAIL_FIELDNAMES
|
|
376
377
|
assert all(out[f] is None for f in GITHUB_FIELDNAMES)
|
|
378
|
+
assert out["git_email"] == ""
|
|
379
|
+
assert out["github_email"] is None
|
|
377
380
|
assert out["commits"] == 1
|
|
378
381
|
assert "github_username" not in row
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_unavailable_row_keeps_git_emails():
|
|
385
|
+
out = unavailable_row(git_row("a"), {"b@x.example", "A@x.example"})
|
|
386
|
+
assert out["git_email"] == "A@x.example; b@x.example"
|
|
387
|
+
assert out["github_email"] is None
|
|
388
|
+
assert out["github_username"] is None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ---- emails (STAT-13) ---------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def test_join_emails_sorts_ignoring_case_and_dedupes_ignoring_case():
|
|
395
|
+
emails = ["zed@x.example", "ada@x.example", "Ada@x.example", "", "Bob@x.example"]
|
|
396
|
+
assert join_emails(emails) == "Ada@x.example; Bob@x.example; zed@x.example"
|
|
397
|
+
assert join_emails([]) == ""
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_consolidate_emails_of_a_combined_row():
|
|
401
|
+
rows = [git_row("ada"), git_row("Ada L", commits=5), git_row("solo")]
|
|
402
|
+
matches = {"ada": ("ahub", "email"), "Ada L": ("AHUB", "username")}
|
|
403
|
+
identities = {
|
|
404
|
+
"ada": {"ada@x.example": "1"},
|
|
405
|
+
"Ada L": {"Ada@x.example": "2", "9+ahub@users.noreply.github.com": "3"},
|
|
406
|
+
"solo": {"solo@x.example": "4"},
|
|
407
|
+
}
|
|
408
|
+
out = consolidate(
|
|
409
|
+
rows, matches, {}, "repo", "s", "e", identities, {"ahub": "pub@x.example"}
|
|
410
|
+
)
|
|
411
|
+
assert [r["git_email"] for r in out] == [
|
|
412
|
+
"9+ahub@users.noreply.github.com; Ada@x.example",
|
|
413
|
+
"solo@x.example",
|
|
414
|
+
]
|
|
415
|
+
assert [r["github_email"] for r in out] == ["pub@x.example", ""]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def test_consolidate_github_only_row_has_no_git_email():
|
|
419
|
+
out = consolidate(
|
|
420
|
+
[],
|
|
421
|
+
{},
|
|
422
|
+
{"Zed": pr(1), "bob": pr(1)},
|
|
423
|
+
"repo",
|
|
424
|
+
"s",
|
|
425
|
+
"e",
|
|
426
|
+
{},
|
|
427
|
+
{"zed": "zed@x.example", "bob": None},
|
|
428
|
+
)
|
|
429
|
+
assert [(r["github_username"], r["git_email"], r["github_email"]) for r in out] == [
|
|
430
|
+
("bob", "", ""),
|
|
431
|
+
("Zed", "", "zed@x.example"),
|
|
432
|
+
]
|
|
433
|
+
assert list(out[0]) == FIELDNAMES + GITHUB_FIELDNAMES + EMAIL_FIELDNAMES
|
|
@@ -3,7 +3,9 @@ Integration tests: build real git repositories and check the statistics that
|
|
|
3
3
|
GitLogsParser and the gitlogstats command derive from them.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import csv
|
|
6
7
|
import datetime
|
|
8
|
+
import io
|
|
7
9
|
import json
|
|
8
10
|
import os
|
|
9
11
|
import shutil
|
|
@@ -973,6 +975,7 @@ GITHUB_COLUMNS = [
|
|
|
973
975
|
"prs_self_merged",
|
|
974
976
|
]
|
|
975
977
|
PR_COLUMNS = GITHUB_COLUMNS[1:]
|
|
978
|
+
EMAIL_COLUMNS = ["git_email", "github_email"]
|
|
976
979
|
NOT_INSTALLED = (
|
|
977
980
|
"gitlogstats: --github needs the GitHub CLI (gh), which is not installed. "
|
|
978
981
|
"Install it from https://cli.github.com and run 'gh auth login', "
|
|
@@ -1386,11 +1389,15 @@ class TestGitHub:
|
|
|
1386
1389
|
self, team, tmp_path, monkeypatch, capsys
|
|
1387
1390
|
):
|
|
1388
1391
|
out, _ = self.github(monkeypatch, capsys, tmp_path, "-u", "alice-gh")
|
|
1389
|
-
assert out.splitlines()[1].endswith(
|
|
1392
|
+
assert out.splitlines()[1].endswith(
|
|
1393
|
+
",alice-gh,3,1,1,1,alice@example.com; asmith@example.com,"
|
|
1394
|
+
)
|
|
1390
1395
|
out, _ = self.github(
|
|
1391
1396
|
monkeypatch, capsys, tmp_path, "-u", "alice-gh", "-f", "markdown"
|
|
1392
1397
|
)
|
|
1393
|
-
assert out.splitlines()[2].endswith(
|
|
1398
|
+
assert out.splitlines()[2].endswith(
|
|
1399
|
+
"| alice-gh | 3 | 1 | 1 | 1 | alice@example.com; asmith@example.com | |"
|
|
1400
|
+
)
|
|
1394
1401
|
|
|
1395
1402
|
@pytest.mark.parametrize(
|
|
1396
1403
|
"user",
|
|
@@ -1441,17 +1448,17 @@ class TestGitHub:
|
|
|
1441
1448
|
out, _ = self.github(monkeypatch, capsys, tmp_path, "-u", "Bob")
|
|
1442
1449
|
assert out == (
|
|
1443
1450
|
"username,repository,start_date,end_date,commits,insertions,deletions,"
|
|
1444
|
-
"files,merges,github_username,prs_opened,prs_merged,prs_closed_unmerged,prs_self_merged\n"
|
|
1445
|
-
"Bob,widgets,01/01/2024,12/31/2024,1,2,0,1,0,bobby,0,1,0,0
|
|
1451
|
+
"files,merges,github_username,prs_opened,prs_merged,prs_closed_unmerged,prs_self_merged,git_email,github_email\n"
|
|
1452
|
+
"Bob,widgets,01/01/2024,12/31/2024,1,2,0,1,0,bobby,0,1,0,0,bob@example.com,\n"
|
|
1446
1453
|
)
|
|
1447
1454
|
out, _ = self.github(
|
|
1448
1455
|
monkeypatch, capsys, tmp_path, "-u", "Bob", "-f", "markdown"
|
|
1449
1456
|
)
|
|
1450
1457
|
header, _, row = out.splitlines()
|
|
1451
1458
|
assert header.endswith(
|
|
1452
|
-
"| merges | github_username | prs_opened | prs_merged | prs_closed_unmerged | prs_self_merged |"
|
|
1459
|
+
"| merges | github_username | prs_opened | prs_merged | prs_closed_unmerged | prs_self_merged | git_email | github_email |"
|
|
1453
1460
|
)
|
|
1454
|
-
assert row.endswith("| 0 | bobby | 0 | 1 | 0 | 0 |")
|
|
1461
|
+
assert row.endswith("| 0 | bobby | 0 | 1 | 0 | 0 | bob@example.com | |")
|
|
1455
1462
|
|
|
1456
1463
|
def test_empty_csv_still_has_the_github_columns(
|
|
1457
1464
|
self, team, tmp_path, monkeypatch, capsys
|
|
@@ -1459,7 +1466,198 @@ class TestGitHub:
|
|
|
1459
1466
|
out, _ = self.github(monkeypatch, capsys, tmp_path, "-u", "nobody")
|
|
1460
1467
|
assert out == (
|
|
1461
1468
|
"username,repository,start_date,end_date,commits,insertions,deletions,"
|
|
1462
|
-
"files,merges,github_username,prs_opened,prs_merged,prs_closed_unmerged,prs_self_merged\n"
|
|
1469
|
+
"files,merges,github_username,prs_opened,prs_merged,prs_closed_unmerged,prs_self_merged,git_email,github_email\n"
|
|
1470
|
+
)
|
|
1471
|
+
|
|
1472
|
+
# --- email columns (STAT-13)
|
|
1473
|
+
|
|
1474
|
+
def emails_of(self, out):
|
|
1475
|
+
"""JSON rows as (username, github_username, git_email, github_email)."""
|
|
1476
|
+
return [
|
|
1477
|
+
(r["username"], r["github_username"], r["git_email"], r["github_email"])
|
|
1478
|
+
for r in json.loads(out)
|
|
1479
|
+
]
|
|
1480
|
+
|
|
1481
|
+
@pytest.fixture
|
|
1482
|
+
def ada(self, widgets, tmp_path, monkeypatch):
|
|
1483
|
+
"""Ada: two git names and three addresses, one only differing in case."""
|
|
1484
|
+
# the old commit goes first: git stops reading at a too-old commit
|
|
1485
|
+
widgets.write("e.txt", lines(1))
|
|
1486
|
+
widgets.commit(
|
|
1487
|
+
"ada", "old", date="2020-01-01 12:00:00", email="a.old@x.example"
|
|
1488
|
+
)
|
|
1489
|
+
widgets.write("a.txt", lines(1))
|
|
1490
|
+
widgets.commit("ada", "base", email="Ada@x.example")
|
|
1491
|
+
first = head(widgets)
|
|
1492
|
+
widgets.write("b.txt", lines(2))
|
|
1493
|
+
widgets.commit("ada", "again", email="ada@x.example")
|
|
1494
|
+
widgets.write("c.txt", lines(3))
|
|
1495
|
+
widgets.commit(
|
|
1496
|
+
"Ada Lovelace", "web", email="111+adal99@users.noreply.github.com"
|
|
1497
|
+
)
|
|
1498
|
+
third = head(widgets)
|
|
1499
|
+
widgets.write("d.txt", lines(1))
|
|
1500
|
+
widgets.commit("Zoe", "other", email="zoe@x.example")
|
|
1501
|
+
return widgets, {first: "adal99", third: "adal99"}
|
|
1502
|
+
|
|
1503
|
+
def ada_world(self, ada, tmp_path, monkeypatch, users, extra_prs=()):
|
|
1504
|
+
widgets, linked = ada
|
|
1505
|
+
github_world(
|
|
1506
|
+
tmp_path,
|
|
1507
|
+
monkeypatch,
|
|
1508
|
+
widgets,
|
|
1509
|
+
prs=[pr("adal99", at(2024, 3, 5)), *extra_prs],
|
|
1510
|
+
users=users,
|
|
1511
|
+
contributors=["adal99"],
|
|
1512
|
+
linked=linked,
|
|
1513
|
+
)
|
|
1514
|
+
|
|
1515
|
+
def test_combined_row_lists_every_git_email_sorted_and_deduplicated(
|
|
1516
|
+
self, ada, tmp_path, monkeypatch, capsys
|
|
1517
|
+
):
|
|
1518
|
+
self.ada_world(ada, tmp_path, monkeypatch, {"adal99": {"name": None}})
|
|
1519
|
+
out, _ = self.github(monkeypatch, capsys, tmp_path, "-f", "json")
|
|
1520
|
+
rows = self.emails_of(out)
|
|
1521
|
+
# case-insensitive order and one of the two case variants; the 2020
|
|
1522
|
+
# address is outside the date range; the noreply address is included
|
|
1523
|
+
assert rows[0] == (
|
|
1524
|
+
"ada",
|
|
1525
|
+
"adal99",
|
|
1526
|
+
"111+adal99@users.noreply.github.com; a.old@x.example; Ada@x.example",
|
|
1527
|
+
"",
|
|
1528
|
+
)
|
|
1529
|
+
# an unmatched git row has no GitHub email
|
|
1530
|
+
assert rows[1] == ("Zoe", "", "zoe@x.example", "")
|
|
1531
|
+
|
|
1532
|
+
def test_public_profile_email_is_github_email(
|
|
1533
|
+
self, ada, tmp_path, monkeypatch, capsys
|
|
1534
|
+
):
|
|
1535
|
+
self.ada_world(
|
|
1536
|
+
ada,
|
|
1537
|
+
tmp_path,
|
|
1538
|
+
monkeypatch,
|
|
1539
|
+
{"adal99": {"name": None, "email": "ada@pub.example"}},
|
|
1540
|
+
)
|
|
1541
|
+
out, _ = self.github(monkeypatch, capsys, tmp_path, "-f", "json")
|
|
1542
|
+
assert self.emails_of(out)[0][3] == "ada@pub.example"
|
|
1543
|
+
|
|
1544
|
+
def test_github_only_row_has_empty_git_email(
|
|
1545
|
+
self, ada, tmp_path, monkeypatch, capsys
|
|
1546
|
+
):
|
|
1547
|
+
self.ada_world(
|
|
1548
|
+
ada,
|
|
1549
|
+
tmp_path,
|
|
1550
|
+
monkeypatch,
|
|
1551
|
+
{
|
|
1552
|
+
"adal99": {"name": None},
|
|
1553
|
+
"pro": {"name": None, "email": "pro@pub.example"},
|
|
1554
|
+
},
|
|
1555
|
+
extra_prs=[pr("pro", at(2024, 3, 8))],
|
|
1556
|
+
)
|
|
1557
|
+
out, _ = self.github(monkeypatch, capsys, tmp_path, "-f", "json")
|
|
1558
|
+
assert self.emails_of(out)[-1] == ("", "pro", "", "pro@pub.example")
|
|
1559
|
+
|
|
1560
|
+
def test_matched_login_that_was_not_a_candidate_gets_its_profile(
|
|
1561
|
+
self, widgets, tmp_path, monkeypatch, capsys
|
|
1562
|
+
):
|
|
1563
|
+
# GitHub links the address to "ghost-writer", who has no pull request
|
|
1564
|
+
# and is not a listed contributor, so the profile is fetched afterwards
|
|
1565
|
+
widgets.write("a.txt", lines(1))
|
|
1566
|
+
widgets.commit("Quinn", "work", email="q@x.example")
|
|
1567
|
+
github_world(
|
|
1568
|
+
tmp_path,
|
|
1569
|
+
monkeypatch,
|
|
1570
|
+
widgets,
|
|
1571
|
+
prs=[],
|
|
1572
|
+
users={"ghost-writer": {"name": None, "email": "gw@pub.example"}},
|
|
1573
|
+
linked={head(widgets): "ghost-writer"},
|
|
1574
|
+
)
|
|
1575
|
+
out, _ = self.github(monkeypatch, capsys, tmp_path, "-f", "json")
|
|
1576
|
+
assert self.emails_of(out) == [
|
|
1577
|
+
("Quinn", "ghost-writer", "q@x.example", "gw@pub.example")
|
|
1578
|
+
]
|
|
1579
|
+
|
|
1580
|
+
def test_missing_profile_of_matched_login_leaves_github_email_empty(
|
|
1581
|
+
self, widgets, tmp_path, monkeypatch, capsys
|
|
1582
|
+
):
|
|
1583
|
+
widgets.write("a.txt", lines(1))
|
|
1584
|
+
widgets.commit("Quinn", "work", email="q@x.example")
|
|
1585
|
+
github_world(
|
|
1586
|
+
tmp_path,
|
|
1587
|
+
monkeypatch,
|
|
1588
|
+
widgets,
|
|
1589
|
+
linked={head(widgets): "ghost-writer"},
|
|
1590
|
+
extra_routes={
|
|
1591
|
+
"users/ghost-writer": {"error": "gh: Not Found (HTTP 404)\n"}
|
|
1592
|
+
},
|
|
1593
|
+
)
|
|
1594
|
+
out, err = self.github(monkeypatch, capsys, tmp_path, "-f", "json")
|
|
1595
|
+
assert self.emails_of(out) == [("Quinn", "ghost-writer", "q@x.example", "")]
|
|
1596
|
+
assert err == ""
|
|
1597
|
+
|
|
1598
|
+
def test_profile_error_of_matched_login_makes_repository_unavailable(
|
|
1599
|
+
self, widgets, tmp_path, monkeypatch, capsys
|
|
1600
|
+
):
|
|
1601
|
+
widgets.write("a.txt", lines(1))
|
|
1602
|
+
widgets.commit("Quinn", "work", email="q@x.example")
|
|
1603
|
+
github_world(
|
|
1604
|
+
tmp_path,
|
|
1605
|
+
monkeypatch,
|
|
1606
|
+
widgets,
|
|
1607
|
+
linked={head(widgets): "ghost-writer"},
|
|
1608
|
+
extra_routes={
|
|
1609
|
+
"users/ghost-writer": {"error": "gh: rate limit exceeded (HTTP 403)\n"}
|
|
1610
|
+
},
|
|
1611
|
+
)
|
|
1612
|
+
out, err = self.github(monkeypatch, capsys, tmp_path, "-f", "json")
|
|
1613
|
+
assert self.emails_of(out) == [("Quinn", None, "q@x.example", None)]
|
|
1614
|
+
assert "rate limit" in err
|
|
1615
|
+
|
|
1616
|
+
def test_csv_round_trips_the_joined_emails(
|
|
1617
|
+
self, ada, tmp_path, monkeypatch, capsys
|
|
1618
|
+
):
|
|
1619
|
+
self.ada_world(
|
|
1620
|
+
ada,
|
|
1621
|
+
tmp_path,
|
|
1622
|
+
monkeypatch,
|
|
1623
|
+
{"adal99": {"name": None, "email": "ada@pub.example"}},
|
|
1624
|
+
)
|
|
1625
|
+
out, _ = self.github(monkeypatch, capsys, tmp_path)
|
|
1626
|
+
table = list(csv.DictReader(io.StringIO(out)))
|
|
1627
|
+
assert list(table[0])[-7:] == GITHUB_COLUMNS + EMAIL_COLUMNS
|
|
1628
|
+
assert table[0]["git_email"].split("; ") == [
|
|
1629
|
+
"111+adal99@users.noreply.github.com",
|
|
1630
|
+
"a.old@x.example",
|
|
1631
|
+
"Ada@x.example",
|
|
1632
|
+
]
|
|
1633
|
+
assert table[0]["github_email"] == "ada@pub.example"
|
|
1634
|
+
assert (table[1]["git_email"], table[1]["github_email"]) == (
|
|
1635
|
+
"zoe@x.example",
|
|
1636
|
+
"",
|
|
1637
|
+
)
|
|
1638
|
+
|
|
1639
|
+
def test_emails_do_not_count_as_activity_for_clean(
|
|
1640
|
+
self, widgets, tmp_path, monkeypatch, capsys
|
|
1641
|
+
):
|
|
1642
|
+
widgets.write("a.txt", lines(1))
|
|
1643
|
+
widgets.commit("Old", "work", date="2020-01-01 12:00:00", email="old@x.example")
|
|
1644
|
+
github_world(tmp_path, monkeypatch, widgets)
|
|
1645
|
+
out, _ = self.github(monkeypatch, capsys, tmp_path, "-f", "json")
|
|
1646
|
+
assert self.emails_of(out) == [("Old", "", "old@x.example", "")]
|
|
1647
|
+
out, _ = self.github(monkeypatch, capsys, tmp_path, "-f", "json", "-c")
|
|
1648
|
+
assert out == "[]\n"
|
|
1649
|
+
|
|
1650
|
+
def test_without_github_there_are_no_email_columns(
|
|
1651
|
+
self, widgets, tmp_path, monkeypatch, capsys
|
|
1652
|
+
):
|
|
1653
|
+
widgets.write("a.txt", lines(1))
|
|
1654
|
+
widgets.commit("Alice", "work", email="alice@example.com")
|
|
1655
|
+
github_world(tmp_path, monkeypatch, widgets)
|
|
1656
|
+
out, _ = run_github(monkeypatch, capsys, tmp_path, "-r", GITHUB_URL, *DATES)
|
|
1657
|
+
assert out == (
|
|
1658
|
+
"username,repository,start_date,end_date,commits,insertions,deletions,"
|
|
1659
|
+
"files,merges\n"
|
|
1660
|
+
"Alice,widgets,01/01/2024,12/31/2024,1,1,0,1,0\n"
|
|
1463
1661
|
)
|
|
1464
1662
|
|
|
1465
1663
|
# --- repositories GitHub cannot be asked about
|
|
@@ -1482,10 +1680,13 @@ class TestGitHub:
|
|
|
1482
1680
|
assert [c for c in calls() if c[0] == "api"] == []
|
|
1483
1681
|
out, _ = run_github(monkeypatch, capsys, tmp_path, *argv)
|
|
1484
1682
|
assert (
|
|
1485
|
-
out.splitlines()[1]
|
|
1683
|
+
out.splitlines()[1]
|
|
1684
|
+
== "Alice,widgets,01/01/2024,12/31/2024,1,3,0,1,0,,,,,,alice@example.com,"
|
|
1486
1685
|
)
|
|
1487
1686
|
out, _ = run_github(monkeypatch, capsys, tmp_path, *argv, "-f", "markdown")
|
|
1488
|
-
assert out.splitlines()[2].endswith(
|
|
1687
|
+
assert out.splitlines()[2].endswith(
|
|
1688
|
+
"| 0 | | | | | | alice@example.com | |"
|
|
1689
|
+
)
|
|
1489
1690
|
|
|
1490
1691
|
def test_github_failure_for_one_repository_does_not_stop_the_next(
|
|
1491
1692
|
self, widgets, tmp_path, monkeypatch, capsys
|
|
@@ -1516,6 +1717,11 @@ class TestGitHub:
|
|
|
1516
1717
|
("Bob", "other", 1),
|
|
1517
1718
|
]
|
|
1518
1719
|
assert all(r[c] is None for r in data for c in GITHUB_COLUMNS)
|
|
1720
|
+
# STAT-13: git_email is still filled in, github_email is unavailable
|
|
1721
|
+
assert [(r["git_email"], r["github_email"]) for r in data] == [
|
|
1722
|
+
("alice@example.com", None),
|
|
1723
|
+
("bob@example.com", None),
|
|
1724
|
+
]
|
|
1519
1725
|
|
|
1520
1726
|
def test_clean_and_user_apply_to_unavailable_rows_too(
|
|
1521
1727
|
self, widgets, tmp_path, monkeypatch, capsys
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|