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.
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/PKG-INFO +16 -5
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/README.md +15 -4
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/docs/SPEC.md +8 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/pyproject.toml +1 -1
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/scripts/board/manifest.yaml +16 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/__main__.py +20 -8
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/aggregate.py +32 -14
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/git_logs_parser.py +73 -17
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/github.py +45 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/github_stats.py +143 -30
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/test_aggregate.py +88 -1
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/test_github.py +38 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/test_integration.py +185 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/CLAUDE.md +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/agents/developer-agent.md +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/agents/spec-reviewer.md +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/hooks/format-edited.sh +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/hooks/guard-paths.sh +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/hooks/guard-paths.test.mjs +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/hooks/verify.sh +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/settings.json +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/skills/phase-handoff/SKILL.md +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.claude/skills/stale-check/SKILL.md +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.github/workflows/build.yaml +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.gitignore +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/.prettierrc.json +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/LICENSE +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/Pipfile +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/Pipfile.lock +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/docs/examples/README.md +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/docs/examples/assignment-stats.sh +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/docs/examples/project-1-stats.csv +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/package-lock.json +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/package.json +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/repos.txt +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/requirements.txt +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/scripts/board/config.mjs +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/scripts/board/derive.mjs +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/scripts/board/derive.test.mjs +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/scripts/board/sync.mjs +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/__init__.py +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/clones.py +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/src/gitlogstats/identity.py +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fake_gh.py +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/commit_linked.json +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/commit_unlinked.json +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/contributors.json +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/contributors_page2.json +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/pulls_page1.json +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/pulls_page2.json +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/user_full.json +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/fixtures/github/user_private.json +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/test_clobber.py +0 -0
- {gitlogstats-1.6.0 → gitlogstats-1.7.0}/tests/test_git_logs_parser.py +0 -0
- {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.
|
|
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,
|
|
758
|
-
|
|
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,
|
|
61
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
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
|
-
|
|
19
|
+
DAY = "day"
|
|
20
|
+
MODES = [DEVELOPER, REPOSITORY, DAY]
|
|
18
21
|
|
|
19
|
-
# the git counts (commits to merges), summed in
|
|
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
|
|
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
|
|
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
|
|
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
|
|
156
|
-
sorted by username (or GitHub username, for a developer with no git
|
|
157
|
-
username) or by repository, ignoring case.
|
|
158
|
-
|
|
159
|
-
@param
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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
|
|
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
|
-
@
|
|
53
|
-
|
|
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 =
|
|
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
|
-
|
|
161
|
+
return consolidate(
|
|
132
162
|
rows,
|
|
133
|
-
matches,
|
|
163
|
+
data["matches"],
|
|
134
164
|
pr_counts,
|
|
135
165
|
repository,
|
|
136
|
-
|
|
137
|
-
|
|
166
|
+
start,
|
|
167
|
+
end,
|
|
138
168
|
identities,
|
|
139
|
-
github_emails,
|
|
169
|
+
data["github_emails"],
|
|
140
170
|
)
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
+
rows = [r for r in rows if keep_user(r, args.user, matches, identities)]
|
|
199
312
|
if args.clean:
|
|
200
|
-
|
|
201
|
-
return
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|