codetrail-cli 1.4.1__py3-none-any.whl
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.
- codetrail/__init__.py +3 -0
- codetrail/__main__.py +5 -0
- codetrail/agents/__init__.py +4 -0
- codetrail/agents/convert.py +92 -0
- codetrail/agents/export.py +284 -0
- codetrail/agents/instructions.py +42 -0
- codetrail/agents/protocol.py +111 -0
- codetrail/agents/tools.py +185 -0
- codetrail/assistant/__init__.py +161 -0
- codetrail/assistant/claude_code.py +330 -0
- codetrail/assistant/codex.py +260 -0
- codetrail/assistant/environment.py +66 -0
- codetrail/assistant/estimate.py +225 -0
- codetrail/assistant/fake.py +74 -0
- codetrail/assistant/guard.py +80 -0
- codetrail/assistant/guard_hook.py +58 -0
- codetrail/assistant/local.py +245 -0
- codetrail/assistant/local_tools.py +177 -0
- codetrail/assistant/prompts.py +404 -0
- codetrail/assistant/routing.py +86 -0
- codetrail/assistant/runner.py +120 -0
- codetrail/assistant/status.py +186 -0
- codetrail/assistant/usage.py +93 -0
- codetrail/bridge.py +292 -0
- codetrail/cli.py +407 -0
- codetrail/config.py +485 -0
- codetrail/database/__init__.py +60 -0
- codetrail/database/migrations/0001_facts.sql +34 -0
- codetrail/database/migrations/0002_settings.sql +5 -0
- codetrail/database/migrations/0003_learning.sql +34 -0
- codetrail/database/migrations/0004_assistant_usage.sql +24 -0
- codetrail/database/migrations/0005_page_failures.sql +7 -0
- codetrail/database/migrations/0006_metric_values.sql +9 -0
- codetrail/errors.py +5 -0
- codetrail/extract/__init__.py +249 -0
- codetrail/extract/adr.py +98 -0
- codetrail/extract/github_actions.py +140 -0
- codetrail/extract/openapi.py +101 -0
- codetrail/extract/python.py +221 -0
- codetrail/extract/swift.py +204 -0
- codetrail/extract/terraform.py +166 -0
- codetrail/extract/typescript.py +464 -0
- codetrail/facts/__init__.py +112 -0
- codetrail/facts/store.py +209 -0
- codetrail/generate/__init__.py +1 -0
- codetrail/generate/failures.py +31 -0
- codetrail/generate/outline.py +159 -0
- codetrail/generate/revise.py +72 -0
- codetrail/generate/run.py +530 -0
- codetrail/generate/scope.py +135 -0
- codetrail/generate/validate.py +218 -0
- codetrail/guide/__init__.py +147 -0
- codetrail/learn/__init__.py +143 -0
- codetrail/learn/routes.py +139 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
- codetrail/locales/codetrail.pot +1942 -0
- codetrail/lock.py +114 -0
- codetrail/metrics/__init__.py +6 -0
- codetrail/metrics/activity.py +96 -0
- codetrail/metrics/churn.py +156 -0
- codetrail/metrics/commits.py +45 -0
- codetrail/metrics/coverage.py +114 -0
- codetrail/metrics/decisions.py +77 -0
- codetrail/metrics/history.py +47 -0
- codetrail/metrics/inventory.py +63 -0
- codetrail/metrics/rationale.py +74 -0
- codetrail/metrics/releases.py +26 -0
- codetrail/metrics/report.py +111 -0
- codetrail/metrics/repository.py +128 -0
- codetrail/metrics/size.py +95 -0
- codetrail/py.typed +0 -0
- codetrail/remove.py +50 -0
- codetrail/repo/__init__.py +4 -0
- codetrail/repo/git.py +56 -0
- codetrail/repo/gitleaks.toml +6 -0
- codetrail/repo/history.py +262 -0
- codetrail/repo/mirror.py +137 -0
- codetrail/repo/refresh.py +41 -0
- codetrail/repo/rules.py +83 -0
- codetrail/repo/secrets.py +145 -0
- codetrail/repo/signal.py +65 -0
- codetrail/repo/source.py +166 -0
- codetrail/search.py +178 -0
- codetrail/server.py +45 -0
- codetrail/system.py +484 -0
- codetrail/update.py +276 -0
- codetrail/web/__init__.py +1 -0
- codetrail/web/app.py +897 -0
- codetrail/web/diagrams.py +414 -0
- codetrail/web/documentation.py +146 -0
- codetrail/web/i18n.py +44 -0
- codetrail/web/navigation.py +127 -0
- codetrail/web/render.py +126 -0
- codetrail/web/repository_view.py +230 -0
- codetrail/web/security.py +171 -0
- codetrail/web/static/brand/favicon.svg +1 -0
- codetrail/web/static/brand/mark.svg +1 -0
- codetrail/web/static/css/base.css +192 -0
- codetrail/web/static/css/components.css +2254 -0
- codetrail/web/static/css/content.css +310 -0
- codetrail/web/static/css/layout.css +410 -0
- codetrail/web/static/css/print.css +42 -0
- codetrail/web/static/css/tokens.css +228 -0
- codetrail/web/static/js/api.js +49 -0
- codetrail/web/static/js/ask.js +182 -0
- codetrail/web/static/js/d2-worker.js +34 -0
- codetrail/web/static/js/d2.js +28 -0
- codetrail/web/static/js/diagram.js +52 -0
- codetrail/web/static/js/documentation.js +24 -0
- codetrail/web/static/js/learning.js +47 -0
- codetrail/web/static/js/main.js +38 -0
- codetrail/web/static/js/outline.js +44 -0
- codetrail/web/static/js/palette.js +211 -0
- codetrail/web/static/js/repository.js +528 -0
- codetrail/web/static/js/resize.js +126 -0
- codetrail/web/static/js/shortcuts.js +87 -0
- codetrail/web/static/js/sidebar.js +194 -0
- codetrail/web/static/js/theme-init.js +27 -0
- codetrail/web/static/js/theme.js +42 -0
- codetrail/web/static/js/update.js +107 -0
- codetrail/web/static/vendor/d2/LICENSE +375 -0
- codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
- codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
- codetrail/web/static/vendor/d2/VERSION +12 -0
- codetrail/web/static/vendor/d2/d2.wasm +0 -0
- codetrail/web/static/vendor/d2/elk.js +105914 -0
- codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
- codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
- codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
- codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
- codetrail/web/static/vendor/inter/VERSION +6 -0
- codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
- codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
- codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
- codetrail/web/target_view.py +63 -0
- codetrail/web/templates/answers.html +25 -0
- codetrail/web/templates/area.html +46 -0
- codetrail/web/templates/base.html +37 -0
- codetrail/web/templates/decisions.html +30 -0
- codetrail/web/templates/diagram.html +10 -0
- codetrail/web/templates/digests.html +24 -0
- codetrail/web/templates/documentation.html +247 -0
- codetrail/web/templates/error.html +9 -0
- codetrail/web/templates/estimate.html +48 -0
- codetrail/web/templates/fact.html +29 -0
- codetrail/web/templates/home.html +196 -0
- codetrail/web/templates/layout/ask_panel.html +27 -0
- codetrail/web/templates/layout/header.html +32 -0
- codetrail/web/templates/layout/icons.html +27 -0
- codetrail/web/templates/layout/outline.html +4 -0
- codetrail/web/templates/layout/palette.html +27 -0
- codetrail/web/templates/layout/shortcuts.html +27 -0
- codetrail/web/templates/layout/sidebar.html +79 -0
- codetrail/web/templates/layout/update_dialog.html +18 -0
- codetrail/web/templates/macros.html +13 -0
- codetrail/web/templates/page.html +93 -0
- codetrail/web/templates/progress.html +46 -0
- codetrail/web/templates/repository.html +369 -0
- codetrail/web/templates/search.html +31 -0
- codetrail/web/templates/source.html +15 -0
- codetrail/web/templates/system.html +43 -0
- codetrail/web/templates/system_arrows.html +17 -0
- codetrail/web/templates/system_legend.html +9 -0
- codetrail/web/templates/update_log.html +30 -0
- codetrail/web/trend.py +22 -0
- codetrail_cli-1.4.1.dist-info/METADATA +372 -0
- codetrail_cli-1.4.1.dist-info/RECORD +171 -0
- codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
- codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
- codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
codetrail/cli.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Codetrail's command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
import unicodedata
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from datetime import UTC, date, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import FrameType
|
|
13
|
+
|
|
14
|
+
from codetrail import __version__
|
|
15
|
+
from codetrail.agents.export import refresh_export
|
|
16
|
+
from codetrail.agents.instructions import server_instructions, setup_text
|
|
17
|
+
from codetrail.agents.protocol import serve_stdio
|
|
18
|
+
from codetrail.agents.tools import AgentTools
|
|
19
|
+
from codetrail.assistant.estimate import UpdateEstimate, describe, tokens_text
|
|
20
|
+
from codetrail.assistant.status import provider_status
|
|
21
|
+
from codetrail.config import PROVIDERS, Paths, load_global, load_target, validate_target_name, write_target
|
|
22
|
+
from codetrail.database import connect
|
|
23
|
+
from codetrail.errors import CodetrailError
|
|
24
|
+
from codetrail.facts import FactDiff
|
|
25
|
+
from codetrail.facts.store import FactStore
|
|
26
|
+
from codetrail.lock import target_in_use
|
|
27
|
+
from codetrail.metrics.history import values_before
|
|
28
|
+
from codetrail.metrics.report import Metric, build_report
|
|
29
|
+
from codetrail.metrics.repository import RepositoryReport, build_repository_report
|
|
30
|
+
from codetrail.remove import remove_target
|
|
31
|
+
from codetrail.repo.mirror import check_branch
|
|
32
|
+
from codetrail.repo.refresh import refresh_source
|
|
33
|
+
from codetrail.repo.rules import Reason
|
|
34
|
+
from codetrail.server import serve
|
|
35
|
+
from codetrail.update import run_update
|
|
36
|
+
from codetrail.web.documentation import COUNTS, age, build_tiles, percent_of
|
|
37
|
+
|
|
38
|
+
stopping = False # set once a hangup or terminate signal has started Codetrail's exit
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
42
|
+
parser = argparse.ArgumentParser(prog="codetrail", description="Turn a git repository into a local learning guide.")
|
|
43
|
+
parser.add_argument("--version", action="version", version=f"codetrail {__version__}")
|
|
44
|
+
commands = parser.add_subparsers(dest="command")
|
|
45
|
+
|
|
46
|
+
target = commands.add_parser("target", help="manage the repositories Codetrail teaches")
|
|
47
|
+
target_commands = target.add_subparsers(dest="target_command", required=True)
|
|
48
|
+
add = target_commands.add_parser("add", help="register a repository; nothing is written into it")
|
|
49
|
+
add.add_argument("name", help="a short name: lower-case letters, digits and hyphens")
|
|
50
|
+
add.add_argument("path", type=Path, help="the repository's local checkout")
|
|
51
|
+
add.add_argument("--branch", default="develop", help="the branch to teach (default: develop)")
|
|
52
|
+
remove = target_commands.add_parser(
|
|
53
|
+
"remove", help="delete everything Codetrail keeps for a target; the repository isn't touched"
|
|
54
|
+
)
|
|
55
|
+
remove.add_argument("name")
|
|
56
|
+
remove.add_argument("--yes", action="store_true", help="delete without asking")
|
|
57
|
+
|
|
58
|
+
files = commands.add_parser("files", help="refresh a target's sources and list exactly what Codetrail can see")
|
|
59
|
+
files.add_argument("name")
|
|
60
|
+
|
|
61
|
+
update = commands.add_parser("update", help="refresh a target's sources and facts, and write the guide")
|
|
62
|
+
update.add_argument("name")
|
|
63
|
+
update.add_argument("--facts-only", action="store_true", help="refresh the facts without calling an assistant")
|
|
64
|
+
update.add_argument("--yes", action="store_true", help="go ahead after showing the estimate, without asking")
|
|
65
|
+
update.add_argument(
|
|
66
|
+
"--retry-failed", action="store_true", help="write pages that failed last time, even if nothing in them changed"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
metrics = commands.add_parser("metrics", help="show how well a target's repository explains itself")
|
|
70
|
+
metrics.add_argument("name")
|
|
71
|
+
|
|
72
|
+
stats = commands.add_parser("stats", help="show a target repository's history, releases, code size and facts")
|
|
73
|
+
stats.add_argument("name")
|
|
74
|
+
|
|
75
|
+
commands.add_parser("providers", help="show each assistant provider: installed, signed in, and how")
|
|
76
|
+
|
|
77
|
+
agents = commands.add_parser("agents", help="rebuild the context for coding agents and print how to connect them")
|
|
78
|
+
agents.add_argument("name")
|
|
79
|
+
mcp = commands.add_parser("mcp", help="the tool server coding agents start over standard input and output")
|
|
80
|
+
mcp.add_argument("name")
|
|
81
|
+
serve_command = commands.add_parser("serve", help="serve the guide's page on 127.0.0.1")
|
|
82
|
+
serve_command.add_argument("name")
|
|
83
|
+
serve_command.add_argument("--no-browser", action="store_true", help="print the sign-in link without opening it")
|
|
84
|
+
return parser
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: list[str] | None = None) -> int:
|
|
88
|
+
for signal_number in (signal.SIGHUP, signal.SIGTERM):
|
|
89
|
+
if signal.getsignal(signal_number) == signal.SIG_DFL: # one ignored at start (nohup) stays ignored
|
|
90
|
+
signal.signal(signal_number, stop_on_signal)
|
|
91
|
+
parser = build_parser()
|
|
92
|
+
arguments = parser.parse_args(argv)
|
|
93
|
+
if arguments.command is None:
|
|
94
|
+
parser.print_help(file=sys.stderr)
|
|
95
|
+
return 2
|
|
96
|
+
paths = Paths.from_environment()
|
|
97
|
+
try:
|
|
98
|
+
if arguments.command == "target" and arguments.target_command == "remove":
|
|
99
|
+
return remove_target_command(paths, arguments.name, yes=arguments.yes)
|
|
100
|
+
if arguments.command == "target":
|
|
101
|
+
return add_target(paths, arguments.name, arguments.path, arguments.branch)
|
|
102
|
+
if arguments.command == "providers":
|
|
103
|
+
return show_providers(paths)
|
|
104
|
+
if arguments.command == "metrics":
|
|
105
|
+
return show_metrics(paths, arguments.name)
|
|
106
|
+
if arguments.command == "stats":
|
|
107
|
+
return show_stats(paths, arguments.name)
|
|
108
|
+
if arguments.command == "update":
|
|
109
|
+
return update_target(paths, arguments.name, facts_only=arguments.facts_only, yes=arguments.yes,
|
|
110
|
+
retry_failed=arguments.retry_failed) # fmt: skip
|
|
111
|
+
if arguments.command == "mcp":
|
|
112
|
+
return serve_agent_tools(paths, arguments.name)
|
|
113
|
+
if arguments.command == "agents":
|
|
114
|
+
return connect_agents(paths, arguments.name)
|
|
115
|
+
if arguments.command == "serve":
|
|
116
|
+
serve(paths, arguments.name, open_browser=not arguments.no_browser)
|
|
117
|
+
return 0
|
|
118
|
+
return list_files(paths, arguments.name)
|
|
119
|
+
except CodetrailError as error:
|
|
120
|
+
print(f"codetrail: {error}", file=sys.stderr)
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def connect_agents(paths: Paths, name: str) -> int:
|
|
125
|
+
folder = refresh_export(paths, name)
|
|
126
|
+
print(setup_text(name, sys.executable, folder), end="")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def serve_agent_tools(paths: Paths, name: str) -> int:
|
|
131
|
+
"""Runs the tool server until the agent closes standard input (design section 23.5)."""
|
|
132
|
+
target = load_target(paths, name)
|
|
133
|
+
tools = AgentTools(paths, name, load_global(paths).agents, str(target.repository))
|
|
134
|
+
serve_stdio(tools, server_instructions(name, str(target.repository)), sys.stdin.buffer, sys.stdout.buffer)
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def stop_on_signal(signal_number: int, _frame: FrameType | None) -> None:
|
|
139
|
+
"""Turns a hangup or terminate signal into an exception, so the cleanup that stops child programs runs.
|
|
140
|
+
|
|
141
|
+
Child programs run in their own session (design section 15.5), so these signals don't reach them; by default
|
|
142
|
+
Python would die without unwinding and leave them running. It fires once: a second signal (closing a terminal can
|
|
143
|
+
send two) mustn't interrupt the cleanup. The signals stay caught rather than ignored, since an ignored signal stays
|
|
144
|
+
ignored in any program the cleanup starts.
|
|
145
|
+
"""
|
|
146
|
+
global stopping
|
|
147
|
+
if stopping:
|
|
148
|
+
return
|
|
149
|
+
stopping = True
|
|
150
|
+
raise SystemExit(128 + signal_number)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def add_target(paths: Paths, name: str, path: Path, branch: str) -> int:
|
|
154
|
+
validate_target_name(name)
|
|
155
|
+
path = path.expanduser().resolve()
|
|
156
|
+
if not (path / ".git").exists():
|
|
157
|
+
raise CodetrailError(f"{path} isn't a git repository (it has no .git).")
|
|
158
|
+
check_branch(path, branch)
|
|
159
|
+
file = write_target(paths, name, path, branch)
|
|
160
|
+
print(f"Added target {name!r}: {path} ({branch}). Settings: {file}")
|
|
161
|
+
print(f"Exclusions you add go in {paths.ignore_file(name)} (gitignore syntax).")
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def remove_target_command(paths: Paths, name: str, yes: bool = False) -> int:
|
|
166
|
+
no_terminal = False
|
|
167
|
+
|
|
168
|
+
def confirm(locations: list[Path]) -> bool:
|
|
169
|
+
nonlocal no_terminal
|
|
170
|
+
print(f"Removing target {name!r} deletes:")
|
|
171
|
+
for location in locations:
|
|
172
|
+
print(f" {printable(str(location))}")
|
|
173
|
+
print("The repository itself isn't touched.")
|
|
174
|
+
if yes:
|
|
175
|
+
return True
|
|
176
|
+
if not sys.stdin.isatty():
|
|
177
|
+
no_terminal = True
|
|
178
|
+
print("There's no terminal to ask in, so nothing was removed. Run again with --yes to go ahead.")
|
|
179
|
+
return False
|
|
180
|
+
return input(f"Remove target {name!r} and delete these? [y/N] ").strip().lower() in ("y", "yes")
|
|
181
|
+
|
|
182
|
+
if not remove_target(paths, name, confirm):
|
|
183
|
+
if not no_terminal:
|
|
184
|
+
print("Nothing was removed.")
|
|
185
|
+
return 2 if no_terminal else 0
|
|
186
|
+
print(f"Removed target {name!r}.")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def printable(path: str) -> str:
|
|
191
|
+
"""A path from a target, quoted with escapes if it holds characters a terminal would act on."""
|
|
192
|
+
if any(unicodedata.category(character) in ("Cc", "Cf", "Cs", "Zl", "Zp") for character in path):
|
|
193
|
+
return repr(path)
|
|
194
|
+
return path
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def list_files(paths: Paths, name: str) -> int:
|
|
198
|
+
manifest = refresh_source(paths, name)
|
|
199
|
+
for path in manifest.files:
|
|
200
|
+
print(printable(path))
|
|
201
|
+
print("Excluded:")
|
|
202
|
+
for item in manifest.excluded:
|
|
203
|
+
reason = f"{item.reason} ({item.rule})" if item.rule else str(item.reason)
|
|
204
|
+
print(f"{reason}\t{printable(item.path)}")
|
|
205
|
+
counts = Counter(item.reason for item in manifest.excluded)
|
|
206
|
+
breakdown = ", ".join(f"{counts[reason]} {reason}" for reason in Reason if counts[reason])
|
|
207
|
+
print(
|
|
208
|
+
f"{len(manifest.files)} visible, {len(manifest.excluded)} excluded ({breakdown or 'none'}), "
|
|
209
|
+
f"commit {manifest.commit[:12]}"
|
|
210
|
+
)
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def show_providers(paths: Paths) -> int:
|
|
215
|
+
settings = load_global(paths)
|
|
216
|
+
for provider in PROVIDERS:
|
|
217
|
+
status = provider_status(provider, settings)
|
|
218
|
+
state = "ready" if status.ready else "not ready"
|
|
219
|
+
how = " · ".join(part for part in (status.method, status.program) if part)
|
|
220
|
+
print(f"{provider:<12} {state:<10} {how}")
|
|
221
|
+
if status.models:
|
|
222
|
+
print(f"{'':<12} models: {', '.join(status.models)}")
|
|
223
|
+
if status.fix:
|
|
224
|
+
print(f"{'':<12} {status.fix}")
|
|
225
|
+
if status.warning:
|
|
226
|
+
print(f"{'':<12} Note: {status.warning}")
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def update_target(
|
|
231
|
+
paths: Paths, name: str, facts_only: bool = False, yes: bool = False, retry_failed: bool = False
|
|
232
|
+
) -> int:
|
|
233
|
+
no_terminal = False
|
|
234
|
+
|
|
235
|
+
def confirm(estimate: UpdateEstimate) -> bool:
|
|
236
|
+
"""Shows the estimate and asks before any paid work (design section 15.4)."""
|
|
237
|
+
nonlocal no_terminal
|
|
238
|
+
if not estimate.lines:
|
|
239
|
+
print("Nothing for the assistant to do in this update.")
|
|
240
|
+
return True
|
|
241
|
+
for line in describe(estimate):
|
|
242
|
+
print(printable(line) if line.startswith(" ") or line.startswith("Skipped") else line)
|
|
243
|
+
if yes:
|
|
244
|
+
return True
|
|
245
|
+
if not sys.stdin.isatty():
|
|
246
|
+
no_terminal = True
|
|
247
|
+
print("There's no terminal to ask in, so nothing was spent. Run again with --yes to go ahead.")
|
|
248
|
+
return False
|
|
249
|
+
return input("Continue? [y/N] ").strip().lower() in ("y", "yes")
|
|
250
|
+
|
|
251
|
+
result = run_update(paths, name, facts_only=facts_only, confirm=None if facts_only else confirm,
|
|
252
|
+
retry_failed=retry_failed) # fmt: skip
|
|
253
|
+
extraction, diff = result.extraction, result.diff
|
|
254
|
+
print(f"Updated {name} at commit {result.manifest.commit[:12]} (snapshot {result.snapshot.id}).")
|
|
255
|
+
print(f"Facts: {len(extraction.entities)} entities, {len(extraction.relations)} relations.")
|
|
256
|
+
if extraction.system:
|
|
257
|
+
print(
|
|
258
|
+
"System connections: " + ", ".join(f"{rule} {count}" for rule, count in sorted(extraction.system.items()))
|
|
259
|
+
)
|
|
260
|
+
if diff.is_empty:
|
|
261
|
+
print("No changes since the last update.")
|
|
262
|
+
else:
|
|
263
|
+
print("Changes (+ added, ~ changed, - removed):")
|
|
264
|
+
for kind, counts in sorted(_change_counts(diff).items()):
|
|
265
|
+
print(f" {kind}: +{counts[0]} ~{counts[1]} -{counts[2]}")
|
|
266
|
+
for extractor, count in sorted(extraction.unresolved.items()):
|
|
267
|
+
print(f"Unresolved references ({extractor}): {count}")
|
|
268
|
+
for warning in extraction.warnings:
|
|
269
|
+
print(f"Warning: {printable(warning)}")
|
|
270
|
+
if result.declined:
|
|
271
|
+
print("The facts were refreshed; the guide wasn't updated.")
|
|
272
|
+
return 2 if no_terminal else 0
|
|
273
|
+
generation = result.generation
|
|
274
|
+
if generation is not None:
|
|
275
|
+
print(
|
|
276
|
+
f"Guide: {len(generation.written)} pages written, {len(generation.failed)} failed, "
|
|
277
|
+
f"{len(generation.left_for_later)} left for the next update; "
|
|
278
|
+
f"used ~{tokens_text(generation.tokens)} tokens, ${generation.cost_usd:.2f} at API prices."
|
|
279
|
+
)
|
|
280
|
+
for page, reason in generation.failed:
|
|
281
|
+
print(f" Not rewritten: {page} ({printable(reason)})")
|
|
282
|
+
for page in generation.skipped:
|
|
283
|
+
print(f" Skipped: {page} (it failed last time, and nothing in it changed since)")
|
|
284
|
+
if generation.skipped:
|
|
285
|
+
print(f" To write them anyway: codetrail update {name} --retry-failed")
|
|
286
|
+
for problem in generation.outline_problems:
|
|
287
|
+
print(f" Outline: {printable(problem)}")
|
|
288
|
+
if generation.digest:
|
|
289
|
+
print(f" Digest: {generation.digest}")
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
METRIC_LABELS = {
|
|
294
|
+
Metric.DOCUMENTED_SHARE: "Documented rationale",
|
|
295
|
+
Metric.COMMIT_WHY_SHARE: "Commits that explain why",
|
|
296
|
+
Metric.ADR_ATTENTION: "ADRs needing attention",
|
|
297
|
+
Metric.MENTIONED_SHARE: "Mentioned in a document",
|
|
298
|
+
Metric.EXPLAINED_SHARE: "Explained by the guide",
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def show_metrics(paths: Paths, name: str) -> int:
|
|
303
|
+
"""Prints each documentation metric with its change since the last update (design 18.4); writes nothing."""
|
|
304
|
+
load_target(paths, name)
|
|
305
|
+
database = paths.target_data(name) / "codetrail.db"
|
|
306
|
+
with target_in_use(paths, name):
|
|
307
|
+
if not database.exists():
|
|
308
|
+
print(f"No facts yet. Run: codetrail update {name} --facts-only")
|
|
309
|
+
return 1
|
|
310
|
+
connection = connect(database)
|
|
311
|
+
try:
|
|
312
|
+
store = FactStore(connection)
|
|
313
|
+
snapshot = store.latest_snapshot()
|
|
314
|
+
if snapshot is None:
|
|
315
|
+
print(f"No facts yet. Run: codetrail update {name} --facts-only")
|
|
316
|
+
return 1
|
|
317
|
+
report = build_report(paths, name, load_global(paths), store, date.today())
|
|
318
|
+
tiles = build_tiles(report.values(), values_before(connection, snapshot.id), {})
|
|
319
|
+
finally:
|
|
320
|
+
connection.close()
|
|
321
|
+
for metric, label in METRIC_LABELS.items():
|
|
322
|
+
tile = tiles[metric]
|
|
323
|
+
if tile.value is None:
|
|
324
|
+
print(f"{label}: not available, the commit history couldn't be read")
|
|
325
|
+
elif metric in (Metric.DOCUMENTED_SHARE, Metric.EXPLAINED_SHARE) and not report.rationale.pages:
|
|
326
|
+
print(f"{label}: no pages yet")
|
|
327
|
+
else:
|
|
328
|
+
if metric in COUNTS:
|
|
329
|
+
shown = str(tile.value.numerator)
|
|
330
|
+
elif tile.percent is None:
|
|
331
|
+
shown = "nothing to measure yet"
|
|
332
|
+
else:
|
|
333
|
+
shown = f"{tile.percent}% ({tile.value.numerator} of {tile.value.denominator})"
|
|
334
|
+
unit = "" if metric in COUNTS else " points"
|
|
335
|
+
change = "no earlier update" if tile.change is None else f"{tile.change:+d}{unit} since the last update"
|
|
336
|
+
print(f"{label}: {shown}, {change}")
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def show_stats(paths: Paths, name: str) -> int:
|
|
341
|
+
"""Prints the repository statistics (design 19.5); reads the history but never writes it."""
|
|
342
|
+
load_target(paths, name)
|
|
343
|
+
database = paths.target_data(name) / "codetrail.db"
|
|
344
|
+
settings = load_global(paths)
|
|
345
|
+
with target_in_use(paths, name):
|
|
346
|
+
if not database.exists():
|
|
347
|
+
print(f"No facts yet. Run: codetrail update {name} --facts-only")
|
|
348
|
+
return 1
|
|
349
|
+
connection = connect(database)
|
|
350
|
+
try:
|
|
351
|
+
store = FactStore(connection)
|
|
352
|
+
if store.latest_snapshot() is None:
|
|
353
|
+
print(f"No facts yet. Run: codetrail update {name} --facts-only")
|
|
354
|
+
return 1
|
|
355
|
+
report = build_repository_report(paths, name, settings, store, date.today())
|
|
356
|
+
finally:
|
|
357
|
+
connection.close()
|
|
358
|
+
print_stats(report, date.today(), settings.metrics.max_listed)
|
|
359
|
+
return 0
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _plural(count: int, word: str) -> str:
|
|
363
|
+
return f"{count} {word}" if count == 1 else f"{count} {word}s"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def print_stats(report: RepositoryReport, today: date, listed: int) -> None:
|
|
367
|
+
activity, releases, size = report.activity, report.releases, report.size
|
|
368
|
+
if activity is None or activity.first is None or activity.latest is None:
|
|
369
|
+
print("History: not available, the commit history couldn't be read")
|
|
370
|
+
else:
|
|
371
|
+
years, months = age(activity.first, today)
|
|
372
|
+
if years or months:
|
|
373
|
+
ago = f"{_plural(years, 'year')}, {_plural(months, 'month')}"
|
|
374
|
+
else:
|
|
375
|
+
ago = _plural(max((today - activity.first).days, 0), "day")
|
|
376
|
+
print(f"First commit: {activity.first.isoformat()} ({ago} ago)")
|
|
377
|
+
print(f"Latest commit: {activity.latest.isoformat()}")
|
|
378
|
+
print(f"Commits: {activity.commits} ({_plural(activity.merges, 'merge')})")
|
|
379
|
+
if releases is None:
|
|
380
|
+
print("Releases: not available, the tags couldn't be read")
|
|
381
|
+
elif not releases.tags:
|
|
382
|
+
print("Releases: none")
|
|
383
|
+
else:
|
|
384
|
+
latest = releases.tags[0]
|
|
385
|
+
day = datetime.fromtimestamp(latest.date, UTC).date().isoformat()
|
|
386
|
+
print(f"Releases: {len(releases.tags)}, the latest {printable(latest.name)} on {day}, "
|
|
387
|
+
f"{_plural(releases.since_latest or 0, 'commit')} since") # fmt: skip
|
|
388
|
+
print(f"Code: {size.code_lines} lines in {_plural(len(size.languages), 'language')}")
|
|
389
|
+
for language in size.languages[:listed]:
|
|
390
|
+
print(f" {language.language}: {_plural(language.files, 'file')}, {_plural(language.lines, 'line')}")
|
|
391
|
+
print(f"Documents: {_plural(size.documents.files, 'file')}, {_plural(size.documents.lines, 'line')}")
|
|
392
|
+
share = percent_of(report.values()[Metric.TEST_SHARE])
|
|
393
|
+
shown = "nothing to measure" if share is None else f"{share}%"
|
|
394
|
+
print(f"Tests: {shown} ({size.test_lines} of {size.code_lines} lines)")
|
|
395
|
+
kinds = ", ".join(f"{kind.count} {kind.kind}" for kind in report.inventory.kinds)
|
|
396
|
+
print(f"Facts: {kinds or 'none'}")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _change_counts(diff: FactDiff) -> dict[str, list[int]]:
|
|
400
|
+
counts: dict[str, list[int]] = {}
|
|
401
|
+
for position, ids in enumerate((diff.added_entities, diff.changed_entities, diff.removed_entities)):
|
|
402
|
+
for entity_id in ids:
|
|
403
|
+
counts.setdefault(entity_id.split(":", 1)[0], [0, 0, 0])[position] += 1
|
|
404
|
+
for position, keys in enumerate((diff.added_relations, diff.changed_relations, diff.removed_relations)):
|
|
405
|
+
for key in keys:
|
|
406
|
+
counts.setdefault(key[1], [0, 0, 0])[position] += 1
|
|
407
|
+
return counts
|