monag 0.3.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.
- monag-0.3.0/PKG-INFO +71 -0
- monag-0.3.0/README.md +54 -0
- monag-0.3.0/pyproject.toml +32 -0
- monag-0.3.0/setup.cfg +4 -0
- monag-0.3.0/src/monag/__init__.py +2 -0
- monag-0.3.0/src/monag/__main__.py +2 -0
- monag-0.3.0/src/monag/agents.py +59 -0
- monag-0.3.0/src/monag/audit.py +189 -0
- monag-0.3.0/src/monag/catalog.py +192 -0
- monag-0.3.0/src/monag/cli.py +460 -0
- monag-0.3.0/src/monag/doctor.py +30 -0
- monag-0.3.0/src/monag/export.py +128 -0
- monag-0.3.0/src/monag/history.py +140 -0
- monag-0.3.0/src/monag/monitor.py +384 -0
- monag-0.3.0/src/monag/panel.py +272 -0
- monag-0.3.0/src/monag/presentation.py +153 -0
- monag-0.3.0/src/monag/resume.py +381 -0
- monag-0.3.0/src/monag.egg-info/PKG-INFO +71 -0
- monag-0.3.0/src/monag.egg-info/SOURCES.txt +30 -0
- monag-0.3.0/src/monag.egg-info/dependency_links.txt +1 -0
- monag-0.3.0/src/monag.egg-info/entry_points.txt +2 -0
- monag-0.3.0/src/monag.egg-info/requires.txt +6 -0
- monag-0.3.0/src/monag.egg-info/top_level.txt +1 -0
- monag-0.3.0/tests/test_activity.py +238 -0
- monag-0.3.0/tests/test_audit.py +150 -0
- monag-0.3.0/tests/test_catalog.py +123 -0
- monag-0.3.0/tests/test_export.py +109 -0
- monag-0.3.0/tests/test_monitor.py +173 -0
- monag-0.3.0/tests/test_panel.py +148 -0
- monag-0.3.0/tests/test_presentation.py +129 -0
- monag-0.3.0/tests/test_resume.py +187 -0
- monag-0.3.0/tests/test_startup.py +67 -0
monag-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: monag
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: See agent processes, concurrent checkouts and recent GitHub workspace activity in your terminal
|
|
5
|
+
Author: semcod
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: rich<15,>=14
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: build<2,>=1; extra == "dev"
|
|
15
|
+
Requires-Dist: twine<7,>=6; extra == "dev"
|
|
16
|
+
Requires-Dist: setuptools>=77.0.3; extra == "dev"
|
|
17
|
+
|
|
18
|
+
# monag
|
|
19
|
+
|
|
20
|
+
A terminal view of agents working across your GitHub workspace. Linux, Python
|
|
21
|
+
3.10+, Git; Rich renders Markdown in the terminal. Optional GitHub integration uses `gh`.
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
python3 -m pip install .
|
|
25
|
+
monag
|
|
26
|
+
monag status
|
|
27
|
+
monag --machine --agents-only watch
|
|
28
|
+
monag --machine --agents-only --view tree watch
|
|
29
|
+
monag --root ~/github --markdown status > activity.md
|
|
30
|
+
monag --root ~/github watch
|
|
31
|
+
monag --root ~/github --github status
|
|
32
|
+
monag --root ~/github --json status
|
|
33
|
+
monag history --search ticket-001
|
|
34
|
+
monag doctor
|
|
35
|
+
monag run --task "Fix issue #42" --issue 42 -- claude
|
|
36
|
+
./project.sh --help
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Running `monag` in a terminal opens the interactive shell and immediately prints
|
|
40
|
+
the agent dashboard plus the restart/backlog inventory. Use `refresh` to repeat
|
|
41
|
+
both reports, `resume` for worktrees and Planfile tickets, `audit` for the
|
|
42
|
+
Planfile/GitHub comparison, and `quit` or Ctrl-D to exit. `monag watch` remains
|
|
43
|
+
available for an always-refreshing dashboard; Ctrl-C exits it.
|
|
44
|
+
Use `monag status` for one snapshot. Pipes, `--json`, and `--markdown` default
|
|
45
|
+
to one snapshot unless `watch` is explicit.
|
|
46
|
+
|
|
47
|
+
Shows agent process count, working directories, concurrent agents in a checkout,
|
|
48
|
+
process trees and their working directories, changed files, recent commits,
|
|
49
|
+
reported tasks, searchable local history, and optional GitHub issues/PRs.
|
|
50
|
+
Custom agents: `--agent LABEL=EXECUTABLE` or `run --agent-kind LABEL`.
|
|
51
|
+
Open descriptor paths: `--open-files`. Watch records observations by default;
|
|
52
|
+
use `watch --no-record` to disable recording.
|
|
53
|
+
Put global options before `status`, `watch`, or `run`.
|
|
54
|
+
|
|
55
|
+
Planfile tickets are stored per sprint. `planfile ticket list` shows only the
|
|
56
|
+
`current` sprint; inspect the backlog explicitly with `planfile ticket list
|
|
57
|
+
--sprint backlog` (or use `monag resume`, which reads every local sprint and
|
|
58
|
+
legacy keyed records). To reconcile local tickets with GitHub, run the
|
|
59
|
+
read-only preview first:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
planfile sync github --dry-run --direction both
|
|
63
|
+
planfile sync github --direction both
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
[Usage, interpretation and limitations](docs/information/usage.md) ·
|
|
67
|
+
[Documentation](docs/README.md)
|
|
68
|
+
|
|
69
|
+
Governance checks are available through `./project.sh` on Linux/macOS and
|
|
70
|
+
`project.bat` on Windows; both entry points execute the repository's pinned
|
|
71
|
+
governance validator before optional analysis tooling.
|
monag-0.3.0/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# monag
|
|
2
|
+
|
|
3
|
+
A terminal view of agents working across your GitHub workspace. Linux, Python
|
|
4
|
+
3.10+, Git; Rich renders Markdown in the terminal. Optional GitHub integration uses `gh`.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
python3 -m pip install .
|
|
8
|
+
monag
|
|
9
|
+
monag status
|
|
10
|
+
monag --machine --agents-only watch
|
|
11
|
+
monag --machine --agents-only --view tree watch
|
|
12
|
+
monag --root ~/github --markdown status > activity.md
|
|
13
|
+
monag --root ~/github watch
|
|
14
|
+
monag --root ~/github --github status
|
|
15
|
+
monag --root ~/github --json status
|
|
16
|
+
monag history --search ticket-001
|
|
17
|
+
monag doctor
|
|
18
|
+
monag run --task "Fix issue #42" --issue 42 -- claude
|
|
19
|
+
./project.sh --help
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Running `monag` in a terminal opens the interactive shell and immediately prints
|
|
23
|
+
the agent dashboard plus the restart/backlog inventory. Use `refresh` to repeat
|
|
24
|
+
both reports, `resume` for worktrees and Planfile tickets, `audit` for the
|
|
25
|
+
Planfile/GitHub comparison, and `quit` or Ctrl-D to exit. `monag watch` remains
|
|
26
|
+
available for an always-refreshing dashboard; Ctrl-C exits it.
|
|
27
|
+
Use `monag status` for one snapshot. Pipes, `--json`, and `--markdown` default
|
|
28
|
+
to one snapshot unless `watch` is explicit.
|
|
29
|
+
|
|
30
|
+
Shows agent process count, working directories, concurrent agents in a checkout,
|
|
31
|
+
process trees and their working directories, changed files, recent commits,
|
|
32
|
+
reported tasks, searchable local history, and optional GitHub issues/PRs.
|
|
33
|
+
Custom agents: `--agent LABEL=EXECUTABLE` or `run --agent-kind LABEL`.
|
|
34
|
+
Open descriptor paths: `--open-files`. Watch records observations by default;
|
|
35
|
+
use `watch --no-record` to disable recording.
|
|
36
|
+
Put global options before `status`, `watch`, or `run`.
|
|
37
|
+
|
|
38
|
+
Planfile tickets are stored per sprint. `planfile ticket list` shows only the
|
|
39
|
+
`current` sprint; inspect the backlog explicitly with `planfile ticket list
|
|
40
|
+
--sprint backlog` (or use `monag resume`, which reads every local sprint and
|
|
41
|
+
legacy keyed records). To reconcile local tickets with GitHub, run the
|
|
42
|
+
read-only preview first:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
planfile sync github --dry-run --direction both
|
|
46
|
+
planfile sync github --direction both
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
[Usage, interpretation and limitations](docs/information/usage.md) ·
|
|
50
|
+
[Documentation](docs/README.md)
|
|
51
|
+
|
|
52
|
+
Governance checks are available through `./project.sh` on Linux/macOS and
|
|
53
|
+
`project.bat` on Windows; both entry points execute the repository's pinned
|
|
54
|
+
governance validator before optional analysis tooling.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "monag"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "See agent processes, concurrent checkouts and recent GitHub workspace activity in your terminal"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{name = "semcod"}]
|
|
13
|
+
classifiers = ["Environment :: Console", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3"]
|
|
14
|
+
dependencies = ["rich>=14,<15"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["build>=1,<2", "twine>=6,<7", "setuptools>=77.0.3"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
monag = "monag.cli:main"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
|
|
25
|
+
[tool.wellmanifest]
|
|
26
|
+
standard = "0.20.31"
|
|
27
|
+
revision = "2b016654cff1a1ccef2c0d6126a9c2550ae6b37a"
|
|
28
|
+
gate = "project/governance-check.sh"
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
addopts = "-p wellmanifest_governance"
|
|
32
|
+
pythonpath = ["src", "tests"]
|
monag-0.3.0/setup.cfg
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Extensible executable detection; never search prompts or arbitrary arguments."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
ALIASES = {name: name for name in (
|
|
6
|
+
'codex', 'claude', 'aider', 'gemini', 'opencode', 'goose', 'cursor-agent',
|
|
7
|
+
'amp', 'qwen', 'kilo', 'copilot', 'crush', 'openhands', 'continue', 'devin',
|
|
8
|
+
)}
|
|
9
|
+
MODULES = {'aider': 'aider', 'aider.main': 'aider', 'openhands': 'openhands'}
|
|
10
|
+
SCOPES = {'@openai/codex': 'codex', '@anthropic-ai/claude-code': 'claude',
|
|
11
|
+
'@google/gemini-cli': 'gemini', '@qwen-code/qwen-code': 'qwen',
|
|
12
|
+
'@github/copilot': 'copilot'}
|
|
13
|
+
# Agent Client Protocol adapters started by IDEs, e.g. claude-agent-acp or glm-acp-agent.
|
|
14
|
+
ACP = re.compile(r'[a-z0-9][a-z0-9_.-]*-(?:agent-acp|acp-agent|acp)')
|
|
15
|
+
# Helper modes selected by a leading flag are not agent sessions.
|
|
16
|
+
HELPER_FLAGS = {'--chrome-native-host'}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def aliases(entries=()):
|
|
20
|
+
result = dict(ALIASES)
|
|
21
|
+
for entry in entries:
|
|
22
|
+
kind, sep, executable = entry.partition('=')
|
|
23
|
+
if not sep or not re.fullmatch(r'[a-zA-Z0-9_-]+', kind) or not executable or Path(executable).name != executable:
|
|
24
|
+
raise ValueError('--agent must be LABEL=EXECUTABLE (a basename, without a path)')
|
|
25
|
+
result[executable] = kind
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def kind_of(name, registry):
|
|
30
|
+
return registry.get(name) or (name if ACP.fullmatch(name) else None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def helper(arguments):
|
|
34
|
+
# Only an exact leading flag is compared; a positional prompt is never read.
|
|
35
|
+
return bool(arguments) and arguments[0] in HELPER_FLAGS
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def identify(argv, registry=None):
|
|
39
|
+
registry = ALIASES if registry is None else registry
|
|
40
|
+
if not argv or not argv[0]:
|
|
41
|
+
return None, False
|
|
42
|
+
executable = Path(argv[0]).name
|
|
43
|
+
kind = kind_of(executable, registry)
|
|
44
|
+
if kind:
|
|
45
|
+
return (None, False) if helper(argv[1:]) else (kind, False)
|
|
46
|
+
runtime = executable in {'node', 'nodejs', 'bun', 'deno'} or re.fullmatch(r'python(?:\d+(?:\.\d+)*)?', executable)
|
|
47
|
+
if not runtime or len(argv) < 2:
|
|
48
|
+
return None, False
|
|
49
|
+
if argv[1] == '-m' and len(argv) > 2:
|
|
50
|
+
return MODULES.get(argv[2], registry.get(argv[2])), True
|
|
51
|
+
# Only the runtime's entrypoint is inspected, never trailing arguments.
|
|
52
|
+
script = argv[1]
|
|
53
|
+
if script.startswith('-'):
|
|
54
|
+
return None, False
|
|
55
|
+
name = Path(script).name.removesuffix('.js').removesuffix('.mjs').removesuffix('.py')
|
|
56
|
+
kind = kind_of(name, registry) or next((k for scope, k in SCOPES.items() if f'/{scope}/' in script), None)
|
|
57
|
+
if not kind or helper(argv[2:]):
|
|
58
|
+
return None, False
|
|
59
|
+
return kind, True
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Read-only audit of local Planfile ticket coverage against GitHub Issues.
|
|
2
|
+
|
|
3
|
+
For one repository (`--root` is a Git checkout) this reports how many of its
|
|
4
|
+
GitHub Issues have a corresponding Planfile ticket, which tickets have none,
|
|
5
|
+
and whether a ticket's own GitHub mapping agrees with the repository-wide
|
|
6
|
+
sync index (`.planfile/sync/github.state.yaml`). When `--root` is a workspace
|
|
7
|
+
of several repositories (not itself a checkout, e.g. the default `~/github`),
|
|
8
|
+
the same audit runs for every discovered repository with a GitHub remote.
|
|
9
|
+
|
|
10
|
+
`gh` failures, a missing remote, or a repository without a local Planfile are
|
|
11
|
+
all reported as explicit unknowns, never silently treated as "zero".
|
|
12
|
+
"""
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from .monitor import command, github_repo
|
|
22
|
+
from .presentation import table
|
|
23
|
+
from .resume import planfile as read_planfile, registrations, roots as discover_roots
|
|
24
|
+
|
|
25
|
+
SCHEMA = 'monag.audit/v1'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sync_index(path):
|
|
29
|
+
"""The repository-wide Planfile-to-GitHub mapping, if one is recorded."""
|
|
30
|
+
file = Path(path) / '.planfile' / 'sync' / 'github.state.yaml'
|
|
31
|
+
if not file.is_file():
|
|
32
|
+
return {}, None, None
|
|
33
|
+
try:
|
|
34
|
+
if file.stat().st_size > 2_000_000:
|
|
35
|
+
raise ValueError('file exceeds 2 MB read limit')
|
|
36
|
+
data = yaml.load(file.read_text(), Loader=getattr(yaml, 'CSafeLoader', yaml.SafeLoader))
|
|
37
|
+
mapping = data.get('ticket_map') if isinstance(data, dict) else None
|
|
38
|
+
if not isinstance(mapping, dict):
|
|
39
|
+
raise ValueError('expected a ticket_map mapping')
|
|
40
|
+
return ({str(k): str(v) for k, v in mapping.items() if v is not None},
|
|
41
|
+
str(file), None)
|
|
42
|
+
except (OSError, ValueError, TypeError, yaml.YAMLError, RecursionError) as error:
|
|
43
|
+
return {}, str(file), f'{file}: {type(error).__name__}'
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def repo_remote(path):
|
|
47
|
+
remote, _ = command(['git', 'config', '--get', 'remote.origin.url'], path)
|
|
48
|
+
return github_repo(remote) if remote else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def github_issues(repo, limit=200):
|
|
52
|
+
"""All GitHub Issues (open and closed) for a repository, via `gh`."""
|
|
53
|
+
out, error = command(['gh', 'issue', 'list', '--repo', repo, '--state', 'all',
|
|
54
|
+
'--limit', str(limit), '--json',
|
|
55
|
+
'number,title,state,url,updatedAt,labels'], timeout=20)
|
|
56
|
+
if error:
|
|
57
|
+
return None, [f'{repo}: {error}']
|
|
58
|
+
try:
|
|
59
|
+
issues = json.loads(out)
|
|
60
|
+
if not isinstance(issues, list):
|
|
61
|
+
raise ValueError('expected a JSON array')
|
|
62
|
+
return issues, []
|
|
63
|
+
except (ValueError, TypeError):
|
|
64
|
+
return None, [f'{repo}: invalid gh JSON']
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def audit_repo(path, issue_limit=200):
|
|
68
|
+
"""One repository's coverage report; never raises, errors are data."""
|
|
69
|
+
path = Path(path)
|
|
70
|
+
tickets, planfile_sources, planfile_errors = read_planfile(path)
|
|
71
|
+
index, index_source, index_error = sync_index(path)
|
|
72
|
+
repo = repo_remote(path)
|
|
73
|
+
issues, github_errors = (None, []) if repo is None else github_issues(repo, issue_limit)
|
|
74
|
+
fetched = repo is not None and not github_errors
|
|
75
|
+
issues = issues or []
|
|
76
|
+
issue_by_number = {str(item['number']): item for item in issues
|
|
77
|
+
if isinstance(item, dict) and 'number' in item}
|
|
78
|
+
mapped = {t['id']: t['github'] for t in tickets if t.get('github')}
|
|
79
|
+
tracked_numbers = set(mapped.values())
|
|
80
|
+
untracked_issues = ([item for item in issues
|
|
81
|
+
if str(item.get('number')) not in tracked_numbers] if fetched else [])
|
|
82
|
+
orphan_tickets = ([{'id': ticket_id, 'github': issue_id} for ticket_id, issue_id in mapped.items()
|
|
83
|
+
if issue_id not in issue_by_number] if fetched else [])
|
|
84
|
+
# Meaningful only once a sync index file exists: its absence is a different,
|
|
85
|
+
# unrelated fact from an existing index missing one ticket's entry.
|
|
86
|
+
sync_drift = ([{'id': ticket_id, 'github': issue_id, 'in_sync_index': index.get(ticket_id) == issue_id}
|
|
87
|
+
for ticket_id, issue_id in sorted(mapped.items()) if index.get(ticket_id) != issue_id]
|
|
88
|
+
if index_source is not None else [])
|
|
89
|
+
return {
|
|
90
|
+
'path': str(path), 'repo': repo,
|
|
91
|
+
'planfile_available': bool(planfile_sources),
|
|
92
|
+
'planfile_sources': planfile_sources, 'planfile_errors': planfile_errors,
|
|
93
|
+
'tickets': tickets, 'ticket_count': len(tickets), 'mapped_ticket_count': len(mapped),
|
|
94
|
+
'sync_index_source': index_source, 'sync_index_error': index_error,
|
|
95
|
+
'github_fetched': fetched, 'github_errors': github_errors,
|
|
96
|
+
'github_issue_count': len(issues) if fetched else None,
|
|
97
|
+
'github_open': sum(1 for i in issues if i.get('state') == 'OPEN') if fetched else None,
|
|
98
|
+
'github_closed': sum(1 for i in issues if i.get('state') == 'CLOSED') if fetched else None,
|
|
99
|
+
'untracked_issues': untracked_issues, 'orphan_tickets': orphan_tickets,
|
|
100
|
+
'sync_drift': sync_drift,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def targets(root, depth):
|
|
105
|
+
"""(mode, [primary checkout paths]) — one repository, or every one under root."""
|
|
106
|
+
root = Path(root)
|
|
107
|
+
if (root / '.git').exists():
|
|
108
|
+
records, error = registrations(root)
|
|
109
|
+
primary = Path(records[0]['path']) if records and not error else root
|
|
110
|
+
return 'repository', [primary], ([f'{root}: {error}'] if error else [])
|
|
111
|
+
found, seen, errors = [], set(), []
|
|
112
|
+
for candidate in discover_roots(root, depth):
|
|
113
|
+
records, error = registrations(candidate)
|
|
114
|
+
if error or not records:
|
|
115
|
+
errors.append(f'{candidate}: {error or "no worktree records"}')
|
|
116
|
+
continue
|
|
117
|
+
primary = Path(records[0]['path'])
|
|
118
|
+
if str(primary) in seen:
|
|
119
|
+
continue
|
|
120
|
+
seen.add(str(primary))
|
|
121
|
+
found.append(primary)
|
|
122
|
+
return 'workspace', found, errors
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def scan(root, depth=2, issue_limit=200):
|
|
126
|
+
started = time.monotonic()
|
|
127
|
+
mode, paths, errors = targets(root, depth)
|
|
128
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
129
|
+
repositories = list(pool.map(lambda p: audit_repo(p, issue_limit), paths))
|
|
130
|
+
repositories.sort(key=lambda r: (-len(r['untracked_issues']), -len(r['sync_drift']),
|
|
131
|
+
-len(r['orphan_tickets']), r['path']))
|
|
132
|
+
return {
|
|
133
|
+
'schema': SCHEMA, 'root': str(root), 'mode': mode,
|
|
134
|
+
'observed_at': datetime.now(timezone.utc).isoformat(),
|
|
135
|
+
'duration_seconds': round(time.monotonic() - started, 2),
|
|
136
|
+
'repositories': repositories, 'repository_count': len(repositories),
|
|
137
|
+
'repositories_with_github': sum(1 for r in repositories if r['repo']),
|
|
138
|
+
'repositories_with_planfile': sum(1 for r in repositories if r['planfile_available']),
|
|
139
|
+
'total_github_issues': sum(r['github_issue_count'] or 0 for r in repositories),
|
|
140
|
+
'total_planfile_tickets': sum(r['ticket_count'] for r in repositories),
|
|
141
|
+
'total_untracked_issues': sum(len(r['untracked_issues']) for r in repositories),
|
|
142
|
+
'total_sync_drift': sum(len(r['sync_drift']) for r in repositories),
|
|
143
|
+
'errors': errors,
|
|
144
|
+
'notice': 'Read-only comparison of local Planfile tickets against GitHub Issues via `gh`; '
|
|
145
|
+
'no writes to either. A missing GitHub remote or failed `gh` call leaves that '
|
|
146
|
+
'repository\'s issue counts unset (shown as "-"), never zero. Sync-index drift '
|
|
147
|
+
'compares a ticket\'s own mapping against .planfile/sync/github.state.yaml; a '
|
|
148
|
+
'missing global entry does not mean the ticket itself is wrong.',
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def markdown(data, limit=12):
|
|
153
|
+
lines = ['# MONAG — Planfile / GitHub coverage audit', '',
|
|
154
|
+
f"Mode: **{data['mode']}** · repositories: **{data['repository_count']}** "
|
|
155
|
+
f"({data['repositories_with_github']} with a GitHub remote, "
|
|
156
|
+
f"{data['repositories_with_planfile']} with a local Planfile) · scan: "
|
|
157
|
+
f"{data['duration_seconds']} s.", '',
|
|
158
|
+
f"GitHub issues observed: **{data['total_github_issues']}** · "
|
|
159
|
+
f"Planfile tickets: **{data['total_planfile_tickets']}** · "
|
|
160
|
+
f"issues with no Planfile ticket: **{data['total_untracked_issues']}** · "
|
|
161
|
+
f"ticket/sync-index drift: **{data['total_sync_drift']}**.", '',
|
|
162
|
+
'## Repositories', '']
|
|
163
|
+
shown = data['repositories'][:limit]
|
|
164
|
+
lines.append(table(['Repository', 'GitHub issues', 'Planfile tickets', 'Mapped', 'Untracked', 'Orphan', 'Sync drift'],
|
|
165
|
+
[[r['repo'] or r['path'],
|
|
166
|
+
r['github_issue_count'] if r['github_issue_count'] is not None else '-',
|
|
167
|
+
r['ticket_count'] if r['planfile_available'] else '-',
|
|
168
|
+
r['mapped_ticket_count'], len(r['untracked_issues']),
|
|
169
|
+
len(r['orphan_tickets']), len(r['sync_drift'])] for r in shown]))
|
|
170
|
+
untracked_rows = [[r['repo'] or r['path'], '#' + str(issue.get('number')),
|
|
171
|
+
issue.get('state', ''), issue.get('title', '')]
|
|
172
|
+
for r in shown for issue in r['untracked_issues'][:limit]]
|
|
173
|
+
lines.extend(['', '## GitHub issues with no Planfile ticket', '',
|
|
174
|
+
table(['Repository', 'Issue', 'State', 'Title'], untracked_rows[:limit])])
|
|
175
|
+
drift_rows = [[r['repo'] or r['path'], drift['id'], drift['github'],
|
|
176
|
+
'present' if drift['in_sync_index'] else 'missing/mismatched']
|
|
177
|
+
for r in shown for drift in r['sync_drift'][:limit]]
|
|
178
|
+
lines.extend(['', '## Ticket vs. sync-index drift', '',
|
|
179
|
+
table(['Repository', 'Ticket', 'GitHub id', 'Global sync index'], drift_rows[:limit])])
|
|
180
|
+
orphan_rows = [[r['repo'] or r['path'], orphan['id'], orphan['github']]
|
|
181
|
+
for r in shown for orphan in r['orphan_tickets'][:limit]]
|
|
182
|
+
if orphan_rows:
|
|
183
|
+
lines.extend(['', '## Planfile tickets mapped to a missing GitHub issue', '',
|
|
184
|
+
table(['Repository', 'Ticket', 'GitHub id'], orphan_rows[:limit])])
|
|
185
|
+
failures = (data['errors'] + [e for r in data['repositories'] for e in r['planfile_errors']] +
|
|
186
|
+
[e for r in data['repositories'] for e in r['github_errors']])
|
|
187
|
+
lines.extend(['', f"Read errors: {len(failures)}. Repositories shown: {min(limit, data['repository_count'])}/{data['repository_count']}.",
|
|
188
|
+
'', data['notice'], '', 'Full data and every row: `monag --json audit`.'])
|
|
189
|
+
return '\n'.join(lines) + '\n'
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Read-only catalog of local repositories: what each one is, from its own metadata.
|
|
2
|
+
|
|
3
|
+
No network calls and no LLM summarization: every field is either read verbatim
|
|
4
|
+
from a manifest or doc the project already ships (pyproject.toml, package.json,
|
|
5
|
+
README.md, ...) or a boolean/timestamp observed directly from the checkout. A
|
|
6
|
+
project with no declared description is reported as unknown, never guessed --
|
|
7
|
+
this is an index of what projects say about themselves, not an opinion.
|
|
8
|
+
"""
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from .audit import targets as discover_targets
|
|
17
|
+
from .monitor import command, github_repo
|
|
18
|
+
from .presentation import table
|
|
19
|
+
|
|
20
|
+
SCHEMA = 'monag.catalog/v1'
|
|
21
|
+
|
|
22
|
+
STACK_MANIFESTS = {
|
|
23
|
+
'python': ('pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt'),
|
|
24
|
+
'node': ('package.json',),
|
|
25
|
+
'go': ('go.mod',),
|
|
26
|
+
'rust': ('Cargo.toml',),
|
|
27
|
+
'java': ('pom.xml', 'build.gradle', 'build.gradle.kts'),
|
|
28
|
+
'docker': ('Dockerfile', 'docker-compose.yml', 'docker-compose.yaml'),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def detect_stacks(path):
|
|
33
|
+
return sorted(name for name, files in STACK_MANIFESTS.items()
|
|
34
|
+
if any((path / name_file).is_file() for name_file in files))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_text(path, limit=200_000):
|
|
38
|
+
try:
|
|
39
|
+
if not path.is_file() or path.is_symlink() or path.stat().st_size > limit:
|
|
40
|
+
return None
|
|
41
|
+
return path.read_text(errors='replace')
|
|
42
|
+
except OSError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def description_from_pyproject(path):
|
|
47
|
+
text = read_text(path / 'pyproject.toml')
|
|
48
|
+
if not text:
|
|
49
|
+
return None
|
|
50
|
+
match = re.search(r'(?m)^\s*description\s*=\s*"((?:[^"\\]|\\.)*)"', text)
|
|
51
|
+
if not match:
|
|
52
|
+
return None
|
|
53
|
+
value = match.group(1).replace('\\"', '"').strip()
|
|
54
|
+
return value or None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def description_from_package_json(path):
|
|
58
|
+
text = read_text(path / 'package.json')
|
|
59
|
+
if not text:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(text)
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
value = data.get('description') if isinstance(data, dict) else None
|
|
66
|
+
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def description_from_readme(path):
|
|
70
|
+
for name in ('README.md', 'README.rst', 'README.txt', 'README'):
|
|
71
|
+
text = read_text(path / name)
|
|
72
|
+
if not text:
|
|
73
|
+
continue
|
|
74
|
+
paragraph = []
|
|
75
|
+
for line in text.splitlines():
|
|
76
|
+
stripped = line.strip()
|
|
77
|
+
if not stripped:
|
|
78
|
+
if paragraph:
|
|
79
|
+
break
|
|
80
|
+
continue
|
|
81
|
+
if stripped.startswith(('#', '```', '![', '|', '<', '[!', '>')):
|
|
82
|
+
continue
|
|
83
|
+
paragraph.append(stripped)
|
|
84
|
+
if paragraph:
|
|
85
|
+
return ' '.join(paragraph)[:400]
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
DESCRIPTION_SOURCES = (
|
|
90
|
+
(description_from_pyproject, 'pyproject.toml'),
|
|
91
|
+
(description_from_package_json, 'package.json'),
|
|
92
|
+
(description_from_readme, 'README.md'),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def entry_points(path):
|
|
97
|
+
points = set()
|
|
98
|
+
pyproject = read_text(path / 'pyproject.toml')
|
|
99
|
+
if pyproject:
|
|
100
|
+
section = re.search(r'(?ms)^\[project\.scripts\]\s*$(.*?)(?=^\[|\Z)', pyproject)
|
|
101
|
+
if section:
|
|
102
|
+
points.update(re.findall(r'(?m)^\s*([A-Za-z0-9_.-]+)\s*=', section.group(1)))
|
|
103
|
+
package_json = read_text(path / 'package.json')
|
|
104
|
+
if package_json:
|
|
105
|
+
try:
|
|
106
|
+
data = json.loads(package_json)
|
|
107
|
+
except ValueError:
|
|
108
|
+
data = None
|
|
109
|
+
if isinstance(data, dict):
|
|
110
|
+
bin_field = data.get('bin')
|
|
111
|
+
if isinstance(bin_field, dict):
|
|
112
|
+
points.update(k for k in bin_field if isinstance(k, str))
|
|
113
|
+
elif isinstance(bin_field, str) and isinstance(data.get('name'), str):
|
|
114
|
+
points.add(data['name'])
|
|
115
|
+
return sorted(points)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def last_commit(path):
|
|
119
|
+
out, error = command(['git', 'log', '-1', '--format=%ct%x00%s'], path)
|
|
120
|
+
if error or not out.strip():
|
|
121
|
+
return None, None
|
|
122
|
+
stamp, _, subject = out.strip('\n').partition('\0')
|
|
123
|
+
try:
|
|
124
|
+
when = datetime.fromtimestamp(int(stamp), timezone.utc).isoformat()
|
|
125
|
+
except (ValueError, OverflowError):
|
|
126
|
+
return None, subject or None
|
|
127
|
+
return when, subject or None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def describe_repo(path):
|
|
131
|
+
"""One repository's self-declared identity; never raises, errors are data."""
|
|
132
|
+
path = Path(path)
|
|
133
|
+
remote, _ = command(['git', 'config', '--get', 'remote.origin.url'], path)
|
|
134
|
+
repo = github_repo(remote) if remote else None
|
|
135
|
+
description, source = None, None
|
|
136
|
+
for extractor, label in DESCRIPTION_SOURCES:
|
|
137
|
+
description = extractor(path)
|
|
138
|
+
if description:
|
|
139
|
+
source = label
|
|
140
|
+
break
|
|
141
|
+
commit_at, commit_subject = last_commit(path)
|
|
142
|
+
return {
|
|
143
|
+
'path': str(path), 'name': path.name, 'repo': repo,
|
|
144
|
+
'description': description, 'description_source': source,
|
|
145
|
+
'stacks': detect_stacks(path), 'entry_points': entry_points(path),
|
|
146
|
+
'has_docs': (path / 'docs').is_dir(),
|
|
147
|
+
'has_tests': any((path / name).is_dir() for name in ('tests', 'test')),
|
|
148
|
+
'has_changelog': (path / 'CHANGELOG.md').is_file(),
|
|
149
|
+
'last_commit_at': commit_at, 'last_commit_subject': commit_subject,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def scan(root, depth=2):
|
|
154
|
+
started = time.monotonic()
|
|
155
|
+
mode, paths, errors = discover_targets(root, depth)
|
|
156
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
157
|
+
repositories = list(pool.map(describe_repo, paths))
|
|
158
|
+
repositories.sort(key=lambda r: r['name'])
|
|
159
|
+
return {
|
|
160
|
+
'schema': SCHEMA, 'root': str(root), 'mode': mode,
|
|
161
|
+
'observed_at': datetime.now(timezone.utc).isoformat(),
|
|
162
|
+
'duration_seconds': round(time.monotonic() - started, 2),
|
|
163
|
+
'repositories': repositories, 'repository_count': len(repositories),
|
|
164
|
+
'described_count': sum(1 for r in repositories if r['description']),
|
|
165
|
+
'undescribed_count': sum(1 for r in repositories if not r['description']),
|
|
166
|
+
'errors': errors,
|
|
167
|
+
'notice': 'Read-only, local-only catalog: every field is read verbatim from a '
|
|
168
|
+
"manifest or doc the project already ships, or observed directly from "
|
|
169
|
+
'the checkout. No network calls, no LLM summarization, no guessing -- '
|
|
170
|
+
'a project with no declared description is reported as unknown, not '
|
|
171
|
+
'invented.',
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def markdown(data, limit=40):
|
|
176
|
+
lines = ['# MONAG — project catalog', '',
|
|
177
|
+
f"Mode: **{data['mode']}** · repositories: **{data['repository_count']}** "
|
|
178
|
+
f"({data['described_count']} with a declared description, "
|
|
179
|
+
f"{data['undescribed_count']} unknown) · scan: {data['duration_seconds']} s.",
|
|
180
|
+
'', '## Repositories', '']
|
|
181
|
+
shown = data['repositories'][:limit]
|
|
182
|
+
lines.append(table(['Name', 'Stacks', 'Description', 'Docs', 'Tests', 'Last commit'],
|
|
183
|
+
[[r['name'], ', '.join(r['stacks']) or '-',
|
|
184
|
+
r['description'] or '(undeclared)',
|
|
185
|
+
'yes' if r['has_docs'] else 'no', 'yes' if r['has_tests'] else 'no',
|
|
186
|
+
(r['last_commit_at'] or '-')[:10]] for r in shown]))
|
|
187
|
+
undescribed = sorted(r['name'] for r in data['repositories'] if not r['description'])
|
|
188
|
+
if undescribed:
|
|
189
|
+
lines.extend(['', '## No declared description', '', ', '.join(undescribed[:limit])])
|
|
190
|
+
lines.extend(['', f"Repositories shown: {min(limit, data['repository_count'])}/{data['repository_count']}.",
|
|
191
|
+
'', data['notice'], '', 'Full data and every row: `monag --json catalog`.'])
|
|
192
|
+
return '\n'.join(lines) + '\n'
|