gitlogstats 1.1.0__tar.gz → 1.2.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 (51) hide show
  1. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/.claude/CLAUDE.md +2 -1
  2. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/.claude/settings.json +3 -0
  3. gitlogstats-1.2.0/.github/workflows/build.yaml +65 -0
  4. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/PKG-INFO +14 -12
  5. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/Pipfile +0 -2
  6. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/Pipfile.lock +1 -59
  7. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/README.md +13 -11
  8. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/docs/SPEC.md +12 -6
  9. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/pyproject.toml +1 -1
  10. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/scripts/board/manifest.yaml +35 -16
  11. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/src/gitlogstats/__main__.py +3 -3
  12. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/src/gitlogstats/github.py +96 -19
  13. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/src/gitlogstats/identity.py +2 -2
  14. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/tests/fake_gh.py +16 -2
  15. gitlogstats-1.2.0/tests/fixtures/github/pulls_page1.json +29 -0
  16. gitlogstats-1.2.0/tests/fixtures/github/pulls_page2.json +53 -0
  17. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/tests/test_github.py +209 -25
  18. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/tests/test_identity.py +12 -4
  19. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/tests/test_integration.py +107 -40
  20. gitlogstats-1.1.0/.github/workflows/build.yaml +0 -46
  21. gitlogstats-1.1.0/tests/fixtures/github/pulls_page1.json +0 -20
  22. gitlogstats-1.1.0/tests/fixtures/github/pulls_page2.json +0 -29
  23. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/.claude/agents/developer-agent.md +0 -0
  24. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/.claude/agents/spec-reviewer.md +0 -0
  25. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/.claude/hooks/format-edited.sh +0 -0
  26. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/.claude/hooks/guard-paths.sh +0 -0
  27. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/.claude/hooks/guard-paths.test.mjs +0 -0
  28. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/.claude/hooks/verify.sh +0 -0
  29. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/.claude/skills/phase-handoff/SKILL.md +0 -0
  30. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/.claude/skills/stale-check/SKILL.md +0 -0
  31. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/.gitignore +0 -0
  32. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/.prettierrc.json +0 -0
  33. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/LICENSE +0 -0
  34. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/package-lock.json +0 -0
  35. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/package.json +0 -0
  36. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/repos.txt +0 -0
  37. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/requirements.txt +0 -0
  38. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/scripts/board/config.mjs +0 -0
  39. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/scripts/board/derive.mjs +0 -0
  40. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/scripts/board/derive.test.mjs +0 -0
  41. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/scripts/board/sync.mjs +0 -0
  42. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/src/gitlogstats/__init__.py +0 -0
  43. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/src/gitlogstats/git_logs_parser.py +0 -0
  44. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/src/gitlogstats/github_stats.py +0 -0
  45. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/tests/fixtures/github/commit_linked.json +0 -0
  46. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/tests/fixtures/github/commit_unlinked.json +0 -0
  47. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/tests/fixtures/github/contributors.json +0 -0
  48. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/tests/fixtures/github/contributors_page2.json +0 -0
  49. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/tests/fixtures/github/user_full.json +0 -0
  50. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/tests/fixtures/github/user_private.json +0 -0
  51. {gitlogstats-1.1.0 → gitlogstats-1.2.0}/tests/test_git_logs_parser.py +0 -0
@@ -70,7 +70,8 @@ Changes are tracked on the GitHub project board (https://github.com/users/bloomb
70
70
  - **Do not commit code changes directly to the default branch (`master`).** Create a feature branch named `feat/<REQ-ID>-<slug>` (e.g. `feat/STAT-6-mailmap`; use a short descriptive slug when no requirement id applies). **Exception:** documentation (Markdown and anything under `docs/`) and project tooling under `scripts/` and `.claude/` may be committed directly to the default branch without a PR.
71
71
  - **Open a pull request** whose description includes `Closes #N` — `N` is the board issue's number — so the issue closes on merge. Commit and push only when asked, and run the checks above first.
72
72
  - **Do not hand-create board issues.** The board is generated from the SPEC: `npm run board:derive` regenerates `scripts/board/manifest.yaml`, then `npm run board:sync` creates or updates the issues (`-- --dry-run` previews; `-- --reconcile` forces SPEC statuses back onto the board). A requirement marked `Done` in the SPEC is always closed and moved to Done; other status moves are left to the board unless `--reconcile` is passed.
73
- - Pushing to `master` publishes a release to PyPI (see `.github/workflows/build.yaml`), so bump `version` in `pyproject.toml` when a change to the package should be released.
73
+ - **Claude runs both board commands itself** (they are pre-approved in `.claude/settings.json`): `board:derive` whenever `docs/SPEC.md` changes, committing the manifest with the change; `board:sync` **before** opening a PR when the SPEC adds requirements (so `Closes #N` has an issue to point at), and again **after** the PR merges. Sync reads the working tree, so before the merge run it from a tree where the new requirements are still `Todo`/`In Progress` — syncing a branch that already marks them `Done` closes their issues before the code lands.
74
+ - Merging a pull request into `master` publishes a release to PyPI once the tests pass on the merged code (see `.github/workflows/build.yaml`); pushes to other branches, and direct pushes to `master`, publish nothing. Bump `version` in `pyproject.toml` in the PR when a change to the package should be released; a merge without a bump skips the upload.
74
75
 
75
76
  If asked to use a different workflow (e.g. commit code straight to the default branch), **remind the user that it diverges from this flow and confirm before proceeding** — then honor the confirmed request.
76
77
 
@@ -1,4 +1,7 @@
1
1
  {
2
+ "permissions": {
3
+ "allow": ["Bash(npm run board:derive*)", "Bash(npm run board:sync*)"]
4
+ },
2
5
  "hooks": {
3
6
  "PreToolUse": [
4
7
  {
@@ -0,0 +1,65 @@
1
+ name: CI / CD
2
+ # CI runs on every pull request into master and on every push to master.
3
+ # Delivery to PyPI happens only when a pull request is merged into master,
4
+ # and only if the tests pass on the merged code (PKG-2, PKG-4).
5
+ on:
6
+ push:
7
+ branches: [master]
8
+ pull_request:
9
+ branches: [master]
10
+ types: [opened, synchronize, reopened, closed]
11
+ permissions:
12
+ contents: read
13
+ jobs:
14
+ build:
15
+ # a pull request closed without merging needs no checks
16
+ if: github.event.action != 'closed' || github.event.pull_request.merged
17
+ runs-on: ubuntu-latest
18
+ timeout-minutes: 10
19
+ strategy:
20
+ matrix:
21
+ python-version: ['3.9', '3.11']
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ with:
25
+ # after a merge, test the merge commit that landed on master
26
+ ref: ${{ github.event.pull_request.merged && github.event.pull_request.merge_commit_sha || github.sha }}
27
+ - name: Set up Python ${{ matrix.python-version }}
28
+ uses: actions/setup-python@v5
29
+ with:
30
+ python-version: ${{ matrix.python-version }}
31
+ - name: Install dependencies
32
+ run: |
33
+ python -m pip install --upgrade pip
34
+ pip install --user pipenv
35
+ pipenv --python python install --dev --skip-lock
36
+ - name: Run tests
37
+ run: |
38
+ pipenv run python -m pytest -q
39
+ - name: Build package
40
+ run: |
41
+ pipenv run python -m build .
42
+ deliver:
43
+ # only a merged pull request is released, and only after build passed
44
+ if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged
45
+ needs: [build]
46
+ runs-on: ubuntu-latest
47
+ timeout-minutes: 5
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+ with:
51
+ ref: ${{ github.event.pull_request.merge_commit_sha }}
52
+ - name: Set up Python
53
+ uses: actions/setup-python@v5
54
+ with:
55
+ python-version: '3.11'
56
+ - name: Build package
57
+ run: |
58
+ python -m pip install --upgrade pip build
59
+ python -m build .
60
+ - name: Publish to PyPI
61
+ uses: pypa/gh-action-pypi-publish@release/v1
62
+ with:
63
+ password: ${{ secrets.PYPI_API_TOKEN }}
64
+ # a merge without a version bump (e.g. tooling only) skips the upload
65
+ skip-existing: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitlogstats
3
- Version: 1.1.0
3
+ Version: 1.2.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
@@ -709,7 +709,7 @@ This script calculates the following, for each developer in each repository:
709
709
  - number of **files changed**
710
710
  - number of **merges** into the analyzed branch
711
711
 
712
- With `--github`, it also reports each developer's GitHub **pull requests** opened, merged and closed without merging (see [GitHub pull requests](#github-pull-requests)).
712
+ With `--github`, it also reports each developer's GitHub **pull requests** opened, merged, closed without merging and merged by themselves (see [GitHub pull requests](#github-pull-requests)).
713
713
 
714
714
  The results can be formatted as `csv`, `json`, or `markdown`.
715
715
 
@@ -751,9 +751,10 @@ options:
751
751
  The branch to parse, if not the default branch
752
752
  -v, --verbose Whether to output debugging info
753
753
  -c, --clean Remove contributors without any contribuition
754
- --github Also report GitHub pull requests opened, merged and
755
- closed per contributor, and match GitHub users to git
756
- authors. Needs the GitHub CLI (gh), signed in
754
+ --github Also report GitHub pull requests opened, merged,
755
+ closed and self-merged per contributor, and match
756
+ GitHub users to git authors. Needs the GitHub CLI
757
+ (gh), signed in
757
758
  ```
758
759
 
759
760
  In case `gitlogstats` is not found as a command, even after installing, try `python -m gitlogstats` or `python3 -m gitlogstats` instead.
@@ -836,14 +837,15 @@ gitlogstats -rf repos.txt -c
836
837
 
837
838
  ### GitHub pull requests
838
839
 
839
- Add `--github` to also count each contributor's pull requests. Four columns are appended to every output format:
840
+ Add `--github` to also count each contributor's pull requests. Five columns are appended to every output format:
840
841
 
841
- | Column | Meaning |
842
- | --------------------- | ----------------------------------------------------------------------------------------------------- |
843
- | `github_username` | the GitHub account matched to the contributor (never the display name), so you can check the matching |
844
- | `prs_opened` | pull requests they opened in the date range, whatever their state now (drafts included) |
845
- | `prs_merged` | pull requests they authored that were merged in the date range, by anyone and in any way (squash too) |
846
- | `prs_closed_unmerged` | pull requests they authored that were closed without merging in the date range |
842
+ | Column | Meaning |
843
+ | --------------------- | ------------------------------------------------------------------------------------------------------- |
844
+ | `github_username` | the GitHub account matched to the contributor (never the display name), so you can check the matching |
845
+ | `prs_opened` | pull requests they opened in the date range, whatever their state now (drafts included) |
846
+ | `prs_merged` | pull requests they authored that were merged in the date range, by anyone and in any way (squash too) |
847
+ | `prs_closed_unmerged` | pull requests they authored that were closed without merging in the date range |
848
+ | `prs_self_merged` | pull requests they authored and also merged themselves, merged in the date range (also in `prs_merged`) |
847
849
 
848
850
  ```
849
851
  gitlogstats -r https://github.com/bloombar/gitlogstats.git --github
@@ -5,8 +5,6 @@ name = "pypi"
5
5
 
6
6
  [packages]
7
7
  gitlogstats = {editable = true, path = "."}
8
- pytest = "*"
9
- build = "*"
10
8
 
11
9
  [dev-packages]
12
10
  build = "*"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "_meta": {
3
3
  "hash": {
4
- "sha256": "70462452caf90504e4b0e1fa21f9c94c9ab312ca80771fa5d2d656e939a719aa"
4
+ "sha256": "d1fee7a304fa34eaf74ad0dd93b88859d18b03c32476298928a490a03ec616e8"
5
5
  },
6
6
  "pipfile-spec": 6,
7
7
  "requires": {
@@ -16,67 +16,9 @@
16
16
  ]
17
17
  },
18
18
  "default": {
19
- "build": {
20
- "hashes": [
21
- "sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53",
22
- "sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7"
23
- ],
24
- "index": "pypi",
25
- "markers": "python_version >= '3.10'",
26
- "version": "==1.6.1"
27
- },
28
19
  "gitlogstats": {
29
20
  "editable": true,
30
21
  "path": "."
31
- },
32
- "iniconfig": {
33
- "hashes": [
34
- "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960",
35
- "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7"
36
- ],
37
- "markers": "python_version >= '3.10'",
38
- "version": "==2.3.1"
39
- },
40
- "packaging": {
41
- "hashes": [
42
- "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79",
43
- "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"
44
- ],
45
- "markers": "python_version >= '3.9'",
46
- "version": "==26.3"
47
- },
48
- "pluggy": {
49
- "hashes": [
50
- "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec",
51
- "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8"
52
- ],
53
- "markers": "python_version >= '3.10'",
54
- "version": "==1.7.0"
55
- },
56
- "pygments": {
57
- "hashes": [
58
- "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9",
59
- "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"
60
- ],
61
- "markers": "python_version >= '3.9'",
62
- "version": "==2.21.0"
63
- },
64
- "pyproject-hooks": {
65
- "hashes": [
66
- "sha256:5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b",
67
- "sha256:defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43"
68
- ],
69
- "markers": "python_version >= '3.8'",
70
- "version": "==1.3.3"
71
- },
72
- "pytest": {
73
- "hashes": [
74
- "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313",
75
- "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"
76
- ],
77
- "index": "pypi",
78
- "markers": "python_version >= '3.10'",
79
- "version": "==9.1.1"
80
22
  }
81
23
  },
82
24
  "develop": {
@@ -12,7 +12,7 @@ This script calculates the following, for each developer in each repository:
12
12
  - number of **files changed**
13
13
  - number of **merges** into the analyzed branch
14
14
 
15
- With `--github`, it also reports each developer's GitHub **pull requests** opened, merged and closed without merging (see [GitHub pull requests](#github-pull-requests)).
15
+ With `--github`, it also reports each developer's GitHub **pull requests** opened, merged, closed without merging and merged by themselves (see [GitHub pull requests](#github-pull-requests)).
16
16
 
17
17
  The results can be formatted as `csv`, `json`, or `markdown`.
18
18
 
@@ -54,9 +54,10 @@ options:
54
54
  The branch to parse, if not the default branch
55
55
  -v, --verbose Whether to output debugging info
56
56
  -c, --clean Remove contributors without any contribuition
57
- --github Also report GitHub pull requests opened, merged and
58
- closed per contributor, and match GitHub users to git
59
- authors. Needs the GitHub CLI (gh), signed in
57
+ --github Also report GitHub pull requests opened, merged,
58
+ closed and self-merged per contributor, and match
59
+ GitHub users to git authors. Needs the GitHub CLI
60
+ (gh), signed in
60
61
  ```
61
62
 
62
63
  In case `gitlogstats` is not found as a command, even after installing, try `python -m gitlogstats` or `python3 -m gitlogstats` instead.
@@ -139,14 +140,15 @@ gitlogstats -rf repos.txt -c
139
140
 
140
141
  ### GitHub pull requests
141
142
 
142
- Add `--github` to also count each contributor's pull requests. Four columns are appended to every output format:
143
+ Add `--github` to also count each contributor's pull requests. Five columns are appended to every output format:
143
144
 
144
- | Column | Meaning |
145
- | --------------------- | ----------------------------------------------------------------------------------------------------- |
146
- | `github_username` | the GitHub account matched to the contributor (never the display name), so you can check the matching |
147
- | `prs_opened` | pull requests they opened in the date range, whatever their state now (drafts included) |
148
- | `prs_merged` | pull requests they authored that were merged in the date range, by anyone and in any way (squash too) |
149
- | `prs_closed_unmerged` | pull requests they authored that were closed without merging in the date range |
145
+ | Column | Meaning |
146
+ | --------------------- | ------------------------------------------------------------------------------------------------------- |
147
+ | `github_username` | the GitHub account matched to the contributor (never the display name), so you can check the matching |
148
+ | `prs_opened` | pull requests they opened in the date range, whatever their state now (drafts included) |
149
+ | `prs_merged` | pull requests they authored that were merged in the date range, by anyone and in any way (squash too) |
150
+ | `prs_closed_unmerged` | pull requests they authored that were closed without merging in the date range |
151
+ | `prs_self_merged` | pull requests they authored and also merged themselves, merged in the date range (also in `prs_merged`) |
150
152
 
151
153
  ```
152
154
  gitlogstats -r https://github.com/bloombar/gitlogstats.git --github
@@ -114,6 +114,12 @@ If a tier finds several GitHub users for one identity, that tier is skipped as a
114
114
 
115
115
  **Status:** Done
116
116
 
117
+ #### STAT-12 Self-merged pull requests
118
+
119
+ With `--github`, for each contributor, the number of pull requests they authored and also merged themselves (the GitHub user who merged the pull request is its author, ignoring case), with a merge time (`merged_at`) in the date range. Reported in a `prs_self_merged` column, appended after `prs_closed_unmerged`. Every self-merged pull request is also counted in `prs_merged` (STAT-9), so `prs_self_merged` is never larger than `prs_merged`. A merged pull request whose merger GitHub does not report (e.g. a deleted account) is not counted as self-merged. `-b` and `-s/-e` apply as for `prs_merged`.
120
+
121
+ **Status:** Done
122
+
117
123
  ### 3. Filtering
118
124
 
119
125
  #### FILT-1 Date range
@@ -144,7 +150,7 @@ By default, contributors with no activity in the date range are listed with zero
144
150
 
145
151
  #### OUT-1 CSV output
146
152
 
147
- `-f csv` (the default) prints a header row followed by one row per contributor per repository, with fields `username, repository, start_date, end_date, commits, insertions, deletions, files, merges`. New fields are added at the end so existing column positions do not change. Values containing commas, quotes or line breaks are quoted. The header is printed even when there are no rows, and the output ends with a single newline. With `--github`, the columns `github_username, prs_opened, prs_merged, prs_closed_unmerged` are appended, and are in the header even when there are no rows (CLI-3).
153
+ `-f csv` (the default) prints a header row followed by one row per contributor per repository, with fields `username, repository, start_date, end_date, commits, insertions, deletions, files, merges`. New fields are added at the end so existing column positions do not change. Values containing commas, quotes or line breaks are quoted. The header is printed even when there are no rows, and the output ends with a single newline. With `--github`, the columns `github_username, prs_opened, prs_merged, prs_closed_unmerged, prs_self_merged` are appended, and are in the header even when there are no rows (CLI-3).
148
154
 
149
155
  **Status:** Done
150
156
 
@@ -188,12 +194,12 @@ Invalid dates, and a start date after the end date, are rejected with a usage er
188
194
 
189
195
  #### CLI-3 GitHub pull-request statistics
190
196
 
191
- `--github` adds GitHub pull-request statistics (STAT-8 to STAT-11) for repositories hosted on github.com, appending the columns `github_username, prs_opened, prs_merged, prs_closed_unmerged` to every output format. Without `--github`, the output and behaviour are unchanged, GitHub is never contacted and the GitHub CLI (`gh`) is not needed. With `--github`, the GitHub API is called through `gh api`, so `gh` must be installed and signed in. This is checked before any repository is cloned; if it fails, nothing is printed to standard output, the tool exits with status 1, and one plain message on standard error says why and what to do — no stack trace:
197
+ `--github` adds GitHub pull-request statistics (STAT-8 to STAT-12) for repositories hosted on github.com, appending the columns `github_username, prs_opened, prs_merged, prs_closed_unmerged, prs_self_merged` to every output format. Without `--github`, the output and behaviour are unchanged, GitHub is never contacted and the GitHub CLI (`gh`) is not needed. With `--github`, the GitHub API is called through `gh api`, so `gh` must be installed and signed in. This is checked before any repository is cloned; if it fails, nothing is printed to standard output, the tool exits with status 1, and one plain message on standard error says why and what to do — no stack trace:
192
198
 
193
199
  - `gh` not found: `gitlogstats: --github needs the GitHub CLI (gh), which is not installed. Install it from https://cli.github.com and run 'gh auth login', or run without --github.`
194
200
  - `gh` not signed in (`gh auth status` fails): `gitlogstats: --github needs the GitHub CLI (gh) to be signed in. Run 'gh auth login', or run without --github.`
195
201
 
196
- The other options apply to pull requests as follows: `-s/-e` filter each count by its own timestamp, using the same inclusive local-time range as FILT-1; `-u` selects the combined row whose author names, emails or GitHub username match exactly (the username ignoring case); `-b` counts only pull requests whose base branch is that branch, and without `-b` every pull request counts; `-c` removes only rows with neither git nor pull-request activity; `-x` does not apply. For a repository not on github.com, or when GitHub cannot be reached for one repository (no network, rate limit, private repository without access), the git statistics are still reported, the four GitHub columns are empty (`null` in JSON) rather than zero, and the reason is printed to standard error.
202
+ The other options apply to pull requests as follows: `-s/-e` filter each count by its own timestamp, using the same inclusive local-time range as FILT-1; `-u` selects the combined row whose author names, emails or GitHub username match exactly (the username ignoring case); `-b` counts only pull requests whose base branch is that branch, and without `-b` every pull request counts; `-c` removes only rows with neither git nor pull-request activity; `-x` does not apply. For a repository not on github.com, or when GitHub cannot be reached for one repository (no network, rate limit, private repository without access), the git statistics are still reported, the five GitHub columns are empty (`null` in JSON) rather than zero, and the reason is printed to standard error.
197
203
 
198
204
  **Status:** Done
199
205
 
@@ -207,7 +213,7 @@ The tool installs with `pip install gitlogstats`, which provides a `gitlogstats`
207
213
 
208
214
  #### PKG-2 Continuous integration and release
209
215
 
210
- GitHub Actions builds the package on every push, on Python 3.9 and 3.11, and publishes it to PyPI.
216
+ GitHub Actions builds the package on Python 3.9 and 3.11 for every pull request into `master` and every push to `master`. It publishes to PyPI only when a pull request is merged into `master` and the build of the merged code passes; pushes to other branches publish nothing. A merge whose version is already on PyPI (no version bump) skips the upload instead of failing.
211
217
 
212
218
  **Status:** Done
213
219
 
@@ -219,9 +225,9 @@ Unit tests cover parsing and formatting, and integration tests build real git re
219
225
 
220
226
  #### PKG-4 Run tests in CI
221
227
 
222
- The CI workflow runs the test suite on every push and stops the release if any test fails. (Today it builds and publishes without running tests.)
228
+ The CI workflow runs the Python test suite (`pytest`) on every pull request into `master` and every push to `master`, and on the merged code before a release; a failing test stops the release.
223
229
 
224
- **Status:** Todo
230
+ **Status:** Done
225
231
 
226
232
  ### 7. Future Work
227
233
 
@@ -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.1.0"
8
+ version = "1.2.0"
9
9
  authors = [
10
10
  { name="Tobody Tothing", email="nobody.nothing@nowhere.com" },
11
11
  ]
@@ -19,16 +19,16 @@
19
19
  status: Done
20
20
  - id: CLI-3
21
21
  title: GitHub pull-request statistics
22
- full: "--github adds GitHub pull-request statistics (STAT-8 to STAT-11) for
22
+ full: "--github adds GitHub pull-request statistics (STAT-8 to STAT-12) for
23
23
  repositories hosted on github.com, appending the columns github_username,
24
- prs_opened, prs_merged, prs_closed_unmerged to every output format. Without
25
- --github, the output and behaviour are unchanged, GitHub is never contacted
26
- and the GitHub CLI (gh) is not needed. With --github, the GitHub API is
27
- called through gh api, so gh must be installed and signed in. This is
28
- checked before any repository is cloned; if it fails, nothing is printed to
29
- standard output, the tool exits with status 1, and one plain message on
30
- standard error says why and what to do — no stack trace: gh not found:
31
- gitlogstats: --github needs the GitHub CLI…"
24
+ prs_opened, prs_merged, prs_closed_unmerged, prs_self_merged to every output
25
+ format. Without --github, the output and behaviour are unchanged, GitHub is
26
+ never contacted and the GitHub CLI (gh) is not needed. With --github, the
27
+ GitHub API is called through gh api, so gh must be installed and signed in.
28
+ This is checked before any repository is cloned; if it fails, nothing is
29
+ printed to standard output, the tool exits with status 1, and one plain
30
+ message on standard error says why and what to do — no stack trace: gh not
31
+ found: gitlogstats: --github…"
32
32
  section: §5 Command-Line Interface
33
33
  family: CLI
34
34
  phase: 1
@@ -89,8 +89,8 @@
89
89
  containing commas, quotes or line breaks are quoted. The header is printed
90
90
  even when there are no rows, and the output ends with a single newline. With
91
91
  --github, the columns github_username, prs_opened, prs_merged,
92
- prs_closed_unmerged are appended, and are in the header even when there are
93
- no rows (CLI-3).
92
+ prs_closed_unmerged, prs_self_merged are appended, and are in the header
93
+ even when there are no rows (CLI-3).
94
94
  section: §4 Output
95
95
  family: OUT
96
96
  phase: 1
@@ -141,8 +141,11 @@
141
141
  status: Done
142
142
  - id: PKG-2
143
143
  title: Continuous integration and release
144
- full: GitHub Actions builds the package on every push, on Python 3.9 and 3.11,
145
- and publishes it to PyPI.
144
+ full: GitHub Actions builds the package on Python 3.9 and 3.11 for every pull
145
+ request into master and every push to master. It publishes to PyPI only when
146
+ a pull request is merged into master and the build of the merged code
147
+ passes; pushes to other branches publish nothing. A merge whose version is
148
+ already on PyPI (no version bump) skips the upload instead of failing.
146
149
  section: §6 Packaging & Quality
147
150
  family: PKG
148
151
  phase: 1
@@ -159,12 +162,13 @@
159
162
  status: Done
160
163
  - id: PKG-4
161
164
  title: Run tests in CI
162
- full: The CI workflow runs the test suite on every push and stops the release if
163
- any test fails. (Today it builds and publishes without running tests.)
165
+ full: The CI workflow runs the Python test suite (pytest) on every pull request
166
+ into master and every push to master, and on the merged code before a
167
+ release; a failing test stops the release.
164
168
  section: §6 Packaging & Quality
165
169
  family: PKG
166
170
  phase: 1
167
- status: Todo
171
+ status: Done
168
172
  - id: REPO-1
169
173
  title: Single repository
170
174
  full: -r/--repository <url> analyzes one repository. Any URL or path that git
@@ -329,6 +333,21 @@
329
333
  family: STAT
330
334
  phase: 1
331
335
  status: Done
336
+ - id: STAT-12
337
+ title: Self-merged pull requests
338
+ full: With --github, for each contributor, the number of pull requests they
339
+ authored and also merged themselves (the GitHub user who merged the pull
340
+ request is its author, ignoring case), with a merge time (merged_at) in the
341
+ date range. Reported in a prs_self_merged column, appended after
342
+ prs_closed_unmerged. Every self-merged pull request is also counted in
343
+ prs_merged (STAT-9), so prs_self_merged is never larger than prs_merged. A
344
+ merged pull request whose merger GitHub does not report (e.g. a deleted
345
+ account) is not counted as self-merged. -b and -s/-e apply as for
346
+ prs_merged.
347
+ section: §2 Contribution Statistics
348
+ family: STAT
349
+ phase: 1
350
+ status: Done
332
351
  - id: FUTURE-author-date-filtering
333
352
  title: Author-date filtering
334
353
  full: Author-date filtering — offer filtering by author date, so rebased or
@@ -141,9 +141,9 @@ def main():
141
141
  )
142
142
  parser.add_argument(
143
143
  "--github",
144
- help="Also report GitHub pull requests opened, merged and closed per "
145
- "contributor, and match GitHub users to git authors. Needs the GitHub CLI "
146
- "(gh), signed in",
144
+ help="Also report GitHub pull requests opened, merged, closed and "
145
+ "self-merged per contributor, and match GitHub users to git authors. "
146
+ "Needs the GitHub CLI (gh), signed in",
147
147
  default=False,
148
148
  action="store_true",
149
149
  )
@@ -18,6 +18,7 @@ GITHUB_FIELDNAMES = [
18
18
  "prs_opened",
19
19
  "prs_merged",
20
20
  "prs_closed_unmerged",
21
+ "prs_self_merged",
21
22
  ]
22
23
 
23
24
  # CLI-3: the two user-facing messages, verbatim from the SPEC
@@ -31,6 +32,17 @@ GH_NOT_SIGNED_IN = (
31
32
  "Run 'gh auth login', or run without --github."
32
33
  )
33
34
 
35
+ # STAT-12: the list endpoint of the REST API does not say who merged a pull
36
+ # request, but GraphQL does, in one paginated query (gh fills in $endCursor)
37
+ PULLS_QUERY = (
38
+ "query($owner:String!,$name:String!,$endCursor:String){"
39
+ "repository(owner:$owner,name:$name){"
40
+ "pullRequests(first:100,after:$endCursor){"
41
+ "pageInfo{hasNextPage endCursor} "
42
+ "nodes{author{login __typename} mergedBy{login __typename} "
43
+ "state createdAt mergedAt closedAt baseRefName}}}}"
44
+ )
45
+
34
46
  # Login given to pull requests whose author's account was deleted
35
47
  GHOST_LOGIN = "ghost"
36
48
 
@@ -148,29 +160,84 @@ def gh_api(path, paginate=False):
148
160
  raise GitHubError(f"gh api {path} returned invalid JSON: {exc}")
149
161
 
150
162
 
163
+ def gh_graphql(query, variables):
164
+ """
165
+ Run a paginated GraphQL query through gh. gh prints one JSON document per
166
+ page, back to back.
167
+ @param query: the query text; it must use $endCursor for pagination
168
+ @param variables: {name: string value}, passed as separate arguments
169
+ @returns: a list with one parsed response per page
170
+ @raises GitHubError: if gh exits with an error or the response has errors
171
+ """
172
+ cmd = ["gh", "api", "graphql", "--paginate", "-f", f"query={query}"]
173
+ for name, value in variables.items():
174
+ # -f sends a raw string; -F would turn "2048" or "true" into a number
175
+ # or boolean, which GitHub rejects for String! variables
176
+ cmd += ["-f", f"{name}={value}"]
177
+ result = subprocess.run(
178
+ cmd,
179
+ stdout=subprocess.PIPE,
180
+ stderr=subprocess.PIPE,
181
+ universal_newlines=True,
182
+ )
183
+ if result.returncode != 0:
184
+ raise GitHubError(result.stderr.strip() or "gh api graphql failed")
185
+ try:
186
+ pages = _parse_json_stream(result.stdout)
187
+ except ValueError as exc: # json.JSONDecodeError is a ValueError
188
+ raise GitHubError(f"gh api graphql returned invalid JSON: {exc}")
189
+ if isinstance(pages, dict):
190
+ pages = [pages]
191
+ for page in pages:
192
+ if page.get("errors"):
193
+ messages = [e.get("message", "unknown error") for e in page["errors"]]
194
+ raise GitHubError("; ".join(messages))
195
+ return pages
196
+
197
+
198
+ def _graphql_login(account):
199
+ """
200
+ Read a login from a GraphQL author or merger. GraphQL leaves "[bot]" off
201
+ bot logins that the REST API includes, so it is added back.
202
+ @param account: {"login", "__typename"}, or None for a deleted account
203
+ @returns: the login, or None if there is no account
204
+ """
205
+ if not account:
206
+ return None
207
+ login = account["login"]
208
+ return login + "[bot]" if account.get("__typename") == "Bot" else login
209
+
210
+
151
211
  def fetch_pull_requests(repo):
152
212
  """
153
213
  List every pull request of a repository, in any state.
154
214
  @param repo: "owner/repo"
155
- @returns: a list of dicts with login, user_type, state, created_at,
156
- merged_at, closed_at and base (the base branch name)
215
+ @returns: a list of dicts with login, user_type, state ("open" or
216
+ "closed"), created_at, merged_at, closed_at, base (the base branch
217
+ name) and merged_by (a login, or None if not merged or unknown)
157
218
  @raises GitHubError: if the request fails
158
219
  """
159
- pulls = gh_api(f"repos/{repo}/pulls?state=all&per_page=100", paginate=True)
220
+ owner, name = repo.split("/", 1)
221
+ pages = gh_graphql(PULLS_QUERY, {"owner": owner, "name": name})
160
222
  result = []
161
- for pull in pulls:
162
- user = pull.get("user")
163
- result.append(
164
- {
165
- "login": user["login"] if user else GHOST_LOGIN,
166
- "user_type": user.get("type") if user else None,
167
- "state": pull.get("state"),
168
- "created_at": pull.get("created_at"),
169
- "merged_at": pull.get("merged_at"),
170
- "closed_at": pull.get("closed_at"),
171
- "base": (pull.get("base") or {}).get("ref"),
172
- }
173
- )
223
+ for page in pages:
224
+ repository = (page.get("data") or {}).get("repository")
225
+ if repository is None:
226
+ raise GitHubError(f"could not read pull requests of {repo}")
227
+ for pull in repository["pullRequests"]["nodes"]:
228
+ author = pull.get("author")
229
+ result.append(
230
+ {
231
+ "login": _graphql_login(author) or GHOST_LOGIN,
232
+ "user_type": author.get("__typename") if author else None,
233
+ "state": "open" if pull.get("state") == "OPEN" else "closed",
234
+ "created_at": pull.get("createdAt"),
235
+ "merged_at": pull.get("mergedAt"),
236
+ "closed_at": pull.get("closedAt"),
237
+ "base": pull.get("baseRefName"),
238
+ "merged_by": _graphql_login(pull.get("mergedBy")),
239
+ }
240
+ )
174
241
  return result
175
242
 
176
243
 
@@ -188,13 +255,14 @@ def _local_time(timestamp):
188
255
 
189
256
  def count_pull_requests(prs, start, end, branch=None):
190
257
  """
191
- STAT-8, STAT-9, STAT-10: count each user's pull requests in a date range.
258
+ STAT-8, STAT-9, STAT-10, STAT-12: count each user's pull requests in a date range.
192
259
  The range is inclusive local time, as in FILT-1.
193
260
  @param prs: the list from fetch_pull_requests
194
261
  @param start: first day, mm/dd/yyyy
195
262
  @param end: last day, mm/dd/yyyy
196
263
  @param branch: count only pull requests with this base branch, if given
197
- @returns: {login: {"prs_opened", "prs_merged", "prs_closed_unmerged"}}
264
+ @returns: {login: {"prs_opened", "prs_merged", "prs_closed_unmerged",
265
+ "prs_self_merged"}}
198
266
  """
199
267
  since = datetime.datetime.strptime(start, "%m/%d/%Y")
200
268
  until = datetime.datetime.strptime(end, "%m/%d/%Y").replace(
@@ -211,12 +279,21 @@ def count_pull_requests(prs, start, end, branch=None):
211
279
  continue
212
280
  row = counts.setdefault(
213
281
  pr["login"],
214
- {"prs_opened": 0, "prs_merged": 0, "prs_closed_unmerged": 0},
282
+ {
283
+ "prs_opened": 0,
284
+ "prs_merged": 0,
285
+ "prs_closed_unmerged": 0,
286
+ "prs_self_merged": 0,
287
+ },
215
288
  )
216
289
  if in_range(pr["created_at"]):
217
290
  row["prs_opened"] += 1
218
291
  if in_range(pr["merged_at"]):
219
292
  row["prs_merged"] += 1
293
+ # STAT-12: the merger is the author; an unknown merger never is
294
+ merger = pr.get("merged_by")
295
+ if merger and merger.casefold() == pr["login"].casefold():
296
+ row["prs_self_merged"] += 1
220
297
  if (
221
298
  pr["state"] == "closed"
222
299
  and not pr["merged_at"]
@@ -184,7 +184,7 @@ def consolidate(rows, matches, pr_counts, repository, start, end):
184
184
  appended after the git rows in login order.
185
185
  @param rows: git rows for one repository, from GitLogsParser.parse().
186
186
  @param matches: {author_name: (login, tier)} from match_identities.
187
- @param pr_counts: {login: {"prs_opened", "prs_merged", "prs_closed_unmerged"}}.
187
+ @param pr_counts: {login: {PR column: count}} from count_pull_requests.
188
188
  @param repository: kept for GitHub-only rows.
189
189
  @param start: start date kept for GitHub-only rows.
190
190
  @param end: end date kept for GitHub-only rows.
@@ -243,7 +243,7 @@ def unavailable_row(row):
243
243
  """
244
244
  Copy a git row with the GitHub columns marked unavailable (CLI-3).
245
245
  @param row: a git row.
246
- @returns: a new row whose four GitHub columns are None.
246
+ @returns: a new row whose GitHub columns are all None.
247
247
  """
248
248
  out = {field: row[field] for field in FIELDNAMES}
249
249
  for field in GITHUB_FIELDNAMES: