punchlist 0.6.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.
- punchlist-0.6.0/.gitignore +19 -0
- punchlist-0.6.0/LICENSE +21 -0
- punchlist-0.6.0/PKG-INFO +118 -0
- punchlist-0.6.0/README.md +89 -0
- punchlist-0.6.0/pyproject.toml +80 -0
- punchlist-0.6.0/src/punchlist/__init__.py +1 -0
- punchlist-0.6.0/src/punchlist/check.py +324 -0
- punchlist-0.6.0/src/punchlist/cli.py +823 -0
- punchlist-0.6.0/src/punchlist/comment.py +162 -0
- punchlist-0.6.0/src/punchlist/config.py +321 -0
- punchlist-0.6.0/src/punchlist/errors.py +34 -0
- punchlist-0.6.0/src/punchlist/git.py +141 -0
- punchlist-0.6.0/src/punchlist/history.py +261 -0
- punchlist-0.6.0/src/punchlist/ids.py +72 -0
- punchlist-0.6.0/src/punchlist/importer.py +299 -0
- punchlist-0.6.0/src/punchlist/issue.py +351 -0
- punchlist-0.6.0/src/punchlist/ops.py +287 -0
- punchlist-0.6.0/src/punchlist/static/board.html +466 -0
- punchlist-0.6.0/src/punchlist/web.py +408 -0
- punchlist-0.6.0/tests/conftest.py +25 -0
- punchlist-0.6.0/tests/fixtures/roadmap_shapes.md +42 -0
- punchlist-0.6.0/tests/helpers.py +38 -0
- punchlist-0.6.0/tests/test_check.py +247 -0
- punchlist-0.6.0/tests/test_cli.py +685 -0
- punchlist-0.6.0/tests/test_comment.py +290 -0
- punchlist-0.6.0/tests/test_config.py +166 -0
- punchlist-0.6.0/tests/test_git.py +83 -0
- punchlist-0.6.0/tests/test_history.py +366 -0
- punchlist-0.6.0/tests/test_ids.py +103 -0
- punchlist-0.6.0/tests/test_importer.py +311 -0
- punchlist-0.6.0/tests/test_issue.py +224 -0
- punchlist-0.6.0/tests/test_links.py +392 -0
- punchlist-0.6.0/tests/test_web.py +372 -0
punchlist-0.6.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steven Moret
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
punchlist-0.6.0/PKG-INFO
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: punchlist
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: A file-based issue tracker: one markdown file per issue, in the project's own git repo
|
|
5
|
+
Project-URL: Homepage, https://github.com/smoret/punchlist
|
|
6
|
+
Project-URL: Source, https://github.com/smoret/punchlist
|
|
7
|
+
Project-URL: Issues, https://github.com/smoret/punchlist/tree/main/issues
|
|
8
|
+
Author-email: Steven Moret <smoret@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,cli,git,issue tracker,markdown
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Bug Tracking
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pylint; extra == 'dev'
|
|
26
|
+
Requires-Dist: pyright; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# punchlist
|
|
31
|
+
|
|
32
|
+
A small, file-based issue tracker for projects worked on by one developer and a crew of agents. Each issue is a markdown file with a short header, stored in the project's own git repo, so closing an issue lands in the same commit as the work that closed it. The `punch` CLI creates, lists, filters, and checks them.
|
|
33
|
+
|
|
34
|
+
Status: early, and tracking its own work: planned work is in [issues/](https://github.com/smoret/punchlist/tree/main/issues) (`punch list`).
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Needs Python 3.11 or later. `punch history` and `punch changed` read the repo's log, so they also need `git` on the PATH.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pipx install punchlist # or: pip install punchlist
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Development
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python -m venv .venv
|
|
48
|
+
.venv/Scripts/activate # Windows (bash); .venv\Scripts\activate in cmd
|
|
49
|
+
pip install -e ".[dev]"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
punch --version # the installed version
|
|
56
|
+
punch init --prefix pl # write punchlist.json in the current directory
|
|
57
|
+
punch init --prefix pl --line-endings lf # write every file LF (default: the platform's)
|
|
58
|
+
punch new "Title" --type task --category tooling # prints the new id
|
|
59
|
+
punch new "Title" --type task --priority high --rank 2.5 # rank orders within a priority
|
|
60
|
+
punch new "Title" --type bug --body-file - # body from stdin
|
|
61
|
+
punch list # every status but closed: id status type priority category title
|
|
62
|
+
punch list --all --label cli --json
|
|
63
|
+
punch list --ready # open issues whose depends-on issues are all closed
|
|
64
|
+
punch set 7k3 depends-on+=2x9 # 7k3 also waits on 2x9; --blocked lists what's waiting
|
|
65
|
+
punch set 7k3 related+=9qp # add a non-blocking link, written into both issues
|
|
66
|
+
punch set 7k3 related-=9qp # remove one link, from both issues
|
|
67
|
+
punch search 'retry|backoff' # title or body, case-insensitive; takes list's filters
|
|
68
|
+
punch search retry --context 1 # plus each hit's matching lines (file line numbers), 1 line either side
|
|
69
|
+
punch show pl-7k3qx # or any unique start: pl-7k, 7k3
|
|
70
|
+
punch show 7k3 --json # header fields plus body
|
|
71
|
+
punch close pl-7k3qx
|
|
72
|
+
punch close 7k3 --comment "Fixed by the retry change." --author claude
|
|
73
|
+
punch comment 7k3 --author claude --body "Tried X; blocked on Y." # author: flag, $PUNCH_AUTHOR, git
|
|
74
|
+
punch show 7k3 --comments # the issue, then its comments oldest first
|
|
75
|
+
punch history 7k3 # from git: each commit that changed it or mentions it
|
|
76
|
+
punch history 7k3 --since 2026-03-01 --json # or --since <rev>: only what's newer
|
|
77
|
+
punch history 2x9 --tree --diff # an epic and everything under it; body changes as diffs
|
|
78
|
+
punch changed 2026-03-01 --type bug # created/closed/reopened/rescoped since; takes list's filters
|
|
79
|
+
punch set 7k3 priority=high labels=cli,docs # a list's = replaces the WHOLE list; field= removes
|
|
80
|
+
punch set 7k3 labels+=api rank=2 # += adds to a list, -= removes; one op per field per call
|
|
81
|
+
punch serve # kanban board at http://127.0.0.1:8314/, a lane per status
|
|
82
|
+
punch serve --author alex --port 0 --no-browser # any free port; comments signed alex
|
|
83
|
+
punch check # validate every issue file
|
|
84
|
+
punch check --references # also flag ids mentioned in bodies that don't exist
|
|
85
|
+
punch import-md ROADMAP.md --dry-run # what a markdown checklist would become
|
|
86
|
+
punch import-md ROADMAP.md --add-categories
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Issue format
|
|
90
|
+
|
|
91
|
+
```markdown
|
|
92
|
+
---
|
|
93
|
+
id: pl-7k3qx
|
|
94
|
+
title: An example issue
|
|
95
|
+
type: task
|
|
96
|
+
status: open
|
|
97
|
+
created: 2026-10-18
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
Markdown body.
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The header is `key: value` lines, values taken verbatim -- no quoting, no YAML. See [docs/design.md](https://github.com/smoret/punchlist/blob/main/docs/design.md) for the full rules.
|
|
104
|
+
|
|
105
|
+
## Documentation
|
|
106
|
+
|
|
107
|
+
Links are absolute so they also work on the PyPI page.
|
|
108
|
+
|
|
109
|
+
| File | Purpose |
|
|
110
|
+
|---|---|
|
|
111
|
+
| [CLAUDE.md](https://github.com/smoret/punchlist/blob/main/CLAUDE.md) | Conventions for agents working in this repo |
|
|
112
|
+
| [issues/](https://github.com/smoret/punchlist/tree/main/issues) | Planned work, as punchlist issues |
|
|
113
|
+
| [docs/design.md](https://github.com/smoret/punchlist/blob/main/docs/design.md) | What was decided and why |
|
|
114
|
+
| [ARCHITECTURE.md](https://github.com/smoret/punchlist/blob/main/ARCHITECTURE.md) | The module map |
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT; see [LICENSE](https://github.com/smoret/punchlist/blob/main/LICENSE).
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# punchlist
|
|
2
|
+
|
|
3
|
+
A small, file-based issue tracker for projects worked on by one developer and a crew of agents. Each issue is a markdown file with a short header, stored in the project's own git repo, so closing an issue lands in the same commit as the work that closed it. The `punch` CLI creates, lists, filters, and checks them.
|
|
4
|
+
|
|
5
|
+
Status: early, and tracking its own work: planned work is in [issues/](https://github.com/smoret/punchlist/tree/main/issues) (`punch list`).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Needs Python 3.11 or later. `punch history` and `punch changed` read the repo's log, so they also need `git` on the PATH.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pipx install punchlist # or: pip install punchlist
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Development
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python -m venv .venv
|
|
19
|
+
.venv/Scripts/activate # Windows (bash); .venv\Scripts\activate in cmd
|
|
20
|
+
pip install -e ".[dev]"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
punch --version # the installed version
|
|
27
|
+
punch init --prefix pl # write punchlist.json in the current directory
|
|
28
|
+
punch init --prefix pl --line-endings lf # write every file LF (default: the platform's)
|
|
29
|
+
punch new "Title" --type task --category tooling # prints the new id
|
|
30
|
+
punch new "Title" --type task --priority high --rank 2.5 # rank orders within a priority
|
|
31
|
+
punch new "Title" --type bug --body-file - # body from stdin
|
|
32
|
+
punch list # every status but closed: id status type priority category title
|
|
33
|
+
punch list --all --label cli --json
|
|
34
|
+
punch list --ready # open issues whose depends-on issues are all closed
|
|
35
|
+
punch set 7k3 depends-on+=2x9 # 7k3 also waits on 2x9; --blocked lists what's waiting
|
|
36
|
+
punch set 7k3 related+=9qp # add a non-blocking link, written into both issues
|
|
37
|
+
punch set 7k3 related-=9qp # remove one link, from both issues
|
|
38
|
+
punch search 'retry|backoff' # title or body, case-insensitive; takes list's filters
|
|
39
|
+
punch search retry --context 1 # plus each hit's matching lines (file line numbers), 1 line either side
|
|
40
|
+
punch show pl-7k3qx # or any unique start: pl-7k, 7k3
|
|
41
|
+
punch show 7k3 --json # header fields plus body
|
|
42
|
+
punch close pl-7k3qx
|
|
43
|
+
punch close 7k3 --comment "Fixed by the retry change." --author claude
|
|
44
|
+
punch comment 7k3 --author claude --body "Tried X; blocked on Y." # author: flag, $PUNCH_AUTHOR, git
|
|
45
|
+
punch show 7k3 --comments # the issue, then its comments oldest first
|
|
46
|
+
punch history 7k3 # from git: each commit that changed it or mentions it
|
|
47
|
+
punch history 7k3 --since 2026-03-01 --json # or --since <rev>: only what's newer
|
|
48
|
+
punch history 2x9 --tree --diff # an epic and everything under it; body changes as diffs
|
|
49
|
+
punch changed 2026-03-01 --type bug # created/closed/reopened/rescoped since; takes list's filters
|
|
50
|
+
punch set 7k3 priority=high labels=cli,docs # a list's = replaces the WHOLE list; field= removes
|
|
51
|
+
punch set 7k3 labels+=api rank=2 # += adds to a list, -= removes; one op per field per call
|
|
52
|
+
punch serve # kanban board at http://127.0.0.1:8314/, a lane per status
|
|
53
|
+
punch serve --author alex --port 0 --no-browser # any free port; comments signed alex
|
|
54
|
+
punch check # validate every issue file
|
|
55
|
+
punch check --references # also flag ids mentioned in bodies that don't exist
|
|
56
|
+
punch import-md ROADMAP.md --dry-run # what a markdown checklist would become
|
|
57
|
+
punch import-md ROADMAP.md --add-categories
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Issue format
|
|
61
|
+
|
|
62
|
+
```markdown
|
|
63
|
+
---
|
|
64
|
+
id: pl-7k3qx
|
|
65
|
+
title: An example issue
|
|
66
|
+
type: task
|
|
67
|
+
status: open
|
|
68
|
+
created: 2026-10-18
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
Markdown body.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The header is `key: value` lines, values taken verbatim -- no quoting, no YAML. See [docs/design.md](https://github.com/smoret/punchlist/blob/main/docs/design.md) for the full rules.
|
|
75
|
+
|
|
76
|
+
## Documentation
|
|
77
|
+
|
|
78
|
+
Links are absolute so they also work on the PyPI page.
|
|
79
|
+
|
|
80
|
+
| File | Purpose |
|
|
81
|
+
|---|---|
|
|
82
|
+
| [CLAUDE.md](https://github.com/smoret/punchlist/blob/main/CLAUDE.md) | Conventions for agents working in this repo |
|
|
83
|
+
| [issues/](https://github.com/smoret/punchlist/tree/main/issues) | Planned work, as punchlist issues |
|
|
84
|
+
| [docs/design.md](https://github.com/smoret/punchlist/blob/main/docs/design.md) | What was decided and why |
|
|
85
|
+
| [ARCHITECTURE.md](https://github.com/smoret/punchlist/blob/main/ARCHITECTURE.md) | The module map |
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT; see [LICENSE](https://github.com/smoret/punchlist/blob/main/LICENSE).
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
# 1.27 is the first hatchling to write the PEP 639 license fields below.
|
|
3
|
+
requires = ["hatchling>=1.27"]
|
|
4
|
+
build-backend = "hatchling.build"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "punchlist"
|
|
8
|
+
version = "0.6.0"
|
|
9
|
+
description = "A file-based issue tracker: one markdown file per issue, in the project's own git repo"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Steven Moret", email = "smoret@gmail.com" },
|
|
12
|
+
]
|
|
13
|
+
license = "MIT"
|
|
14
|
+
license-files = ["LICENSE"]
|
|
15
|
+
readme = "README.md"
|
|
16
|
+
requires-python = ">=3.11"
|
|
17
|
+
dependencies = []
|
|
18
|
+
keywords = ["issue tracker", "git", "markdown", "cli", "agents"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Environment :: Console",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Programming Language :: Python :: 3.14",
|
|
30
|
+
"Topic :: Software Development :: Bug Tracking",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/smoret/punchlist"
|
|
35
|
+
Source = "https://github.com/smoret/punchlist"
|
|
36
|
+
Issues = "https://github.com/smoret/punchlist/tree/main/issues"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest",
|
|
41
|
+
"pylint",
|
|
42
|
+
"pyright",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
punch = "punchlist.cli:main"
|
|
47
|
+
punchlist = "punchlist.cli:main"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
# Everything under the package ships, including static/board.html for `punch serve`.
|
|
51
|
+
packages = ["src/punchlist"]
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.sdist]
|
|
54
|
+
# The sdist is for building and testing the package, not the project's issues or docs.
|
|
55
|
+
include = ["src", "tests", "README.md", "LICENSE"]
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests"]
|
|
59
|
+
# Tests import shared helpers as `from helpers import ...`.
|
|
60
|
+
pythonpath = ["tests"]
|
|
61
|
+
|
|
62
|
+
[tool.pyright]
|
|
63
|
+
extraPaths = ["tests"]
|
|
64
|
+
|
|
65
|
+
[tool.pylint.basic]
|
|
66
|
+
# http.server dispatches a request to the handler method named do_<METHOD>.
|
|
67
|
+
good-names-rgxs = ["^do_[A-Z]+$"]
|
|
68
|
+
|
|
69
|
+
[tool.pylint."messages control"]
|
|
70
|
+
# Complexity metrics are refactor-time signals, not defects.
|
|
71
|
+
disable = [
|
|
72
|
+
"too-many-arguments",
|
|
73
|
+
"too-many-branches",
|
|
74
|
+
"too-many-instance-attributes",
|
|
75
|
+
"too-many-locals",
|
|
76
|
+
"too-many-positional-arguments",
|
|
77
|
+
"too-many-return-statements",
|
|
78
|
+
"too-many-statements",
|
|
79
|
+
"too-few-public-methods",
|
|
80
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""punchlist: a file-based issue tracker, one markdown file per issue in the project's own repo."""
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""`punch check`: validate every issue file and report every problem, not just the first.
|
|
2
|
+
|
|
3
|
+
Per file it reports a file outside its bucket and the first parse error (the
|
|
4
|
+
parser stops there), then checks across files: each `id:` matches its
|
|
5
|
+
filename, which with correct buckets also makes ids unique; each `parent`
|
|
6
|
+
names an existing issue file; and no chain of parents loops back on itself.
|
|
7
|
+
With a `depends-on` field: each dependency names an existing issue file, and
|
|
8
|
+
no group of issues depends on each other in a cycle. With a `related` field:
|
|
9
|
+
each related id names another existing issue, which lists this one back.
|
|
10
|
+
Opt-in (config `check_references`, or `punch check --references`): each id
|
|
11
|
+
of this tracker's shape mentioned in a body names an existing issue file.
|
|
12
|
+
Comments: every folder beside an issue file belongs to an existing issue, and
|
|
13
|
+
every file in a `comments/` folder is a valid comment matching its name.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .comment import read_comment
|
|
22
|
+
from .config import DEPENDS_ON, RELATED, Config
|
|
23
|
+
from .errors import PunchError
|
|
24
|
+
from .ids import ALPHABET
|
|
25
|
+
from .issue import Issue, issue_files, placement_error, read_issue
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parent_loop(config: Config, issue_id: str, parent: str) -> list[str] | None:
|
|
29
|
+
"""The loop that giving `issue_id` this `parent` would close, as `[a, b, a]`; else None.
|
|
30
|
+
|
|
31
|
+
Walks up from `parent` through each ancestor's file. A dangling parent or
|
|
32
|
+
a loop elsewhere ends the walk; `punch check` reports those.
|
|
33
|
+
"""
|
|
34
|
+
chain: list[str] = [issue_id]
|
|
35
|
+
node: str | None = parent
|
|
36
|
+
while node is not None and node not in chain[1:]:
|
|
37
|
+
chain.append(node)
|
|
38
|
+
if node == issue_id:
|
|
39
|
+
return chain
|
|
40
|
+
path: Path = config.issue_path(node)
|
|
41
|
+
if not path.is_file():
|
|
42
|
+
return None
|
|
43
|
+
node = read_issue(path, config).text("parent")
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def dependency_loop(config: Config, issue_id: str,
|
|
48
|
+
dependencies: list[str]) -> list[str] | None:
|
|
49
|
+
"""The loop giving `issue_id` these dependencies would close, as `[a, b, a]`; else None.
|
|
50
|
+
|
|
51
|
+
Searches breadth-first from each dependency through the `depends-on` of
|
|
52
|
+
each issue file it reaches, so the loop reported is a shortest one. A
|
|
53
|
+
missing file ends that branch; `punch check` reports it.
|
|
54
|
+
"""
|
|
55
|
+
previous: dict[str, str] = {}
|
|
56
|
+
queue: list[str] = []
|
|
57
|
+
for dependency in dependencies:
|
|
58
|
+
if dependency == issue_id:
|
|
59
|
+
return [issue_id, issue_id]
|
|
60
|
+
if dependency not in previous:
|
|
61
|
+
previous[dependency] = issue_id
|
|
62
|
+
queue.append(dependency)
|
|
63
|
+
while queue:
|
|
64
|
+
node: str = queue.pop(0)
|
|
65
|
+
path: Path = config.issue_path(node)
|
|
66
|
+
if not path.is_file():
|
|
67
|
+
continue
|
|
68
|
+
for successor in read_issue(path, config).items(DEPENDS_ON):
|
|
69
|
+
if successor == issue_id:
|
|
70
|
+
return _loop_path(previous, issue_id, node)
|
|
71
|
+
if successor not in previous:
|
|
72
|
+
previous[successor] = node
|
|
73
|
+
queue.append(successor)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _loop_path(previous: dict[str, str], start: str, last: str) -> list[str]:
|
|
78
|
+
"""`[start, ..., last, start]` from the breadth-first `previous` links."""
|
|
79
|
+
path: list[str] = [last]
|
|
80
|
+
while path[0] != start:
|
|
81
|
+
path.insert(0, previous[path[0]])
|
|
82
|
+
return [*path, start]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_issues(config: Config,
|
|
86
|
+
references: bool | None = None) -> tuple[int, list[PunchError]]:
|
|
87
|
+
"""The number of issue files, and every problem found in them, in file and line order.
|
|
88
|
+
|
|
89
|
+
`references` turns the body reference check on or off; None follows the
|
|
90
|
+
config's `check_references`.
|
|
91
|
+
"""
|
|
92
|
+
if references is None:
|
|
93
|
+
references = config.check_references
|
|
94
|
+
paths: list[Path] = issue_files(config)
|
|
95
|
+
# Parents are checked against filenames, so a corrupt parent file is one
|
|
96
|
+
# problem rather than one per child.
|
|
97
|
+
stems: set[str] = {path.stem for path in paths}
|
|
98
|
+
problems: list[PunchError] = []
|
|
99
|
+
parsed: dict[str, Issue] = {}
|
|
100
|
+
for path in paths:
|
|
101
|
+
placement: PunchError | None = placement_error(path, config)
|
|
102
|
+
if placement is not None:
|
|
103
|
+
problems.append(placement)
|
|
104
|
+
try:
|
|
105
|
+
issue: Issue = read_issue(path, config)
|
|
106
|
+
except PunchError as exc:
|
|
107
|
+
problems.append(exc)
|
|
108
|
+
continue
|
|
109
|
+
parsed[path.stem] = issue
|
|
110
|
+
if issue.id != path.stem:
|
|
111
|
+
problems.append(PunchError(
|
|
112
|
+
f"id {issue.id!r} does not match the filename", path, issue.lines["id"]
|
|
113
|
+
))
|
|
114
|
+
parent: str | None = issue.text("parent")
|
|
115
|
+
if parent is not None and parent not in stems:
|
|
116
|
+
problems.append(PunchError(
|
|
117
|
+
f"parent {parent!r} does not exist", path, issue.lines["parent"]
|
|
118
|
+
))
|
|
119
|
+
if config.has_links:
|
|
120
|
+
for dependency in issue.items(DEPENDS_ON):
|
|
121
|
+
if dependency not in stems:
|
|
122
|
+
problems.append(PunchError(
|
|
123
|
+
f"{DEPENDS_ON} {dependency!r} does not exist", path,
|
|
124
|
+
issue.lines[DEPENDS_ON],
|
|
125
|
+
))
|
|
126
|
+
if config.has_related:
|
|
127
|
+
for other in issue.items(RELATED):
|
|
128
|
+
if other == issue.id:
|
|
129
|
+
problems.append(PunchError(
|
|
130
|
+
f"{RELATED} lists the issue itself", path, issue.lines[RELATED]
|
|
131
|
+
))
|
|
132
|
+
elif other not in stems:
|
|
133
|
+
problems.append(PunchError(
|
|
134
|
+
f"{RELATED} {other!r} does not exist", path, issue.lines[RELATED]
|
|
135
|
+
))
|
|
136
|
+
if references:
|
|
137
|
+
problems.extend(_dangling_references(config, issue, stems))
|
|
138
|
+
problems.extend(_parent_loops(parsed))
|
|
139
|
+
if config.has_links:
|
|
140
|
+
problems.extend(_dependency_loops(parsed))
|
|
141
|
+
if config.has_related:
|
|
142
|
+
problems.extend(_one_sided_related(parsed))
|
|
143
|
+
problems.extend(_comment_problems(config))
|
|
144
|
+
problems.sort(key=lambda p: (p.path or Path(), p.line or 0))
|
|
145
|
+
return len(paths), problems
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _comment_problems(config: Config) -> list[PunchError]:
|
|
149
|
+
"""Problems with comment folders and files.
|
|
150
|
+
|
|
151
|
+
A folder beside an issue file (`issues/<bucket>/<id>/`) must belong to an
|
|
152
|
+
existing, correctly placed issue; every entry of its `comments/` folder
|
|
153
|
+
must be a comment file whose header parses and matches its name.
|
|
154
|
+
"""
|
|
155
|
+
problems: list[PunchError] = []
|
|
156
|
+
if not config.issues_path.is_dir():
|
|
157
|
+
return problems
|
|
158
|
+
for folder in sorted(config.issues_path.glob("*/*/")):
|
|
159
|
+
try:
|
|
160
|
+
expected: Path = config.issue_path(folder.name).with_suffix("")
|
|
161
|
+
except PunchError:
|
|
162
|
+
expected = Path()
|
|
163
|
+
if folder != expected or not (folder.parent / f"{folder.name}.md").is_file():
|
|
164
|
+
problems.append(PunchError(
|
|
165
|
+
f"folder for issue {folder.name!r}, which does not exist here", folder
|
|
166
|
+
))
|
|
167
|
+
continue
|
|
168
|
+
comments: Path = folder / "comments"
|
|
169
|
+
if not comments.is_dir():
|
|
170
|
+
continue
|
|
171
|
+
for path in sorted(comments.iterdir()):
|
|
172
|
+
if not path.is_file() or path.suffix != ".md":
|
|
173
|
+
problems.append(PunchError(
|
|
174
|
+
"not a comment file (expected <time>-<author>.md)", path
|
|
175
|
+
))
|
|
176
|
+
continue
|
|
177
|
+
try:
|
|
178
|
+
read_comment(path)
|
|
179
|
+
except PunchError as exc:
|
|
180
|
+
problems.append(exc)
|
|
181
|
+
return problems
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _dangling_references(config: Config, issue: Issue, stems: set[str]) -> list[PunchError]:
|
|
185
|
+
"""Each body mention of this tracker's id shape that names no issue file, once per line.
|
|
186
|
+
|
|
187
|
+
Only `<prefix>-` plus `id_length` id-alphabet characters, on word
|
|
188
|
+
boundaries, counts, so another tracker's ids (another prefix) never match.
|
|
189
|
+
"""
|
|
190
|
+
pattern: re.Pattern[str] = re.compile(
|
|
191
|
+
rf"\b{re.escape(config.prefix)}-[{ALPHABET}]{{{config.id_length}}}\b"
|
|
192
|
+
)
|
|
193
|
+
problems: list[PunchError] = []
|
|
194
|
+
for offset, line in enumerate(issue.body.split("\n")):
|
|
195
|
+
for mention in dict.fromkeys(pattern.findall(line)):
|
|
196
|
+
if mention not in stems:
|
|
197
|
+
problems.append(PunchError(
|
|
198
|
+
f"reference to {mention}, which does not exist",
|
|
199
|
+
issue.path, issue.body_line + offset,
|
|
200
|
+
))
|
|
201
|
+
return problems
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parent_loops(issues: dict[str, Issue]) -> list[PunchError]:
|
|
205
|
+
"""One problem per parent loop, at the `parent:` line of the loop's lowest id."""
|
|
206
|
+
parents: dict[str, str] = {}
|
|
207
|
+
for stem, issue in issues.items():
|
|
208
|
+
parent: str | None = issue.text("parent")
|
|
209
|
+
if parent is not None:
|
|
210
|
+
parents[stem] = parent
|
|
211
|
+
problems: list[PunchError] = []
|
|
212
|
+
done: set[str] = set()
|
|
213
|
+
for start in sorted(parents):
|
|
214
|
+
chain: list[str] = []
|
|
215
|
+
node: str | None = start
|
|
216
|
+
while node is not None and node not in done and node not in chain:
|
|
217
|
+
chain.append(node)
|
|
218
|
+
node = parents.get(node)
|
|
219
|
+
if node is not None and node in chain:
|
|
220
|
+
loop: list[str] = chain[chain.index(node):]
|
|
221
|
+
first: int = loop.index(min(loop))
|
|
222
|
+
loop = loop[first:] + loop[:first]
|
|
223
|
+
head: Issue = issues[loop[0]]
|
|
224
|
+
problems.append(PunchError(
|
|
225
|
+
f"parent loop: {' -> '.join([*loop, loop[0]])}", head.path, head.lines["parent"]
|
|
226
|
+
))
|
|
227
|
+
done.update(chain)
|
|
228
|
+
return problems
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _one_sided_related(issues: dict[str, Issue]) -> list[PunchError]:
|
|
232
|
+
"""A related link the other issue doesn't list back, on the side that has it.
|
|
233
|
+
|
|
234
|
+
`set` and `new` write both sides, so this comes from a hand edit or a merge
|
|
235
|
+
that kept one file's side. A partner that didn't parse is skipped; its own
|
|
236
|
+
problem is already reported.
|
|
237
|
+
"""
|
|
238
|
+
problems: list[PunchError] = []
|
|
239
|
+
for stem, issue in issues.items():
|
|
240
|
+
for other in issue.items(RELATED):
|
|
241
|
+
if other != stem and other in issues and stem not in issues[other].items(RELATED):
|
|
242
|
+
problems.append(PunchError(
|
|
243
|
+
f"{RELATED} {other!r} does not list {stem!r} back", issue.path,
|
|
244
|
+
issue.lines[RELATED],
|
|
245
|
+
))
|
|
246
|
+
return problems
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _dependency_loops(issues: dict[str, Issue]) -> list[PunchError]:
|
|
250
|
+
"""One problem per group of issues that depend on each other in a cycle.
|
|
251
|
+
|
|
252
|
+
Each strongly connected component with a cycle is reported once, at the
|
|
253
|
+
`depends-on` line of its lowest id, naming a shortest loop through it.
|
|
254
|
+
"""
|
|
255
|
+
graph: dict[str, list[str]] = {
|
|
256
|
+
stem: [d for d in issue.items(DEPENDS_ON) if d in issues]
|
|
257
|
+
for stem, issue in issues.items()
|
|
258
|
+
}
|
|
259
|
+
problems: list[PunchError] = []
|
|
260
|
+
for component in _components(graph):
|
|
261
|
+
start: str = min(component)
|
|
262
|
+
if len(component) == 1 and start not in graph[start]:
|
|
263
|
+
continue
|
|
264
|
+
members: set[str] = set(component)
|
|
265
|
+
previous: dict[str, str] = {}
|
|
266
|
+
queue: list[str] = [start]
|
|
267
|
+
last: str | None = None
|
|
268
|
+
while queue and last is None:
|
|
269
|
+
node: str = queue.pop(0)
|
|
270
|
+
for successor in sorted(graph[node]):
|
|
271
|
+
if successor == start:
|
|
272
|
+
last = node
|
|
273
|
+
break
|
|
274
|
+
if successor in members and successor not in previous:
|
|
275
|
+
previous[successor] = node
|
|
276
|
+
queue.append(successor)
|
|
277
|
+
assert last is not None # a component with a cycle has a loop through any member
|
|
278
|
+
head: Issue = issues[start]
|
|
279
|
+
loop: list[str] = _loop_path(previous, start, last)
|
|
280
|
+
problems.append(PunchError(
|
|
281
|
+
f"dependency loop: {' -> '.join(loop)}", head.path, head.lines[DEPENDS_ON]
|
|
282
|
+
))
|
|
283
|
+
return problems
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _components(graph: dict[str, list[str]]) -> list[list[str]]:
|
|
287
|
+
"""Strongly connected components of `graph` (Tarjan's algorithm, iterative)."""
|
|
288
|
+
index: dict[str, int] = {}
|
|
289
|
+
low: dict[str, int] = {}
|
|
290
|
+
stack: list[str] = []
|
|
291
|
+
on_stack: set[str] = set()
|
|
292
|
+
components: list[list[str]] = []
|
|
293
|
+
for root in sorted(graph):
|
|
294
|
+
if root in index:
|
|
295
|
+
continue
|
|
296
|
+
work: list[tuple[str, int]] = [(root, 0)]
|
|
297
|
+
while work:
|
|
298
|
+
node, position = work[-1]
|
|
299
|
+
if position == 0:
|
|
300
|
+
index[node] = low[node] = len(index)
|
|
301
|
+
stack.append(node)
|
|
302
|
+
on_stack.add(node)
|
|
303
|
+
successors: list[str] = graph[node]
|
|
304
|
+
if position < len(successors):
|
|
305
|
+
work[-1] = (node, position + 1)
|
|
306
|
+
successor: str = successors[position]
|
|
307
|
+
if successor not in index:
|
|
308
|
+
work.append((successor, 0))
|
|
309
|
+
elif successor in on_stack:
|
|
310
|
+
low[node] = min(low[node], index[successor])
|
|
311
|
+
continue
|
|
312
|
+
work.pop()
|
|
313
|
+
if work:
|
|
314
|
+
low[work[-1][0]] = min(low[work[-1][0]], low[node])
|
|
315
|
+
if low[node] == index[node]:
|
|
316
|
+
component: list[str] = []
|
|
317
|
+
while True:
|
|
318
|
+
member: str = stack.pop()
|
|
319
|
+
on_stack.discard(member)
|
|
320
|
+
component.append(member)
|
|
321
|
+
if member == node:
|
|
322
|
+
break
|
|
323
|
+
components.append(component)
|
|
324
|
+
return components
|