gitlogstats 1.6.0__tar.gz → 1.7.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 (55) hide show
  1. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/PKG-INFO +16 -5
  2. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/README.md +15 -4
  3. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/docs/SPEC.md +8 -0
  4. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/pyproject.toml +1 -1
  5. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/scripts/board/manifest.yaml +16 -0
  6. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/__main__.py +20 -8
  7. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/aggregate.py +32 -14
  8. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/git_logs_parser.py +73 -17
  9. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/github.py +45 -0
  10. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/github_stats.py +143 -30
  11. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/test_aggregate.py +88 -1
  12. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/test_github.py +38 -0
  13. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/test_integration.py +185 -0
  14. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/CLAUDE.md +0 -0
  15. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/agents/developer-agent.md +0 -0
  16. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/agents/spec-reviewer.md +0 -0
  17. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/hooks/format-edited.sh +0 -0
  18. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/hooks/guard-paths.sh +0 -0
  19. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/hooks/guard-paths.test.mjs +0 -0
  20. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/hooks/verify.sh +0 -0
  21. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/settings.json +0 -0
  22. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/skills/phase-handoff/SKILL.md +0 -0
  23. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/skills/stale-check/SKILL.md +0 -0
  24. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.github/workflows/build.yaml +0 -0
  25. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.gitignore +0 -0
  26. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.prettierrc.json +0 -0
  27. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/LICENSE +0 -0
  28. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/Pipfile +0 -0
  29. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/Pipfile.lock +0 -0
  30. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/docs/examples/README.md +0 -0
  31. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/docs/examples/assignment-stats.sh +0 -0
  32. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/docs/examples/project-1-stats.csv +0 -0
  33. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/package-lock.json +0 -0
  34. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/package.json +0 -0
  35. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/repos.txt +0 -0
  36. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/requirements.txt +0 -0
  37. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/scripts/board/config.mjs +0 -0
  38. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/scripts/board/derive.mjs +0 -0
  39. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/scripts/board/derive.test.mjs +0 -0
  40. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/scripts/board/sync.mjs +0 -0
  41. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/__init__.py +0 -0
  42. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/clones.py +0 -0
  43. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/identity.py +0 -0
  44. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fake_gh.py +0 -0
  45. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/commit_linked.json +0 -0
  46. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/commit_unlinked.json +0 -0
  47. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/contributors.json +0 -0
  48. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/contributors_page2.json +0 -0
  49. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/pulls_page1.json +0 -0
  50. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/pulls_page2.json +0 -0
  51. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/user_full.json +0 -0
  52. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/user_private.json +0 -0
  53. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/test_clobber.py +0 -0
  54. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/test_git_logs_parser.py +0 -0
  55. {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/test_identity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitlogstats
3
- Version: 1.6.0
3
+ Version: 1.7.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
@@ -726,7 +726,7 @@ The command `gitlogstats --help` shows the usage instructions:
726
726
  ```
727
727
  usage: gitlogstats [-h] (-r REPOSITORY | -rf REPOFILE) [-u USER] [-s START]
728
728
  [-e END] [-x EXCLUSIONS] [-f {csv,json,markdown}]
729
- [-b BRANCH] [-v] [-c] [-a {developer,repository}]
729
+ [-b BRANCH] [-v] [-c] [-a {developer,repository,day}]
730
730
  [--github] [--clobber]
731
731
 
732
732
  options:
@@ -752,10 +752,11 @@ options:
752
752
  The branch to parse, if not the default branch
753
753
  -v, --verbose Whether to output debugging info
754
754
  -c, --clean Remove contributors without any contribuition
755
- -a {developer,repository}, --aggregate {developer,repository}
755
+ -a {developer,repository,day}, --aggregate {developer,repository,day}
756
756
  Combine the results into one row per developer, summed
757
- over all repositories, or one row per repository,
758
- summed over its contributors
757
+ over all repositories, one row per repository, summed
758
+ over its contributors, or one row per day with
759
+ activity, summed over everyone
759
760
  --github Also report GitHub pull requests opened, merged,
760
761
  closed and self-merged per contributor, with their git
761
762
  and GitHub emails, their pushes to the analyzed branch
@@ -856,6 +857,16 @@ gitlogstats -rf repos.txt -a repository
856
857
 
857
858
  Developer rows have no `repository` column; instead a `repositories` column counts the repositories the developer was active in during the date range. Repository rows have no username or email columns; a `contributors` column counts the contributors who were active in the repository during the date range. `-u` and `-c` are applied first, so `-u alice -a repository` shows Alice's share of each repository. With `--github`, a developer's rows are combined by GitHub username, so different git names linked to one GitHub account become one row, and their email addresses are listed together. A developer matched to a GitHub account in one repository but not in another (for example a repository not on GitHub) keeps a separate row for each.
858
859
 
860
+ ### Activity per day
861
+
862
+ Add `-a day` to get one row per day on which anything happened, summed over every contributor and repository, in date order:
863
+
864
+ ```
865
+ gitlogstats -rf repos.txt -a day
866
+ ```
867
+
868
+ The columns are `date`, the usual counts, `repositories` (how many repositories had activity that day) and `contributors` (how many developers were active that day; someone active in several repositories counts once, but, as with `-a developer`, someone matched to a GitHub account in one repository and not in another counts twice). Every event is counted on its own day in local time: commits and merges on their commit date and, with `--github`, pull requests on the day they were opened, merged or closed and pushes on the day they were pushed. The days therefore add up to the totals printed without `-a`. Days without activity are left out. Combine with `-u` to see one contributor's days.
869
+
859
870
  ### Cloned repositories
860
871
 
861
872
  Each repository is cloned into a `repos/` directory under the directory you run the command from, and later runs update (`git pull`) the existing clone instead of cloning again, which makes them much faster. Add `--clobber` to delete the clones the run used, once the results have been printed; `repos/` itself is removed too if nothing else is left in it. Clones from other runs are kept, and nothing is deleted if the run fails before printing, so you can inspect it. With `-v`, each deleted path is listed on standard error.
@@ -29,7 +29,7 @@ The command `gitlogstats --help` shows the usage instructions:
29
29
  ```
30
30
  usage: gitlogstats [-h] (-r REPOSITORY | -rf REPOFILE) [-u USER] [-s START]
31
31
  [-e END] [-x EXCLUSIONS] [-f {csv,json,markdown}]
32
- [-b BRANCH] [-v] [-c] [-a {developer,repository}]
32
+ [-b BRANCH] [-v] [-c] [-a {developer,repository,day}]
33
33
  [--github] [--clobber]
34
34
 
35
35
  options:
@@ -55,10 +55,11 @@ options:
55
55
  The branch to parse, if not the default branch
56
56
  -v, --verbose Whether to output debugging info
57
57
  -c, --clean Remove contributors without any contribuition
58
- -a {developer,repository}, --aggregate {developer,repository}
58
+ -a {developer,repository,day}, --aggregate {developer,repository,day}
59
59
  Combine the results into one row per developer, summed
60
- over all repositories, or one row per repository,
61
- summed over its contributors
60
+ over all repositories, one row per repository, summed
61
+ over its contributors, or one row per day with
62
+ activity, summed over everyone
62
63
  --github Also report GitHub pull requests opened, merged,
63
64
  closed and self-merged per contributor, with their git
64
65
  and GitHub emails, their pushes to the analyzed branch
@@ -159,6 +160,16 @@ gitlogstats -rf repos.txt -a repository
159
160
 
160
161
  Developer rows have no `repository` column; instead a `repositories` column counts the repositories the developer was active in during the date range. Repository rows have no username or email columns; a `contributors` column counts the contributors who were active in the repository during the date range. `-u` and `-c` are applied first, so `-u alice -a repository` shows Alice's share of each repository. With `--github`, a developer's rows are combined by GitHub username, so different git names linked to one GitHub account become one row, and their email addresses are listed together. A developer matched to a GitHub account in one repository but not in another (for example a repository not on GitHub) keeps a separate row for each.
161
162
 
163
+ ### Activity per day
164
+
165
+ Add `-a day` to get one row per day on which anything happened, summed over every contributor and repository, in date order:
166
+
167
+ ```
168
+ gitlogstats -rf repos.txt -a day
169
+ ```
170
+
171
+ The columns are `date`, the usual counts, `repositories` (how many repositories had activity that day) and `contributors` (how many developers were active that day; someone active in several repositories counts once, but, as with `-a developer`, someone matched to a GitHub account in one repository and not in another counts twice). Every event is counted on its own day in local time: commits and merges on their commit date and, with `--github`, pull requests on the day they were opened, merged or closed and pushes on the day they were pushed. The days therefore add up to the totals printed without `-a`. Days without activity are left out. Combine with `-u` to see one contributor's days.
172
+
162
173
  ### Cloned repositories
163
174
 
164
175
  Each repository is cloned into a `repos/` directory under the directory you run the command from, and later runs update (`git pull`) the existing clone instead of cloning again, which makes them much faster. Add `--clobber` to delete the clones the run used, once the results have been printed; `repos/` itself is removed too if nothing else is left in it. Clones from other runs are kept, and nothing is deleted if the run fails before printing, so you can inspect it. With `-v`, each deleted path is listed on standard error.
@@ -212,6 +212,14 @@ In both modes every count is the sum of the combined rows, so each total equals
212
212
 
213
213
  **Status:** Done
214
214
 
215
+ #### OUT-7 Results by day
216
+
217
+ `-a/--aggregate day` prints one row per day on which anything was counted, summed over every contributor and repository analyzed, in date order. Its columns are `date, commits, insertions, deletions, files, merges, repositories, contributors`, where `date` is the day in `mm/dd/yyyy` format, `repositories` is the number of repositories with any activity that day and `contributors` the number of developers with any activity that day (a developer is identified as in OUT-6, so one person active in several repositories counts once, while one matched to a GitHub user in one repository but not in another counts twice). With `--github`, the columns `prs_opened, prs_merged, prs_closed_unmerged, prs_self_merged, pushes_to_default, web_commits` follow. There are no `username`, `repository`, `start_date`, `end_date`, `github_username`, `git_email` or `github_email` columns.
218
+
219
+ Each event is counted on the day it happened, in local time, the same way FILT-1 limits the date range: a commit, merge or website commit on its commit date; a pull request as opened on the day it was created, as merged (and self-merged) on the day it was merged, and as closed without merging on the day it was closed; a push on the day it was pushed. So the day totals add up to the totals printed without `-a`. Days without any activity are left out. `-u` limits the counts to that contributor (as in CLI-3 with `--github`); `-c` changes nothing, since inactive contributors add nothing to a day. A GitHub count is empty (`null` in JSON) only when no repository with activity that day could be asked about (CLI-3). The columns are printed in the CSV header even when there are no rows.
220
+
221
+ **Status:** Done
222
+
215
223
  ### 5. Command-Line Interface
216
224
 
217
225
  #### CLI-1 Help and usage
@@ -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.6.0"
8
+ version = "1.7.0"
9
9
  authors = [
10
10
  { name="Tobody Tothing", email="nobody.nothing@nowhere.com" },
11
11
  ]
@@ -148,6 +148,22 @@
148
148
  family: OUT
149
149
  phase: 1
150
150
  status: Done
151
+ - id: OUT-7
152
+ title: Results by day
153
+ full: -a/--aggregate day prints one row per day on which anything was counted,
154
+ summed over every contributor and repository analyzed, in date order. Its
155
+ columns are date, commits, insertions, deletions, files, merges,
156
+ repositories, contributors, where date is the day in mm/dd/yyyy format,
157
+ repositories is the number of repositories with any activity that day and
158
+ contributors the number of developers with any activity that day (a
159
+ developer is identified as in OUT-6, so one person active in several
160
+ repositories counts once, while one matched to a GitHub user in one
161
+ repository but not in another counts twice). With --github, the columns
162
+ prs_opened, prs_merged, prs_closed_unmerged, prs_self_merged…
163
+ section: §4 Output
164
+ family: OUT
165
+ phase: 1
166
+ status: Done
151
167
  - id: PKG-1
152
168
  title: Installable package
153
169
  full: The tool installs with pip install gitlogstats, which provides a
@@ -15,7 +15,7 @@ from .github import (
15
15
  GitHubUnavailable,
16
16
  check_gh,
17
17
  )
18
- from .github_stats import github_rows
18
+ from .github_stats import github_day_rows, github_rows
19
19
 
20
20
 
21
21
  def valid_date(value):
@@ -151,7 +151,8 @@ def main():
151
151
  "-a",
152
152
  "--aggregate",
153
153
  help="Combine the results into one row per developer, summed over all "
154
- "repositories, or one row per repository, summed over its contributors",
154
+ "repositories, one row per repository, summed over its contributors, "
155
+ "or one row per day with activity, summed over everyone",
155
156
  default=None,
156
157
  choices=aggregate.MODES,
157
158
  )
@@ -253,18 +254,29 @@ def main():
253
254
  verbose=args.verbose,
254
255
  clean=False if args.github else args.clean,
255
256
  )
256
- rows = last_parser.parse()
257
- if args.github:
258
- rows = github_rows(
259
- last_parser, rows, repo_url, last_parser.get_identities(), args
260
- )
257
+ if args.aggregate == aggregate.DAY:
258
+ # OUT-7: rows for single days, each with its date
259
+ if args.github:
260
+ rows = github_day_rows(
261
+ last_parser, repo_url, last_parser.get_identities(), args
262
+ )
263
+ else:
264
+ rows = last_parser.day_rows()
265
+ # an idle contributor adds nothing to a day; keep memory small
266
+ rows = [row for row in rows if aggregate.is_active(row)]
267
+ else:
268
+ rows = last_parser.parse()
269
+ if args.github:
270
+ rows = github_rows(
271
+ last_parser, rows, repo_url, last_parser.get_identities(), args
272
+ )
261
273
  all_results.extend(rows) # collect across repos
262
274
 
263
275
  # emit a single table (or JSON array) covering all repositories
264
276
  if last_parser is not None:
265
277
  # an empty CSV table still gets the columns it would have had
266
278
  if args.aggregate:
267
- # OUT-6: one row per developer or per repository
279
+ # OUT-6, OUT-7: one row per developer, repository or day
268
280
  all_results = aggregate.aggregate(all_results, args.aggregate, args.github)
269
281
  fieldnames = aggregate.fieldnames(args.aggregate, args.github)
270
282
  elif args.github:
@@ -1,9 +1,11 @@
1
- """Combine result rows by developer or by repository (OUT-6).
1
+ """Combine result rows by developer or by repository (OUT-6), or by day (OUT-7).
2
2
 
3
3
  Everything here is pure logic over the rows the rest of the tool produces: no
4
4
  git, no network. Each aggregated row sums the counts of the rows it combines.
5
5
  """
6
6
 
7
+ import datetime
8
+
7
9
  from gitlogstats.git_logs_parser import FIELDNAMES
8
10
  from gitlogstats.github import (
9
11
  ACTIVITY_FIELDNAMES,
@@ -14,12 +16,13 @@ from gitlogstats.identity import join_emails
14
16
 
15
17
  DEVELOPER = "developer"
16
18
  REPOSITORY = "repository"
17
- MODES = [DEVELOPER, REPOSITORY]
19
+ DAY = "day"
20
+ MODES = [DEVELOPER, REPOSITORY, DAY]
18
21
 
19
- # the git counts (commits to merges), summed in both modes
22
+ # the git counts (commits to merges), summed in every mode
20
23
  GIT_COUNTS = FIELDNAMES[4:]
21
24
 
22
- # the GitHub pull-request counts (CLI-3), summed in both modes
25
+ # the GitHub pull-request counts (CLI-3), summed in every mode
23
26
  PR_COUNTS = GITHUB_FIELDNAMES[1:]
24
27
 
25
28
  # every count; a row with any of them non-zero is active, as for -c (FILT-4)
@@ -29,7 +32,7 @@ ALL_COUNTS = GIT_COUNTS + PR_COUNTS + ACTIVITY_FIELDNAMES
29
32
  def fieldnames(mode, github):
30
33
  """
31
34
  The output columns of an aggregated table, in order.
32
- @param mode: DEVELOPER or REPOSITORY
35
+ @param mode: DEVELOPER, REPOSITORY or DAY
33
36
  @param github: whether the GitHub columns are reported (--github)
34
37
  @returns: a list of column names
35
38
  """
@@ -39,6 +42,10 @@ def fieldnames(mode, github):
39
42
  if github:
40
43
  columns += ["github_username"] + PR_COUNTS + EMAIL_FIELDNAMES
41
44
  columns += ACTIVITY_FIELDNAMES
45
+ elif mode == DAY:
46
+ columns = ["date"] + GIT_COUNTS + ["repositories", "contributors"]
47
+ if github:
48
+ columns += PR_COUNTS + ACTIVITY_FIELDNAMES
42
49
  else:
43
50
  columns = ["repository"] + dates + GIT_COUNTS + ["contributors"]
44
51
  if github:
@@ -126,7 +133,7 @@ def merge_emails(values):
126
133
  def combine(rows, columns):
127
134
  """
128
135
  Build the aggregated row of one group.
129
- @param rows: the rows of one developer or one repository
136
+ @param rows: the rows of one developer, repository or day
130
137
  @param columns: the output columns, from fieldnames()
131
138
  @returns: one row with those columns
132
139
  """
@@ -135,12 +142,12 @@ def combine(rows, columns):
135
142
  for field in columns:
136
143
  if field == "username":
137
144
  out[field] = main_username(rows)
138
- elif field in ("repository", "start_date", "end_date"):
145
+ elif field in ("repository", "start_date", "end_date", "date"):
139
146
  out[field] = rows[0][field]
140
147
  elif field == "repositories":
141
148
  out[field] = len({row["repository"] for row in active})
142
149
  elif field == "contributors":
143
- out[field] = len(active)
150
+ out[field] = len({developer_key(row) for row in active})
144
151
  elif field == "github_username":
145
152
  out[field] = first_text(row[field] for row in rows)
146
153
  elif field in EMAIL_FIELDNAMES:
@@ -152,22 +159,33 @@ def combine(rows, columns):
152
159
 
153
160
  def aggregate(rows, mode, github):
154
161
  """
155
- Combine result rows into one row per developer or one row per repository,
156
- sorted by username (or GitHub username, for a developer with no git
157
- username) or by repository, ignoring case.
158
- @param rows: result rows of every repository, after -u and -c
159
- @param mode: DEVELOPER or REPOSITORY
162
+ Combine result rows into one row per developer, repository or day. Rows
163
+ are sorted by username (or GitHub username, for a developer with no git
164
+ username) or by repository, ignoring case, or by date. Days only count
165
+ active rows, so a day without activity is left out (OUT-7).
166
+ @param rows: result rows of every repository, after -u and -c; in DAY
167
+ mode, rows for single days, each with a "date" key
168
+ @param mode: DEVELOPER, REPOSITORY or DAY
160
169
  @param github: whether the rows carry the GitHub columns (--github)
161
170
  @returns: the aggregated rows, sorted
162
171
  """
163
172
  groups = {}
164
173
  for row in rows:
165
- key = developer_key(row) if mode == DEVELOPER else row["repository"]
174
+ if mode == DEVELOPER:
175
+ key = developer_key(row)
176
+ elif mode == REPOSITORY:
177
+ key = row["repository"]
178
+ elif is_active(row):
179
+ key = row["date"]
180
+ else:
181
+ continue
166
182
  groups.setdefault(key, []).append(row)
167
183
  columns = fieldnames(mode, github)
168
184
  combined = [combine(group, columns) for group in groups.values()]
169
185
 
170
186
  def sort_key(row):
187
+ if mode == DAY:
188
+ return (datetime.datetime.strptime(row["date"], "%m/%d/%Y"), "")
171
189
  if mode == DEVELOPER:
172
190
  name = row["username"] or row.get("github_username") or ""
173
191
  else:
@@ -17,6 +17,10 @@ RECORD_SEP = "\x1e"
17
17
  FIELD_SEP = "\x1f"
18
18
  LOG_FORMAT = f"--format={RECORD_SEP}%H{FIELD_SEP}%aN{FIELD_SEP}%aE"
19
19
 
20
+ # OUT-7: the same header with the commit date, as a local mm/dd/yyyy day
21
+ DAY_LOG_FORMAT = f"{LOG_FORMAT}{FIELD_SEP}%cd"
22
+ DAY_DATE_ARG = "--date=format-local:%m/%d/%Y"
23
+
20
24
  FILES_RE = re.compile(r"(\d+) files? changed")
21
25
  INSERTIONS_RE = re.compile(r"(\d+) insertions?\(\+\)")
22
26
  DELETIONS_RE = re.compile(r"(\d+) deletions?\(-\)")
@@ -151,6 +155,7 @@ class GitLogsParser:
151
155
  "--no-merges",
152
156
  *(["--shortstat"] if shortstat else []),
153
157
  log_format,
158
+ *([DAY_DATE_ARG] if log_format == DAY_LOG_FORMAT else []),
154
159
  *self.date_range_args(),
155
160
  "--",
156
161
  ".",
@@ -173,7 +178,7 @@ class GitLogsParser:
173
178
  pathspecs.append(f":(exclude,glob)**/{pattern}/**")
174
179
  return pathspecs
175
180
 
176
- def merge_log_command(self):
181
+ def merge_log_command(self, log_format=LOG_FORMAT):
177
182
  """
178
183
  Build the git log command that lists the merge commits made directly on
179
184
  the analyzed branch in the date range, with their authors.
@@ -181,6 +186,8 @@ class GitLogsParser:
181
186
  branch to update it) are not on the branch's first-parent line, so they
182
187
  are left out. File exclusions do not apply: a merge brings in a branch,
183
188
  whichever files it touches.
189
+ @param log_format: the --format argument; DAY_LOG_FORMAT adds each
190
+ merge's day (OUT-7)
184
191
  @returns: the command as a list of arguments, ready for subprocess
185
192
  """
186
193
  return [
@@ -188,7 +195,8 @@ class GitLogsParser:
188
195
  "log",
189
196
  "--first-parent",
190
197
  "--merges",
191
- LOG_FORMAT,
198
+ log_format,
199
+ *([DAY_DATE_ARG] if log_format == DAY_LOG_FORMAT else []),
192
200
  *self.date_range_args(),
193
201
  ]
194
202
 
@@ -245,7 +253,7 @@ class GitLogsParser:
245
253
  """
246
254
  Split raw git log output (produced by git_log_command) into per-commit records.
247
255
  @param logs: the text output of git log
248
- @returns: a list of dictionaries, one per commit, with the author's name and email and the commit's files, insertions and deletions
256
+ @returns: a list of dictionaries, one per commit, with the author's name and email and the commit's files, insertions and deletions, and its day (OUT-7) when the log has one
249
257
  """
250
258
  commits = []
251
259
  for record in logs.split(RECORD_SEP)[1:]:
@@ -256,16 +264,17 @@ class GitLogsParser:
256
264
  files = FILES_RE.search(body)
257
265
  insertions = INSERTIONS_RE.search(body)
258
266
  deletions = DELETIONS_RE.search(body)
259
- commits.append(
260
- {
261
- "hash": fields[0],
262
- "name": fields[1],
263
- "email": fields[2],
264
- "files": int(files.group(1)) if files else 0,
265
- "insertions": int(insertions.group(1)) if insertions else 0,
266
- "deletions": int(deletions.group(1)) if deletions else 0,
267
- }
268
- )
267
+ commit = {
268
+ "hash": fields[0],
269
+ "name": fields[1],
270
+ "email": fields[2],
271
+ "files": int(files.group(1)) if files else 0,
272
+ "insertions": int(insertions.group(1)) if insertions else 0,
273
+ "deletions": int(deletions.group(1)) if deletions else 0,
274
+ }
275
+ if len(fields) > 3:
276
+ commit["day"] = fields[3].strip()
277
+ commits.append(commit)
269
278
  return commits
270
279
 
271
280
  def parse(self):
@@ -275,12 +284,59 @@ class GitLogsParser:
275
284
  """
276
285
  commits = self.run_git_log(self.git_log_command())
277
286
  merges = self.run_git_log(self.merge_log_command())
287
+ return self.tally(commits, merges)
278
288
 
279
- # limit to the specified username, if any; otherwise all contributors
289
+ def parse_days(self, extra_days=()):
290
+ """
291
+ OUT-7: parse the git logs one day at a time.
292
+ @param extra_days: days (mm/dd/yyyy) to report even without git
293
+ activity, e.g. days with GitHub activity
294
+ @returns: {day: contribution stats of all users on that day, as parse()
295
+ returns them}, for every day with a commit or merge and every extra
296
+ day, in date order
297
+ """
298
+ commits = self.run_git_log(self.git_log_command(DAY_LOG_FORMAT))
299
+ merges = self.run_git_log(self.merge_log_command(DAY_LOG_FORMAT))
300
+ days = {day: ([], []) for day in extra_days}
301
+ for commit in commits:
302
+ days.setdefault(commit["day"], ([], []))[0].append(commit)
303
+ for merge in merges:
304
+ days.setdefault(merge["day"], ([], []))[1].append(merge)
305
+ contributors = self.contributors() # once, not once per day
306
+ order = sorted(days, key=lambda d: datetime.datetime.strptime(d, "%m/%d/%Y"))
307
+ return {day: self.tally(*days[day], contributors) for day in order}
308
+
309
+ def day_rows(self):
310
+ """
311
+ OUT-7: the rows of parse_days as one list, each with its "date".
312
+ @returns: a list of row dictionaries
313
+ """
314
+ return [
315
+ dict(row, date=day)
316
+ for day, rows in self.parse_days().items()
317
+ for row in rows
318
+ ]
319
+
320
+ def contributors(self):
321
+ """
322
+ The contributors to report: the one given with -u, or everyone.
323
+ @returns: a list of usernames
324
+ """
280
325
  if self.username:
281
- contributors = [self.username]
282
- else:
283
- contributors = self.get_contributors()
326
+ return [self.username]
327
+ return self.get_contributors()
328
+
329
+ def tally(self, commits, merges, contributors=None):
330
+ """
331
+ Credit commits and merges to each contributing user.
332
+ @param commits: non-merge commits, from parse_log
333
+ @param merges: merge commits, from parse_log
334
+ @param contributors: the usernames to report; defaults to contributors()
335
+ @returns: contribution stats of all users, as a list of dictionaries, one per user
336
+ """
337
+ # limit to the specified username, if any; otherwise all contributors
338
+ if contributors is None:
339
+ contributors = self.contributors()
284
340
 
285
341
  # set up stats for each contributor in dictionary form
286
342
  entries = {}
@@ -302,6 +302,51 @@ def _range_test(start, end):
302
302
  return in_range
303
303
 
304
304
 
305
+ def local_day(moment):
306
+ """
307
+ OUT-7: the local day of a moment, as git reports a commit's day.
308
+ @param moment: a GitHub UTC timestamp string, or seconds since the epoch
309
+ @returns: the day as mm/dd/yyyy
310
+ """
311
+ if isinstance(moment, str):
312
+ moment = _local_time(moment)
313
+ else:
314
+ moment = datetime.datetime.fromtimestamp(moment)
315
+ return moment.strftime("%m/%d/%Y")
316
+
317
+
318
+ def events_by_day(prs, pushes, start, end, branch=None):
319
+ """
320
+ OUT-7: group pull requests and pushes by the local days in a date range on
321
+ which something happened to them, so each day is counted from its own
322
+ events only.
323
+ @param prs: the list from fetch_pull_requests
324
+ @param pushes: the list from fetch_pushes
325
+ @param start: first day, mm/dd/yyyy
326
+ @param end: last day, mm/dd/yyyy
327
+ @param branch: keep only pull requests with this base branch, if given
328
+ @returns: {day (mm/dd/yyyy): {"prs": [...], "pushes": [...], "commits":
329
+ []}}; a pull request is listed on each day it was opened, merged or
330
+ closed, and "commits" is left for the caller to fill
331
+ """
332
+ in_range = _range_test(start, end)
333
+ days = {}
334
+
335
+ def bucket(day):
336
+ return days.setdefault(day, {"prs": [], "pushes": [], "commits": []})
337
+
338
+ for pr in prs:
339
+ if branch is not None and pr["base"] != branch:
340
+ continue
341
+ moments = (pr["created_at"], pr["merged_at"], pr["closed_at"])
342
+ for day in {local_day(m) for m in moments if in_range(m)}:
343
+ bucket(day)["prs"].append(pr)
344
+ for push in pushes:
345
+ if in_range(push["timestamp"]):
346
+ bucket(local_day(push["timestamp"]))["pushes"].append(push)
347
+ return days
348
+
349
+
305
350
  def count_pull_requests(prs, start, end, branch=None):
306
351
  """
307
352
  STAT-8, STAT-9, STAT-10, STAT-12: count each user's pull requests in a date range.
@@ -19,9 +19,12 @@ from .github import (
19
19
  count_pull_requests,
20
20
  count_pushes,
21
21
  count_web_commits,
22
+ GITHUB_COMMITTER_EMAIL,
23
+ events_by_day,
22
24
  fetch_pull_requests,
23
25
  fetch_pushes,
24
26
  github_repo_from_url,
27
+ local_day,
25
28
  user_profile,
26
29
  )
27
30
  from .identity import consolidate, match_identities, unavailable_row
@@ -41,39 +44,29 @@ def warn(repo_url, reason):
41
44
  )
42
45
 
43
46
 
44
- def combine(parser, rows, identities, github_repo, args):
47
+ def fetch(parser, identities, github_repo):
45
48
  """
46
- STAT-8 to STAT-11, STAT-14, STAT-15: fetch the repository's pull requests
47
- and pushes, match GitHub users to git authors and merge their rows.
49
+ STAT-8 to STAT-11, STAT-14, STAT-15: fetch the repository's pull requests,
50
+ pushes and website-commit candidates, and match GitHub users to git authors.
51
+ Nothing is counted yet, so the result serves any date range (OUT-7).
48
52
  @param parser: the GitLogsParser of the repository (for verbose output)
49
- @param rows: git rows from parser.parse(), unfiltered
50
53
  @param identities: {author_name: {email: commit hash}} from get_identities
51
54
  @param github_repo: "owner/repo"
52
- @param args: the parsed command-line arguments (start, end, branch)
53
- @returns: (combined rows, {author_name: (login, tier)})
55
+ @returns: a dict with prs, pushes (to the checked-out branch), commits (committed_commits), matches
56
+ ({author_name: (login, tier)}) and github_emails ({login: email})
54
57
  @raises GitHubError: if GitHub cannot be read for this repository
55
58
  """
56
59
  api_calls = 3 # the pull request, contributor and activity lists
57
60
  prs = fetch_pull_requests(github_repo)
58
- pr_counts = count_pull_requests(prs, args.start, args.end, args.branch)
59
61
 
60
62
  # STAT-14: pushes to the branch that was actually analyzed; a detached
61
63
  # HEAD is no branch, so nothing was pushed to it
62
64
  branch = parser.current_branch()
63
65
  if branch is None:
64
66
  api_calls -= 1
65
- pushes = {}
67
+ pushes = []
66
68
  else:
67
- pushes = count_pushes(fetch_pushes(github_repo, branch), args.start, args.end)
68
- known = {login.casefold(): login for login in pr_counts}
69
- for login, number in pushes.items():
70
- key = known.setdefault(login.casefold(), login)
71
- found = pr_counts.setdefault(key, {})
72
- found["pushes_to_default"] = found.get("pushes_to_default", 0) + number
73
-
74
- # STAT-15: commits GitHub created itself, apart from pull request merges
75
- web_commits = count_web_commits(parser.committed_commits(), prs)
76
- rows = [dict(row, web_commits=web_commits.get(row["username"], 0)) for row in rows]
69
+ pushes = fetch_pushes(github_repo, branch)
77
70
 
78
71
  logins = {pr["login"] for pr in prs} | contributor_logins(github_repo)
79
72
  candidates = {}
@@ -127,18 +120,71 @@ def combine(parser, rows, identities, github_repo, args):
127
120
  if "(HTTP 404)" not in str(exc):
128
121
  raise
129
122
  parser.verboseprint(f"GitHub API calls: {api_calls}")
123
+ return {
124
+ "prs": prs,
125
+ "pushes": pushes,
126
+ "commits": parser.committed_commits(),
127
+ "matches": matches,
128
+ "github_emails": github_emails,
129
+ }
130
+
131
+
132
+ def tally(parser, rows, data, identities, start, end, branch, events=None):
133
+ """
134
+ Count the fetched GitHub activity in a date range and merge it into the
135
+ git rows of the same range.
136
+ @param parser: the GitLogsParser of the repository
137
+ @param rows: git rows for the range, unfiltered
138
+ @param data: the result of fetch
139
+ @param identities: {author_name: {email: commit hash}} from get_identities
140
+ @param start: first day, mm/dd/yyyy
141
+ @param end: last day, mm/dd/yyyy
142
+ @param branch: count only pull requests with this base branch, if given
143
+ @param events: {"prs", "pushes", "commits"} limited to the range (OUT-7);
144
+ defaults to everything in data (the whole -s/-e range)
145
+ @returns: the combined rows
146
+ """
147
+ events = events or data
148
+ pr_counts = count_pull_requests(events["prs"], start, end, branch)
149
+ pushes = count_pushes(events["pushes"], start, end)
150
+ known = {login.casefold(): login for login in pr_counts}
151
+ for login, number in pushes.items():
152
+ key = known.setdefault(login.casefold(), login)
153
+ found = pr_counts.setdefault(key, {})
154
+ found["pushes_to_default"] = found.get("pushes_to_default", 0) + number
155
+
156
+ # STAT-15: commits GitHub created itself, apart from pull request merges
157
+ web_commits = count_web_commits(events["commits"], data["prs"])
158
+ rows = [dict(row, web_commits=web_commits.get(row["username"], 0)) for row in rows]
159
+
130
160
  repository = GitLogsParser.repo_name_from_url(parser.repository)
131
- combined = consolidate(
161
+ return consolidate(
132
162
  rows,
133
- matches,
163
+ data["matches"],
134
164
  pr_counts,
135
165
  repository,
136
- args.start,
137
- args.end,
166
+ start,
167
+ end,
138
168
  identities,
139
- github_emails,
169
+ data["github_emails"],
140
170
  )
141
- return combined, matches
171
+
172
+
173
+ def combine(parser, rows, identities, github_repo, args):
174
+ """
175
+ STAT-8 to STAT-11, STAT-14, STAT-15: fetch the repository's pull requests
176
+ and pushes, match GitHub users to git authors and merge their rows.
177
+ @param parser: the GitLogsParser of the repository (for verbose output)
178
+ @param rows: git rows from parser.parse(), unfiltered
179
+ @param identities: {author_name: {email: commit hash}} from get_identities
180
+ @param github_repo: "owner/repo"
181
+ @param args: the parsed command-line arguments (start, end, branch)
182
+ @returns: (combined rows, {author_name: (login, tier)})
183
+ @raises GitHubError: if GitHub cannot be read for this repository
184
+ """
185
+ data = fetch(parser, identities, github_repo)
186
+ combined = tally(parser, rows, data, identities, args.start, args.end, args.branch)
187
+ return combined, data["matches"]
142
188
 
143
189
 
144
190
  def member_names(row, matches):
@@ -190,12 +236,79 @@ def github_rows(parser, rows, repo_url, identities, args):
190
236
  result, matches = combine(parser, rows, identities, github_repo, args)
191
237
  except GitHubError as exc:
192
238
  warn(repo_url, exc)
193
- result = [
194
- unavailable_row(row, identities.get(row["username"], ())) for row in rows
195
- ]
239
+ result = unavailable_rows(rows, identities)
240
+
241
+ return select(result, args, matches, identities)
242
+
243
+
244
+ def unavailable_rows(rows, identities):
245
+ """
246
+ Mark the GitHub columns of git rows unavailable (CLI-3), keeping any
247
+ "date" (OUT-7).
248
+ @param rows: git rows
249
+ @param identities: {author_name: {email: commit hash}} from get_identities
250
+ @returns: new rows from unavailable_row
251
+ """
252
+ result = []
253
+ for row in rows:
254
+ out = unavailable_row(row, identities.get(row["username"], ()))
255
+ if "date" in row:
256
+ out["date"] = row["date"]
257
+ result.append(out)
258
+ return result
259
+
260
+
261
+ def github_day_rows(parser, repo_url, identities, args):
262
+ """
263
+ OUT-7: like github_rows, one day at a time. GitHub is read once; each
264
+ event is counted on its own local day.
265
+ @param parser: the GitLogsParser of the repository
266
+ @param repo_url: the repository as given on the command line
267
+ @param identities: {author_name: {email: commit hash}} from get_identities
268
+ @param args: the parsed command-line arguments
269
+ @returns: the final rows for this repository, each with a "date" key
270
+ """
271
+ matches = {}
272
+ github_repo = github_repo_from_url(repo_url)
273
+ result = []
274
+ try:
275
+ if github_repo is None:
276
+ raise GitHubError("not a github.com repository")
277
+ data = fetch(parser, identities, github_repo)
278
+ except GitHubError as exc:
279
+ warn(repo_url, exc)
280
+ result = unavailable_rows(parser.day_rows(), identities)
281
+ else:
282
+ matches = data["matches"]
283
+ days = events_by_day(
284
+ data["prs"], data["pushes"], args.start, args.end, args.branch
285
+ )
286
+ # STAT-15: only commits GitHub committed can be website commits
287
+ none = {"prs": [], "pushes": [], "commits": []}
288
+ for commit in data["commits"]:
289
+ if commit["committer_email"].strip().casefold() == GITHUB_COMMITTER_EMAIL:
290
+ day = local_day(commit["committed"])
291
+ days.setdefault(day, dict(none, commits=[]))["commits"].append(commit)
292
+ for day, rows in parser.parse_days(days).items():
293
+ events = days.get(day, none)
294
+ for row in tally(
295
+ parser, rows, data, identities, day, day, args.branch, events
296
+ ):
297
+ result.append(dict(row, date=day))
298
+ return select(result, args, matches, identities)
196
299
 
300
+
301
+ def select(rows, args, matches, identities):
302
+ """
303
+ Apply -u and -c to combined rows (CLI-3).
304
+ @param rows: rows from tally or unavailable_row
305
+ @param args: the parsed command-line arguments (user, clean)
306
+ @param matches: {author_name: (login, tier)}
307
+ @param identities: {author_name: {email: commit hash}} from get_identities
308
+ @returns: the rows that -u and -c keep
309
+ """
197
310
  if args.user:
198
- result = [r for r in result if keep_user(r, args.user, matches, identities)]
311
+ rows = [r for r in rows if keep_user(r, args.user, matches, identities)]
199
312
  if args.clean:
200
- result = [r for r in result if any(r[f] for f in ACTIVITY_FIELDS)]
201
- return result
313
+ rows = [r for r in rows if any(r[f] for f in ACTIVITY_FIELDS)]
314
+ return rows
@@ -6,6 +6,7 @@ All names, emails and logins here are invented.
6
6
  import pytest
7
7
 
8
8
  from gitlogstats.aggregate import (
9
+ DAY,
9
10
  DEVELOPER,
10
11
  REPOSITORY,
11
12
  aggregate,
@@ -363,6 +364,92 @@ class TestByRepository:
363
364
  assert all(two[f] is None for f in PR_COUNTS + ACTIVITY_FIELDNAMES)
364
365
 
365
366
 
366
- @pytest.mark.parametrize("mode", [DEVELOPER, REPOSITORY])
367
+ def day_row(row, date):
368
+ """A row for a single day (OUT-7)."""
369
+ return dict(row, date=date)
370
+
371
+
372
+ class TestByDay:
373
+ def test_fieldnames(self):
374
+ assert fieldnames(DAY, False) == [
375
+ "date",
376
+ *GIT_COUNTS,
377
+ "repositories",
378
+ "contributors",
379
+ ]
380
+ assert fieldnames(DAY, True) == fieldnames(DAY, False) + [
381
+ *PR_COUNTS,
382
+ *ACTIVITY_FIELDNAMES,
383
+ ]
384
+
385
+ def test_sums_each_day_in_date_order(self):
386
+ rows = [
387
+ day_row(git_row("Ada", "one", commits=2, insertions=3), "01/02/2025"),
388
+ day_row(git_row("Ada", "one", commits=1, files=1), "12/31/2024"),
389
+ day_row(git_row("Bob", "two", commits=1, deletions=4), "12/31/2024"),
390
+ day_row(git_row("Ada", "two", commits=1, insertions=5), "12/31/2024"),
391
+ ]
392
+ out = aggregate(rows, DAY, False)
393
+ assert out == [
394
+ {
395
+ "date": "12/31/2024",
396
+ "commits": 3,
397
+ "insertions": 5,
398
+ "deletions": 4,
399
+ "files": 1,
400
+ "merges": 0,
401
+ "repositories": 2,
402
+ "contributors": 2, # Ada counts once over two repositories
403
+ },
404
+ {
405
+ "date": "01/02/2025",
406
+ "commits": 2,
407
+ "insertions": 3,
408
+ "deletions": 0,
409
+ "files": 0,
410
+ "merges": 0,
411
+ "repositories": 1,
412
+ "contributors": 1,
413
+ },
414
+ ]
415
+
416
+ def test_inactive_rows_and_days_are_left_out(self):
417
+ rows = [
418
+ day_row(git_row("Ada", "one"), "03/01/2024"),
419
+ day_row(git_row("Bob", "two", commits=0), "03/01/2024"),
420
+ day_row(git_row("Bob", "two", commits=0), "03/02/2024"),
421
+ ]
422
+ (row,) = aggregate(rows, DAY, False)
423
+ assert (row["date"], row["repositories"], row["contributors"]) == (
424
+ "03/01/2024",
425
+ 1,
426
+ 1,
427
+ )
428
+
429
+ def test_github_counts_and_one_person_under_two_logins_cases(self):
430
+ rows = [
431
+ day_row(github_row("ada", "one", login="AdaL", prs_opened=1), "03/01/2024"),
432
+ day_row(
433
+ github_row("", "two", login="adal", commits=0, pushes_to_default=2),
434
+ "03/01/2024",
435
+ ),
436
+ day_row(
437
+ github_row("", "two", login="mia", commits=0, prs_merged=1),
438
+ "03/01/2024",
439
+ ),
440
+ day_row(unavailable("bob", "three"), "03/01/2024"),
441
+ day_row(unavailable("bob", "three"), "03/02/2024"),
442
+ ]
443
+ first, second = aggregate(rows, DAY, True)
444
+ assert list(first) == fieldnames(DAY, True)
445
+ assert (first["repositories"], first["contributors"]) == (3, 3)
446
+ assert (first["prs_opened"], first["prs_merged"]) == (1, 1)
447
+ assert (first["pushes_to_default"], first["commits"]) == (2, 2)
448
+ # only a repository GitHub could not be asked about was active that day
449
+ assert second["commits"] == 1
450
+ assert all(second[f] is None for f in PR_COUNTS + ACTIVITY_FIELDNAMES)
451
+
452
+
453
+ @pytest.mark.parametrize("mode", [DEVELOPER, REPOSITORY, DAY])
367
454
  def test_no_rows(mode):
368
455
  assert aggregate([], mode, True) == []
@@ -19,9 +19,11 @@ from gitlogstats.github import (
19
19
  commit_login,
20
20
  contributor_logins,
21
21
  count_pull_requests,
22
+ events_by_day,
22
23
  fetch_pull_requests,
23
24
  gh_api,
24
25
  github_repo_from_url,
26
+ local_day,
25
27
  user_profile,
26
28
  )
27
29
 
@@ -800,3 +802,39 @@ def test_count_web_commits_skips_other_committers_and_merge_results():
800
802
  ]
801
803
  # x1 and x2 are 3 and 4 seconds away; "m" is 1 second before the merge
802
804
  assert github.count_web_commits(commits, prs) == {"A": 3}
805
+
806
+
807
+ # ─── days (OUT-7) ────────────────────────────────────────────────────────────
808
+
809
+
810
+ def test_local_day_of_timestamp_and_epoch_seconds():
811
+ late = datetime.datetime(2024, 3, 10, 23, 59, 59)
812
+ early = datetime.datetime(2024, 3, 11, 0, 0, 0)
813
+ assert local_day(utc_z(late)) == "03/10/2024"
814
+ assert local_day(utc_z(early)) == "03/11/2024"
815
+ assert local_day(time.mktime(late.timetuple())) == "03/10/2024"
816
+ assert local_day(int(time.mktime(early.timetuple()))) == "03/11/2024"
817
+
818
+
819
+ def test_events_by_day_groups_pull_requests_and_pushes_by_local_day():
820
+ day = lambda d, h=12: utc_z(datetime.datetime(2024, 3, d, h))
821
+ merged = make_pr(created=day(1), merged=day(2), closed=day(2))
822
+ closed = make_pr(created=day(3), closed=day(4))
823
+ same_day = make_pr(created=day(5, 9), merged=day(5, 17), closed=day(5, 17))
824
+ other_base = make_pr(created=day(7), base="dev")
825
+ too_early = make_pr(created=utc_z(datetime.datetime(2023, 12, 31, 23, 59, 59)))
826
+ early = {"login": "u", "timestamp": day(6)}
827
+ late = {"login": "u", "timestamp": day(31)}
828
+ prs = [merged, closed, same_day, other_base, too_early]
829
+ days = events_by_day(prs, [early, late], "01/01/2024", "03/30/2024", "main")
830
+ assert {d: (e["prs"], e["pushes"], e["commits"]) for d, e in days.items()} == {
831
+ "03/01/2024": ([merged], [], []),
832
+ "03/02/2024": ([merged], [], []),
833
+ "03/03/2024": ([closed], [], []),
834
+ "03/04/2024": ([closed], [], []),
835
+ "03/05/2024": ([same_day], [], []), # listed once for its two events
836
+ "03/06/2024": ([], [early], []),
837
+ }
838
+ # without a branch, every base counts
839
+ assert "03/07/2024" in events_by_day(prs, [], "01/01/2024", "03/30/2024")
840
+ assert events_by_day([], [], "01/01/2024", "03/30/2024") == {}
@@ -2486,3 +2486,188 @@ class TestAggregate:
2486
2486
  assert not {"username", "github_username", "git_email", "github_email"} & set(
2487
2487
  rows[0]
2488
2488
  )
2489
+
2490
+
2491
+ # ─── Results by day (OUT-7) ──────────────────────────────────────────────────
2492
+
2493
+ DAY_HEADER = "date,commits,insertions,deletions,files,merges,repositories,contributors"
2494
+
2495
+
2496
+ class TestAggregateByDay:
2497
+ @pytest.fixture
2498
+ def repofile(self, tmp_path):
2499
+ """
2500
+ Two repositories. Alice works just before and after midnight on
2501
+ 03/10-03/11, in both repositories on 03/11; Bob merges on 03/12.
2502
+ """
2503
+ one = Repo(tmp_path / "remotes" / "one")
2504
+ one.write("a.txt", lines(3))
2505
+ one.commit("Alice", "late", date="2024-03-10 23:59:59")
2506
+ one.write("b.txt", lines(1))
2507
+ one.commit("Bob", "early", date="2024-03-11 00:00:00")
2508
+ one.branch("feature")
2509
+ one.write("c.txt", lines(2))
2510
+ one.commit("Alice", "feature", date="2024-03-11 09:00:00")
2511
+ one.switch("main")
2512
+ one.merge("Bob", "feature", date="2024-03-12 10:00:00")
2513
+ two = Repo(tmp_path / "remotes" / "two")
2514
+ two.write("old.txt", lines(9))
2515
+ two.commit("Carol", "old work", date="2020-01-01 12:00:00")
2516
+ two.write("d.txt", lines(4))
2517
+ two.commit("Alice", "two", date="2024-03-11 15:00:00")
2518
+ path = tmp_path / "repos.txt"
2519
+ path.write_text(f"{one.path}\n{two.path}\n")
2520
+ return str(path)
2521
+
2522
+ def run(self, monkeypatch, capsys, tmp_path, repofile, *argv, dates=DATES):
2523
+ out, _ = run_github(
2524
+ monkeypatch, capsys, tmp_path, "-rf", repofile, *dates, *argv
2525
+ )
2526
+ return out
2527
+
2528
+ def test_one_row_per_active_day(self, repofile, tmp_path, monkeypatch, capsys):
2529
+ out = self.run(monkeypatch, capsys, tmp_path, repofile, "-a", "day")
2530
+ assert out.splitlines() == [
2531
+ DAY_HEADER,
2532
+ "03/10/2024,1,3,0,1,0,1,1",
2533
+ # Bob and Alice in one, Alice again in two: she counts once
2534
+ "03/11/2024,3,7,0,3,0,2,2",
2535
+ "03/12/2024,0,0,0,0,1,1,1",
2536
+ ]
2537
+
2538
+ def test_days_add_up_to_the_totals(self, repofile, tmp_path, monkeypatch, capsys):
2539
+ days = json.loads(
2540
+ self.run(monkeypatch, capsys, tmp_path, repofile, "-a", "day", "-f", "json")
2541
+ )
2542
+ plain = json.loads(
2543
+ self.run(monkeypatch, capsys, tmp_path, repofile, "-f", "json")
2544
+ )
2545
+ for column in COUNT_COLUMNS:
2546
+ assert sum(d[column] for d in days) == sum(r[column] for r in plain)
2547
+
2548
+ def test_user_and_range(self, repofile, tmp_path, monkeypatch, capsys):
2549
+ out = self.run(
2550
+ monkeypatch, capsys, tmp_path, repofile, "-a", "day", "-u", "Alice"
2551
+ )
2552
+ assert out.splitlines()[1:] == [
2553
+ "03/10/2024,1,3,0,1,0,1,1",
2554
+ "03/11/2024,2,6,0,2,0,2,1",
2555
+ ]
2556
+ out = self.run(
2557
+ monkeypatch, capsys, tmp_path, repofile, "-a", "day",
2558
+ dates=["-s", "03/11/2024", "-e", "03/11/2024"],
2559
+ ) # fmt: skip
2560
+ assert out.splitlines()[1:] == ["03/11/2024,3,7,0,3,0,2,2"]
2561
+
2562
+ def test_clean_changes_nothing(self, repofile, tmp_path, monkeypatch, capsys):
2563
+ argv = ["-a", "day", "-f", "markdown"]
2564
+ out = self.run(monkeypatch, capsys, tmp_path, repofile, *argv)
2565
+ assert self.run(monkeypatch, capsys, tmp_path, repofile, *argv, "-c") == out
2566
+ assert out.splitlines()[0] == "| " + " | ".join(DAY_HEADER.split(",")) + " |"
2567
+
2568
+ def test_no_activity_prints_only_the_header(
2569
+ self, repofile, tmp_path, monkeypatch, capsys
2570
+ ):
2571
+ argv = ["-a", "day"]
2572
+ dates = ["-s", "01/01/2023", "-e", "12/31/2023"]
2573
+ out = self.run(monkeypatch, capsys, tmp_path, repofile, *argv, dates=dates)
2574
+ assert out == DAY_HEADER + "\n"
2575
+ out = self.run(
2576
+ monkeypatch, capsys, tmp_path, repofile, *argv, "-f", "json", dates=dates
2577
+ )
2578
+ assert json.loads(out) == []
2579
+
2580
+ def test_github_events_are_counted_on_their_own_days(
2581
+ self, tmp_path, monkeypatch, capsys
2582
+ ):
2583
+ widgets = Repo(tmp_path / "remotes" / "widgets")
2584
+ widgets.write("a.txt", lines(2))
2585
+ widgets.commit(
2586
+ "ada", "work", date="2024-03-10 12:00:00", email="ada@laptop.example"
2587
+ )
2588
+ first = head(widgets)
2589
+ widgets.write("w.txt", lines(1))
2590
+ widgets.commit(
2591
+ "ada", "Update w.txt", date="2024-03-12 12:00:00",
2592
+ email="ada@laptop.example", committer=("GitHub", "noreply@github.com"),
2593
+ ) # fmt: skip
2594
+ web = head(widgets)
2595
+ local = Repo(tmp_path / "remotes" / "local")
2596
+ local.write("b.txt", lines(5))
2597
+ local.commit("Bob", "work", date="2024-03-11 08:00:00", email="bob@example.com")
2598
+ github_world(
2599
+ tmp_path,
2600
+ monkeypatch,
2601
+ widgets,
2602
+ prs=[
2603
+ # opened on a day without commits, merged by its author later
2604
+ pr(
2605
+ "adal99", at(2024, 3, 9), merged=at(2024, 3, 11), merged_by="adal99"
2606
+ ),
2607
+ pr("mia", at(2024, 3, 11)),
2608
+ pr("mia", at(2023, 12, 31)), # before the range
2609
+ ],
2610
+ users={"adal99": {"name": "Ada", "email": None}},
2611
+ contributors=["adal99"],
2612
+ linked={first: "adal99", web: "adal99"},
2613
+ pushes=[push("adal99", at(2024, 3, 13))],
2614
+ )
2615
+ repofile = tmp_path / "repos.txt"
2616
+ repofile.write_text(f"{GITHUB_URL}\n{local.path}\n")
2617
+ argv = ["-rf", str(repofile), "--github", *DATES, "-f", "json"]
2618
+ out, _ = run_github(monkeypatch, capsys, tmp_path, *argv, "-a", "day")
2619
+ days = {d["date"]: d for d in json.loads(out)}
2620
+ assert list(days) == [
2621
+ "03/09/2024", "03/10/2024", "03/11/2024", "03/12/2024", "03/13/2024",
2622
+ ] # fmt: skip
2623
+ assert set(days["03/09/2024"]) == set(
2624
+ DAY_HEADER.split(",") + PR_COLUMNS + ACTIVITY_COLUMNS
2625
+ )
2626
+ summary = {
2627
+ day: (
2628
+ d["commits"], d["repositories"], d["contributors"],
2629
+ *[d[c] for c in PR_COLUMNS], *[d[c] for c in ACTIVITY_COLUMNS],
2630
+ )
2631
+ for day, d in days.items()
2632
+ } # fmt: skip
2633
+ assert summary == {
2634
+ "03/09/2024": (0, 1, 1, 1, 0, 0, 0, 0, 0),
2635
+ "03/10/2024": (1, 1, 1, 0, 0, 0, 0, 0, 0),
2636
+ # ada's self-merge, mia's new pull request and Bob's local commit
2637
+ "03/11/2024": (1, 2, 3, 1, 1, 0, 1, 0, 0),
2638
+ "03/12/2024": (1, 1, 1, 0, 0, 0, 0, 0, 1),
2639
+ "03/13/2024": (0, 1, 1, 0, 0, 0, 0, 1, 0),
2640
+ }
2641
+ # the days add up to the totals printed without -a
2642
+ plain = json.loads(run_github(monkeypatch, capsys, tmp_path, *argv)[0])
2643
+ for column in COUNT_COLUMNS + PR_COLUMNS + ACTIVITY_COLUMNS:
2644
+ assert sum(d[column] for d in days.values()) == sum(
2645
+ r[column] or 0 for r in plain
2646
+ ), column
2647
+
2648
+ # -u selects one developer's activity on each day, by GitHub username
2649
+ out, _ = run_github(
2650
+ monkeypatch, capsys, tmp_path, *argv, "-a", "day", "-u", "adal99"
2651
+ )
2652
+ assert [(d["date"], d["contributors"]) for d in json.loads(out)] == [
2653
+ ("03/09/2024", 1),
2654
+ ("03/10/2024", 1),
2655
+ ("03/11/2024", 1),
2656
+ ("03/12/2024", 1),
2657
+ ("03/13/2024", 1),
2658
+ ]
2659
+
2660
+ def test_unavailable_repository_has_empty_github_counts(
2661
+ self, tmp_path, monkeypatch, capsys
2662
+ ):
2663
+ local = Repo(tmp_path / "remotes" / "local")
2664
+ local.write("b.txt", lines(5))
2665
+ local.commit("Bob", "work", date="2024-03-11 08:00:00", email="bob@example.com")
2666
+ github_world(tmp_path, monkeypatch, local)
2667
+ argv = ["-r", local.path, "--github", *DATES, "-a", "day"]
2668
+ out, err = run_github(monkeypatch, capsys, tmp_path, *argv)
2669
+ assert "not a github.com repository" in err
2670
+ assert out.splitlines() == [
2671
+ DAY_HEADER + "," + ",".join(PR_COLUMNS + ACTIVITY_COLUMNS),
2672
+ "03/11/2024,1,5,0,1,0,1,1,,,,,,",
2673
+ ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes