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.
Files changed (48) hide show
  1. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/PKG-INFO +8 -6
  2. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/README.md +7 -5
  3. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/docs/SPEC.md +9 -3
  4. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/pyproject.toml +1 -1
  5. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/scripts/board/manifest.yaml +28 -12
  6. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/src/gitlogstats/__main__.py +6 -4
  7. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/src/gitlogstats/github.py +3 -0
  8. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/src/gitlogstats/github_stats.py +30 -3
  9. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/src/gitlogstats/identity.py +42 -9
  10. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/test_github.py +4 -0
  11. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/test_identity.py +65 -10
  12. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/test_integration.py +215 -9
  13. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/CLAUDE.md +0 -0
  14. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/agents/developer-agent.md +0 -0
  15. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/agents/spec-reviewer.md +0 -0
  16. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/hooks/format-edited.sh +0 -0
  17. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/hooks/guard-paths.sh +0 -0
  18. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/hooks/guard-paths.test.mjs +0 -0
  19. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/hooks/verify.sh +0 -0
  20. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/settings.json +0 -0
  21. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/skills/phase-handoff/SKILL.md +0 -0
  22. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.claude/skills/stale-check/SKILL.md +0 -0
  23. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.github/workflows/build.yaml +0 -0
  24. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.gitignore +0 -0
  25. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/.prettierrc.json +0 -0
  26. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/LICENSE +0 -0
  27. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/Pipfile +0 -0
  28. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/Pipfile.lock +0 -0
  29. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/package-lock.json +0 -0
  30. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/package.json +0 -0
  31. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/repos.txt +0 -0
  32. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/requirements.txt +0 -0
  33. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/scripts/board/config.mjs +0 -0
  34. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/scripts/board/derive.mjs +0 -0
  35. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/scripts/board/derive.test.mjs +0 -0
  36. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/scripts/board/sync.mjs +0 -0
  37. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/src/gitlogstats/__init__.py +0 -0
  38. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/src/gitlogstats/git_logs_parser.py +0 -0
  39. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fake_gh.py +0 -0
  40. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/commit_linked.json +0 -0
  41. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/commit_unlinked.json +0 -0
  42. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/contributors.json +0 -0
  43. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/contributors_page2.json +0 -0
  44. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/pulls_page1.json +0 -0
  45. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/pulls_page2.json +0 -0
  46. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/user_full.json +0 -0
  47. {gitlogstats-1.2.0 → gitlogstats-1.3.0}/tests/fixtures/github/user_private.json +0 -0
  48. {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.2.0
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, and match
756
- GitHub users to git authors. Needs the GitHub CLI
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. Five columns are appended to every output format:
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, and match
59
- GitHub users to git authors. Needs the GitHub CLI
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. Five columns are appended to every output format:
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 five GitHub columns are empty (`null` in JSON) rather than zero, and the reason is printed to standard error.
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.2.0"
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: "--github adds GitHub pull-request statistics (STAT-8 to STAT-12) for
23
- repositories hosted on github.com, appending the columns github_username,
24
- prs_opened, prs_merged, prs_closed_unmerged, prs_self_merged to every output
25
- format. Without --github, the output and behaviour are unchanged, GitHub is
26
- never contacted and the GitHub CLI (gh) is not needed. With --github, the
27
- GitHub API is called through gh api, so gh must be installed and signed in.
28
- This is checked before any repository is cloned; if it fails, nothing is
29
- printed to standard output, the tool exits with status 1, and one plain
30
- message on standard error says why and what to do — no stack trace: gh not
31
- found: gitlogstats: --github…"
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 are appended, and are in the header
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, and match GitHub users to git authors. "
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 = FIELDNAMES + GITHUB_FIELDNAMES if args.github else None
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(rows, matches, pr_counts, repository, args.start, args.end)
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 = [unavailable_row(row) for row in rows]
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(rows, matches, pr_counts, repository, start, end):
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
- @returns: new rows with keys FIELDNAMES + GITHUB_FIELDNAMES.
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
- result.append(_blank_row(item))
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
- @returns: a new row whose GitHub columns are all None.
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(",alice-gh,3,1,1,1")
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("| alice-gh | 3 | 1 | 1 | 1 |")
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\n"
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] == "Alice,widgets,01/01/2024,12/31/2024,1,3,0,1,0,,,,,"
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("| 0 | | | | | |")
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