mokosh 0.1.0__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.
- mokosh/__init__.py +11 -0
- mokosh/__main__.py +3 -0
- mokosh/cli.py +440 -0
- mokosh/db_manager.py +404 -0
- mokosh/exporter.py +256 -0
- mokosh/git_connector.py +151 -0
- mokosh/schema.py +45 -0
- mokosh/task_extractor.py +141 -0
- mokosh/task_fetcher.py +121 -0
- mokosh/text.py +103 -0
- mokosh/tokens.py +91 -0
- mokosh/trackers/__init__.py +20 -0
- mokosh/trackers/github.py +83 -0
- mokosh/trackers/gitlab.py +54 -0
- mokosh/trackers/jira_api.py +34 -0
- mokosh/trackers/jira_html.py +30 -0
- mokosh/trackers/jira_selenium.py +41 -0
- mokosh/trackers/youtrack.py +49 -0
- mokosh/ui/__init__.py +0 -0
- mokosh/ui/server.py +315 -0
- mokosh/ui/webui/app.js +203 -0
- mokosh/ui/webui/i18n.js +39 -0
- mokosh/ui/webui/index.html +120 -0
- mokosh/ui/webui/lang/de.js +71 -0
- mokosh/ui/webui/lang/en.js +71 -0
- mokosh/ui/webui/lang/es.js +71 -0
- mokosh/ui/webui/lang/fr.js +71 -0
- mokosh/ui/webui/lang/pt.js +71 -0
- mokosh/ui/webui/lang/uk.js +71 -0
- mokosh/ui/webui/style.css +251 -0
- mokosh/ui/webui/themes/beach.css +19 -0
- mokosh/ui/webui/themes/dark.css +19 -0
- mokosh/ui/webui/themes/light.css +19 -0
- mokosh/ui/webui/themes/mono-dark.css +19 -0
- mokosh/ui/webui/themes/mono-light.css +19 -0
- mokosh/ui/webui/themes/ocean.css +19 -0
- mokosh/ui/webui/themes/soft.css +19 -0
- mokosh/ui/webui/themes/summer.css +19 -0
- mokosh-0.1.0.dist-info/METADATA +239 -0
- mokosh-0.1.0.dist-info/RECORD +44 -0
- mokosh-0.1.0.dist-info/WHEEL +5 -0
- mokosh-0.1.0.dist-info/entry_points.txt +2 -0
- mokosh-0.1.0.dist-info/licenses/LICENSE +21 -0
- mokosh-0.1.0.dist-info/top_level.txt +1 -0
mokosh/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""mokosh — the dataset a code-search study starts from: commits of a git repository and the
|
|
2
|
+
tasks of its tracker (Jira, GitHub, GitLab, YouTrack), in one SQLite database (TASK, COMMIT).
|
|
3
|
+
simargl, gamayun and 1bcoder read that database; mokosh is the only one that writes it."""
|
|
4
|
+
__version__ = "0.1.0"
|
|
5
|
+
|
|
6
|
+
from .db_manager import DatabaseManager # noqa: E402
|
|
7
|
+
from .git_connector import GitConnector # noqa: E402
|
|
8
|
+
from .task_extractor import TaskExtractor # noqa: E402
|
|
9
|
+
from .task_fetcher import TaskFetcher # noqa: E402
|
|
10
|
+
|
|
11
|
+
__all__ = ["DatabaseManager", "GitConnector", "TaskExtractor", "TaskFetcher", "__version__"]
|
mokosh/__main__.py
ADDED
mokosh/cli.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""mokosh command line: gather the dataset.
|
|
2
|
+
|
|
3
|
+
mokosh init # wizard: repository, branch, tracker -> .mokosh/project.yaml
|
|
4
|
+
mokosh ingest [--phase git|tasks] [--force] # commits, then task references and task details;
|
|
5
|
+
# run it again (e.g. monthly) to add only what is new
|
|
6
|
+
mokosh plaintext <db> [--dry-run] # tracker markup (Jira ADF, wiki) in TASK -> plain text
|
|
7
|
+
mokosh export <db> [--join|--all|--task|--commits] [--level title|desc|comments] [--out DIR]
|
|
8
|
+
mokosh status [<db>] # what the database holds
|
|
9
|
+
mokosh fixdiff <db> --repo <repo> # DIFF of an older mokosh (commit -> parent) the right way
|
|
10
|
+
mokosh ui [--port 8093] [--host 127.0.0.1] # web UI: Setup form, ingest with its log, status
|
|
11
|
+
|
|
12
|
+
The project settings and the resume checkpoint live in .mokosh/ (project.yaml, ingest_state.yaml).
|
|
13
|
+
A folder set up earlier with `simargl init` (.simargl/project.yaml) is used as it is: no new init,
|
|
14
|
+
no ingest from scratch. Then: `simargl index units <db>`, `gamayun index --db <db>`.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import datetime as _dt
|
|
20
|
+
import os
|
|
21
|
+
import sqlite3
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
STORE = ".mokosh"
|
|
25
|
+
LEGACY_STORE = ".simargl"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _yaml():
|
|
29
|
+
import yaml
|
|
30
|
+
return yaml
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_store(store_dir: str | None) -> str:
|
|
34
|
+
"""The folder with project.yaml: the one given, else .mokosh, else an earlier .simargl."""
|
|
35
|
+
if store_dir:
|
|
36
|
+
return store_dir
|
|
37
|
+
if not os.path.isfile(os.path.join(STORE, "project.yaml")) and \
|
|
38
|
+
os.path.isfile(os.path.join(LEGACY_STORE, "project.yaml")):
|
|
39
|
+
return LEGACY_STORE
|
|
40
|
+
return STORE
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_env_token(value: str) -> str:
|
|
44
|
+
"""${VAR} -> the environment variable's value (tokens stay out of project.yaml)."""
|
|
45
|
+
from .tokens import resolve
|
|
46
|
+
return resolve(value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_project(store_dir: str) -> dict | None:
|
|
50
|
+
path = os.path.join(store_dir, "project.yaml")
|
|
51
|
+
if not os.path.exists(path):
|
|
52
|
+
print(f"project.yaml not found at {path}")
|
|
53
|
+
print("Run: mokosh init")
|
|
54
|
+
return None
|
|
55
|
+
with open(path, encoding="utf-8") as f:
|
|
56
|
+
return _yaml().safe_load(f)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_state(path: str) -> dict:
|
|
60
|
+
if os.path.exists(path):
|
|
61
|
+
with open(path, encoding="utf-8") as f:
|
|
62
|
+
return _yaml().safe_load(f) or {}
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _save_state(path: str, state: dict):
|
|
67
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
68
|
+
_yaml().dump(state, f, default_flow_style=False, allow_unicode=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _fresh_state() -> dict:
|
|
72
|
+
return {"git": {"status": "pending"},
|
|
73
|
+
"tasks": {"status": "pending", "fetched": 0, "last_key": None}}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── init ─────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def _ask_mask(repo: str, branch: str, default: str, keys=None) -> str:
|
|
79
|
+
"""Explain what each commit mask finds, try them all on the repository's last commits and
|
|
80
|
+
ask, the mask that links the most commits offered as the default."""
|
|
81
|
+
from .task_extractor import MASK_HELP, REGEX_EXAMPLES, compile_mask, preview_masks, recent_messages
|
|
82
|
+
print("\nHow is a task key written in a commit message? (commit_mask)")
|
|
83
|
+
for name, finds, example in MASK_HELP:
|
|
84
|
+
print(f" {name:<10} {finds:<42} e.g. {example}")
|
|
85
|
+
print(" or a regex of your own: the first group is the key, if there is one, e.g.")
|
|
86
|
+
for rx, what in REGEX_EXAMPLES:
|
|
87
|
+
print(f" {rx:<30} {what}")
|
|
88
|
+
if keys:
|
|
89
|
+
print(f" regex-key here: the key{'s' if len(keys) > 1 else ''} {', '.join(keys)}")
|
|
90
|
+
messages = recent_messages(repo, 300, branch)
|
|
91
|
+
if messages:
|
|
92
|
+
rows = preview_masks(messages, keys=keys)
|
|
93
|
+
print(f"\nOn the last {len(messages)} commits of {repo}:")
|
|
94
|
+
for r in rows:
|
|
95
|
+
ex = ", ".join(r["examples"]) if r["examples"] else "-"
|
|
96
|
+
print(f" {r['mask']:<10} links {r['linked']:>4} ({100 * r['linked'] / r['total']:.0f}%) {ex}")
|
|
97
|
+
best = max(rows, key=lambda r: r["linked"])
|
|
98
|
+
if best["linked"]:
|
|
99
|
+
default = best["mask"]
|
|
100
|
+
else:
|
|
101
|
+
print(f"\n(no git history read from {repo} — cannot try the masks on it)")
|
|
102
|
+
while True:
|
|
103
|
+
mask = input(f"Commit mask [Enter = {default}]: ").strip() or default
|
|
104
|
+
try:
|
|
105
|
+
compile_mask(mask, keys)
|
|
106
|
+
return mask
|
|
107
|
+
except ValueError as e:
|
|
108
|
+
print(f" {e}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run_init(store_dir: str = STORE, next_hint: str = "mokosh ingest"):
|
|
112
|
+
yaml_path = os.path.join(store_dir, "project.yaml")
|
|
113
|
+
if os.path.exists(yaml_path):
|
|
114
|
+
ans = input(f"{yaml_path} already exists. Overwrite? [y/N]: ").strip().lower()
|
|
115
|
+
if ans != "y":
|
|
116
|
+
print("[cancelled]")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
print("\nmokosh init\n")
|
|
120
|
+
default_name = os.path.basename(os.path.abspath("."))
|
|
121
|
+
name = input(f"Project name [{default_name}]: ").strip() or default_name
|
|
122
|
+
repo = input("Git repo path [. = current folder]: ").strip() or "."
|
|
123
|
+
branch = input("Branch [main]: ").strip() or "main"
|
|
124
|
+
since = input("Fetch history since (YYYY-MM-DD, Enter = full history): ").strip()
|
|
125
|
+
|
|
126
|
+
print()
|
|
127
|
+
tracker = input("Task tracker? [jira/github/youtrack/gitlab, Enter = none]: ").strip().lower() or "none"
|
|
128
|
+
|
|
129
|
+
cfg: dict = {
|
|
130
|
+
"project": {"name": name, "db": "units.db"},
|
|
131
|
+
"git": {"repo": repo, "branch": branch},
|
|
132
|
+
"ingest": {"batch_size": 100, "rate_limit_delay": 1.0},
|
|
133
|
+
}
|
|
134
|
+
if since:
|
|
135
|
+
cfg["git"]["since"] = since
|
|
136
|
+
|
|
137
|
+
if tracker == "jira":
|
|
138
|
+
jira_url = input("Jira URL: ").strip()
|
|
139
|
+
jira_proj = input("Jira project key (e.g. KAFKA): ").strip()
|
|
140
|
+
jira_conn = input("Connector [api/html/selenium, Enter = api]: ").strip() or "api"
|
|
141
|
+
jira_token = input("Token (Enter to skip for public instances): ").strip()
|
|
142
|
+
tasks_cfg: dict = {"source": "jira", "jira_url": jira_url,
|
|
143
|
+
"jira_project": jira_proj, "jira_connector": jira_conn}
|
|
144
|
+
if jira_token:
|
|
145
|
+
tasks_cfg["jira_token"] = jira_token
|
|
146
|
+
cfg["tasks"] = tasks_cfg
|
|
147
|
+
elif tracker == "github":
|
|
148
|
+
from .trackers.github import URL_HELP, parse_repo_url
|
|
149
|
+
while True: # one URL, owner and repo parsed out of it
|
|
150
|
+
try:
|
|
151
|
+
gh_owner, gh_repo = parse_repo_url(input(f"GitHub repo URL ({URL_HELP}): "))
|
|
152
|
+
break
|
|
153
|
+
except ValueError as e:
|
|
154
|
+
print(f" {e}")
|
|
155
|
+
gh_token = input("Token (Enter to skip, but only 60 req/h without): ").strip()
|
|
156
|
+
tasks_cfg = {"source": "github", "github_owner": gh_owner, "github_repo": gh_repo}
|
|
157
|
+
if gh_token:
|
|
158
|
+
tasks_cfg["github_token"] = gh_token
|
|
159
|
+
cfg["tasks"] = tasks_cfg
|
|
160
|
+
elif tracker == "youtrack":
|
|
161
|
+
yt_url = input("YouTrack URL [https://youtrack.jetbrains.com]: ").strip() or "https://youtrack.jetbrains.com"
|
|
162
|
+
yt_proj = input("YouTrack project key (e.g. KT): ").strip()
|
|
163
|
+
yt_token = input("Token (Enter to skip for public instances): ").strip()
|
|
164
|
+
tasks_cfg = {"source": "youtrack", "youtrack_url": yt_url, "youtrack_project": yt_proj}
|
|
165
|
+
if yt_token:
|
|
166
|
+
tasks_cfg["youtrack_token"] = yt_token
|
|
167
|
+
cfg["tasks"] = tasks_cfg
|
|
168
|
+
elif tracker == "gitlab":
|
|
169
|
+
gl_url = input("GitLab URL [https://gitlab.com]: ").strip() or "https://gitlab.com"
|
|
170
|
+
gl_proj = input("GitLab project (org/repo): ").strip()
|
|
171
|
+
gl_token = input("Token (required for comments, Enter to skip): ").strip()
|
|
172
|
+
tasks_cfg = {"source": "gitlab", "gitlab_url": gl_url, "gitlab_project": gl_proj}
|
|
173
|
+
if gl_token:
|
|
174
|
+
tasks_cfg["gitlab_token"] = gl_token
|
|
175
|
+
cfg["tasks"] = tasks_cfg
|
|
176
|
+
|
|
177
|
+
if "tasks" in cfg: # how commits are linked to tasks — for every tracker
|
|
178
|
+
from .task_extractor import project_keys
|
|
179
|
+
keys = project_keys(cfg["tasks"])
|
|
180
|
+
cfg["tasks"]["commit_mask"] = _ask_mask(repo, branch,
|
|
181
|
+
"generic" if tracker in ("github", "gitlab") else
|
|
182
|
+
"regex-key" if keys else "simple", keys)
|
|
183
|
+
# a token goes to an environment variable; project.yaml keeps ${VAR}
|
|
184
|
+
from .tokens import secure
|
|
185
|
+
for note in secure(cfg["tasks"], name):
|
|
186
|
+
print(f" {note}")
|
|
187
|
+
|
|
188
|
+
os.makedirs(store_dir, exist_ok=True)
|
|
189
|
+
with open(yaml_path, "w", encoding="utf-8") as f:
|
|
190
|
+
_yaml().dump(cfg, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
191
|
+
|
|
192
|
+
print(f"\nCreated {yaml_path}")
|
|
193
|
+
from .ui.server import remember # mokosh ui lists the project from any folder
|
|
194
|
+
remember(os.path.dirname(os.path.abspath(store_dir)))
|
|
195
|
+
print(f"Mode: {'full (git + tasks)' if 'tasks' in cfg else 'commits-only'}")
|
|
196
|
+
print(f"Next: {next_hint}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── ingest ───────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
def run_ingest(store_dir: str, phase: str | None = None, force: bool = False) -> str | None:
|
|
202
|
+
"""Commits (phase git), then task keys from commit messages and the tasks' details
|
|
203
|
+
(phase tasks). Always incremental: run it again a month later and it adds the new
|
|
204
|
+
commits and fetches only the tasks that have no details yet — the tracker is the slow
|
|
205
|
+
part, and it is asked about each task once. An interrupted fetch resumes the same way.
|
|
206
|
+
force: link every commit to a task key again, not only the new ones (after a change of
|
|
207
|
+
commit_mask). <store>/ingest_state.yaml keeps what each run added. -> the db path."""
|
|
208
|
+
cfg = load_project(store_dir)
|
|
209
|
+
if cfg is None:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
db_path = cfg["project"]["db"]
|
|
213
|
+
git_cfg = cfg.get("git", {})
|
|
214
|
+
tasks_cfg = cfg.get("tasks")
|
|
215
|
+
ingest_cfg = cfg.get("ingest", {})
|
|
216
|
+
has_tasks = tasks_cfg is not None
|
|
217
|
+
|
|
218
|
+
state_path = os.path.join(store_dir, "ingest_state.yaml")
|
|
219
|
+
state = _load_state(state_path) or _fresh_state()
|
|
220
|
+
|
|
221
|
+
def save_state(updated_tasks_state=None):
|
|
222
|
+
if updated_tasks_state is not None:
|
|
223
|
+
state["tasks"] = updated_tasks_state
|
|
224
|
+
_save_state(state_path, state)
|
|
225
|
+
|
|
226
|
+
from .db_manager import DatabaseManager
|
|
227
|
+
from .git_connector import GitConnector
|
|
228
|
+
|
|
229
|
+
db = DatabaseManager(db_path)
|
|
230
|
+
db.create_tables(has_tasks=has_tasks)
|
|
231
|
+
db.ensure_indexes()
|
|
232
|
+
now = lambda: _dt.datetime.now().isoformat(timespec="seconds")
|
|
233
|
+
|
|
234
|
+
run_git = phase is None or phase == "git"
|
|
235
|
+
if run_git:
|
|
236
|
+
# before any new row: an older mokosh wrote DIFF commit -> parent; new rows are parent -> commit
|
|
237
|
+
turned = db.fix_diff_direction(GitConnector(git_cfg.get("repo", ".")).root_shas())
|
|
238
|
+
if turned:
|
|
239
|
+
print(f"\n DIFF turned the right way (parent -> commit) in {turned} rows written by an older mokosh")
|
|
240
|
+
print("\n[1/2] Extracting new commits from the Git repository...")
|
|
241
|
+
g = GitConnector(git_cfg.get("repo", ".")).extract_commits(
|
|
242
|
+
db, branch=git_cfg.get("branch", "main"), since=git_cfg.get("since"))
|
|
243
|
+
state["git"] = {"status": "done", "completed_at": now(), "head": g["head"],
|
|
244
|
+
"last_run": {k: g[k] for k in ("commits_new", "rows_new", "skipped_known")}}
|
|
245
|
+
save_state()
|
|
246
|
+
print(f" {g['commits_new']} new commits ({g['rows_new']} commit-file rows), "
|
|
247
|
+
f"{g['skipped_known']} already in the database. Total rows: {db.commit_count()}")
|
|
248
|
+
filled = GitConnector(git_cfg.get("repo", ".")).fill_change_types(db)
|
|
249
|
+
if filled:
|
|
250
|
+
print(f" CHANGE_TYPE (A/M/D/R) filled for {filled} rows stored before it existed")
|
|
251
|
+
else:
|
|
252
|
+
print("[1/2] Git phase skipped (--phase tasks).")
|
|
253
|
+
|
|
254
|
+
run_tasks = phase is None or phase == "tasks"
|
|
255
|
+
if run_tasks and has_tasks:
|
|
256
|
+
print("\n[2/2] Linking commits to task keys...")
|
|
257
|
+
from .task_extractor import TaskExtractor
|
|
258
|
+
from .task_fetcher import TaskFetcher
|
|
259
|
+
|
|
260
|
+
from .task_extractor import project_keys
|
|
261
|
+
extractor = TaskExtractor(tasks_cfg.get("commit_mask", "simple"), project_keys(tasks_cfg))
|
|
262
|
+
if force:
|
|
263
|
+
rows = db.get_commits_for_extraction(only_unlinked=False)
|
|
264
|
+
pairs = [(extractor.extract_task_name(m.decode("utf-8", "ignore") if isinstance(m, bytes) else m), i)
|
|
265
|
+
for i, m in rows]
|
|
266
|
+
db.update_task_names_batch([(n, i) for n, i in pairs if n])
|
|
267
|
+
db.insert_tasks_batch(db.get_distinct_task_names())
|
|
268
|
+
print(f" --force: re-linked all {len(rows)} commit rows")
|
|
269
|
+
x = extractor.process_all_commits(db)
|
|
270
|
+
print(f" {x['rows_linked']} of {x['rows_checked']} unlinked rows linked, {x['tasks_new']} new task keys")
|
|
271
|
+
|
|
272
|
+
print(" Fetching details of the tasks that have none yet...")
|
|
273
|
+
tracker_cfg = {k: _resolve_env_token(v) if isinstance(v, str) else v for k, v in tasks_cfg.items()}
|
|
274
|
+
fetcher = TaskFetcher(tracker_type=tasks_cfg["source"], tracker_config=tracker_cfg)
|
|
275
|
+
tasks_state = state.get("tasks") or {"fetched": 0, "last_key": None}
|
|
276
|
+
f = fetcher.fetch_all_tasks(
|
|
277
|
+
db,
|
|
278
|
+
rate_limit_delay=float(ingest_cfg.get("rate_limit_delay", 1.0)),
|
|
279
|
+
batch_size=int(ingest_cfg.get("batch_size", 100)),
|
|
280
|
+
state=tasks_state,
|
|
281
|
+
state_saver=lambda s: save_state(s),
|
|
282
|
+
) or {}
|
|
283
|
+
state["tasks"] = {**tasks_state, "status": "stopped" if f.get("stopped") else "done", "completed_at": now(),
|
|
284
|
+
"last_run": {"tasks_new": x["tasks_new"], **f}}
|
|
285
|
+
save_state()
|
|
286
|
+
print(f" {f.get('fetched', 0)} fetched, {f.get('failed', 0)} failed, "
|
|
287
|
+
f"{f.get('empty', 0)} empty answers (all tried again next time)")
|
|
288
|
+
if f.get("stopped"):
|
|
289
|
+
src = tasks_cfg["source"]
|
|
290
|
+
where = {"github": "github_owner / github_repo (the <USER> / <REPO> of https://github.com/<USER>/<REPO>)",
|
|
291
|
+
"gitlab": "gitlab_url / gitlab_project (org/repo)", "jira": "jira_url",
|
|
292
|
+
"youtrack": "youtrack_url"}.get(src, "the tracker settings")
|
|
293
|
+
print(f" STOPPED: the tracker answered nothing for the first {f['empty']} tasks. "
|
|
294
|
+
f"Check {where} and the token in {os.path.join(store_dir, 'project.yaml')}, "
|
|
295
|
+
f"then run `mokosh ingest --phase tasks` again.")
|
|
296
|
+
elif run_tasks and not has_tasks:
|
|
297
|
+
print("[2/2] No task tracker configured — commits-only mode.")
|
|
298
|
+
else:
|
|
299
|
+
print("[2/2] Tasks phase skipped (--phase git).")
|
|
300
|
+
return db_path
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ── plaintext / export / status ──────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
def run_plaintext(db_path: str, dry_run: bool = False) -> bool:
|
|
306
|
+
from .db_manager import make_plain
|
|
307
|
+
if not os.path.isfile(db_path):
|
|
308
|
+
print(f"no such database: {db_path}")
|
|
309
|
+
return False
|
|
310
|
+
make_plain(db_path, dry_run=dry_run)
|
|
311
|
+
return True
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def run_export(db_path, out_dir, mode="all", level="desc", diff_mode="changed"):
|
|
315
|
+
from .exporter import export_units
|
|
316
|
+
result = export_units(db_path, out_dir, mode=mode, level=level, diff_mode=diff_mode)
|
|
317
|
+
if mode == "join":
|
|
318
|
+
print(f"Done. Joined: {result['joined_exported']} "
|
|
319
|
+
f"Commits included: {result['commits_included']} "
|
|
320
|
+
f"Commits orphaned (no TASK_NAME): {result['commits_orphaned']} -> {out_dir}")
|
|
321
|
+
else:
|
|
322
|
+
print(f"Done. Tasks exported: {result['tasks_exported']} "
|
|
323
|
+
f"Commits exported: {result['commits_exported']} -> {out_dir}")
|
|
324
|
+
return result
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def status(db_path: str) -> dict:
|
|
328
|
+
"""What a dataset holds: tasks, commits, (commit, file) rows, task-linked commits, files."""
|
|
329
|
+
from . import schema
|
|
330
|
+
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
331
|
+
task_t, commit_t = schema.tables(con)
|
|
332
|
+
have = schema.has_task_table(con)
|
|
333
|
+
out = {"db": db_path,
|
|
334
|
+
"tasks": con.execute(f"SELECT COUNT(*) FROM {task_t}").fetchone()[0] if have else 0,
|
|
335
|
+
"rows": con.execute(f"SELECT COUNT(*) FROM {commit_t}").fetchone()[0],
|
|
336
|
+
"commits": con.execute(f"SELECT COUNT(DISTINCT SHA) FROM {commit_t}").fetchone()[0],
|
|
337
|
+
"linked_commits": con.execute(f"SELECT COUNT(DISTINCT SHA) FROM {commit_t} "
|
|
338
|
+
"WHERE TASK_NAME IS NOT NULL AND TASK_NAME != ''").fetchone()[0],
|
|
339
|
+
"files": con.execute(f"SELECT COUNT(DISTINCT PATH) FROM {commit_t}").fetchone()[0],
|
|
340
|
+
"first": con.execute(f"SELECT MIN(CMT_DATE) FROM {commit_t}").fetchone()[0],
|
|
341
|
+
"last": con.execute(f"SELECT MAX(CMT_DATE) FROM {commit_t}").fetchone()[0]}
|
|
342
|
+
con.close()
|
|
343
|
+
return out
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _next_steps(db_path):
|
|
347
|
+
print("\nNext:")
|
|
348
|
+
print(f" simargl index units {db_path} # semantic search over the tasks")
|
|
349
|
+
print(f" gamayun index --db {db_path} --repo . --project <name> # statistical ontology")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def main(argv=None):
|
|
353
|
+
for stream in (sys.stdout, sys.stderr):
|
|
354
|
+
try:
|
|
355
|
+
stream.reconfigure(encoding="utf-8")
|
|
356
|
+
except (AttributeError, ValueError):
|
|
357
|
+
pass
|
|
358
|
+
p = argparse.ArgumentParser(prog="mokosh", description="Gather a task + commit dataset into SQLite")
|
|
359
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
360
|
+
ini = sub.add_parser("init", help="wizard: repository, branch, tracker -> .mokosh/project.yaml")
|
|
361
|
+
ini.add_argument("--store-dir", default=STORE)
|
|
362
|
+
ing = sub.add_parser("ingest", help="commits, then task references and details; run it again "
|
|
363
|
+
"to add only what is new (incremental, resumable)")
|
|
364
|
+
ing.add_argument("--phase", choices=["git", "tasks"], default=None, help="run only one phase (default: both)")
|
|
365
|
+
ing.add_argument("--force", action="store_true",
|
|
366
|
+
help="link every commit to a task key again, not only new ones (after changing commit_mask)")
|
|
367
|
+
ing.add_argument("--store-dir", default=None, help="default: .mokosh, or an earlier .simargl")
|
|
368
|
+
pt = sub.add_parser("plaintext", help="rewrite TASK title/description/comments as plain text")
|
|
369
|
+
pt.add_argument("db_path", nargs="?", default="units.db")
|
|
370
|
+
pt.add_argument("--dry-run", action="store_true", help="only count what would change")
|
|
371
|
+
exp = sub.add_parser("export", help="TASK/COMMIT rows as flat text files (for a glossary or a folder index)")
|
|
372
|
+
exp.add_argument("db_path")
|
|
373
|
+
exp.add_argument("--out", required=True, help="output directory")
|
|
374
|
+
mode = exp.add_mutually_exclusive_group()
|
|
375
|
+
mode.add_argument("--task", action="store_true", help="tasks only")
|
|
376
|
+
mode.add_argument("--commits", action="store_true", help="commits only")
|
|
377
|
+
mode.add_argument("--all", action="store_true", help="tasks/ and commits/ separately (default)")
|
|
378
|
+
mode.add_argument("--join", action="store_true", help="one file per task with its commits (joined/)")
|
|
379
|
+
exp.add_argument("--level", default="desc", choices=["title", "desc", "comments"])
|
|
380
|
+
exp.add_argument("--diff-mode", default="changed", choices=["changed", "full"])
|
|
381
|
+
fx = sub.add_parser("fixdiff", help="turn DIFF the right way (parent -> commit) in a database an older "
|
|
382
|
+
"mokosh wrote; `ingest` does it on its own")
|
|
383
|
+
fx.add_argument("db_path")
|
|
384
|
+
fx.add_argument("--repo", required=True, help="the git repository (its root commits are left as they are)")
|
|
385
|
+
ui = sub.add_parser("ui", help="web UI (Flask): the Setup form, ingest with its log, status")
|
|
386
|
+
ui.add_argument("--port", type=int, default=8093)
|
|
387
|
+
ui.add_argument("--host", default="127.0.0.1", help="0.0.0.0 to open it to the network")
|
|
388
|
+
ui.add_argument("--root", default=None, help="the folder to look for projects under (default: here)")
|
|
389
|
+
st = sub.add_parser("status", help="what a dataset holds")
|
|
390
|
+
st.add_argument("db_path", nargs="?", default=None, help="default: the db of the project in this folder")
|
|
391
|
+
st.add_argument("--store-dir", default=None)
|
|
392
|
+
a = p.parse_args(argv)
|
|
393
|
+
|
|
394
|
+
if a.cmd == "init":
|
|
395
|
+
run_init(a.store_dir)
|
|
396
|
+
elif a.cmd == "ingest":
|
|
397
|
+
db_path = run_ingest(resolve_store(a.store_dir), a.phase, a.force)
|
|
398
|
+
if db_path is None:
|
|
399
|
+
sys.exit(1)
|
|
400
|
+
print("\nIngest complete.")
|
|
401
|
+
_next_steps(db_path)
|
|
402
|
+
elif a.cmd == "ui":
|
|
403
|
+
from .ui.server import serve
|
|
404
|
+
serve(host=a.host, port=a.port, start=a.root)
|
|
405
|
+
elif a.cmd == "fixdiff":
|
|
406
|
+
from .db_manager import DatabaseManager
|
|
407
|
+
from .git_connector import GitConnector
|
|
408
|
+
db = DatabaseManager(a.db_path)
|
|
409
|
+
roots = GitConnector(a.repo).root_shas()
|
|
410
|
+
before = db.diff_direction(roots)
|
|
411
|
+
if not roots:
|
|
412
|
+
print(f"no git history at {a.repo}")
|
|
413
|
+
sys.exit(1)
|
|
414
|
+
n = db.fix_diff_direction(roots)
|
|
415
|
+
print(f"{a.db_path}: first hunks of new files '@@ -0,0 +' {before['new_file_hunks']}, "
|
|
416
|
+
f"'+0,0 @@' {before['deleted_file_hunks']}, marker {before['marker']}")
|
|
417
|
+
print(f" {n} rows turned" if n else " already the right way — marked, nothing changed")
|
|
418
|
+
elif a.cmd == "plaintext":
|
|
419
|
+
if not run_plaintext(a.db_path, a.dry_run):
|
|
420
|
+
sys.exit(1)
|
|
421
|
+
elif a.cmd == "export":
|
|
422
|
+
m = "task" if a.task else "commits" if a.commits else "join" if a.join else "all"
|
|
423
|
+
run_export(a.db_path, a.out, mode=m, level=a.level, diff_mode=a.diff_mode)
|
|
424
|
+
elif a.cmd == "status":
|
|
425
|
+
db_path = a.db_path
|
|
426
|
+
if not db_path:
|
|
427
|
+
cfg = load_project(resolve_store(a.store_dir))
|
|
428
|
+
if cfg is None:
|
|
429
|
+
sys.exit(1)
|
|
430
|
+
db_path = cfg["project"]["db"]
|
|
431
|
+
if not os.path.isfile(db_path):
|
|
432
|
+
print(f"no such database: {db_path}")
|
|
433
|
+
sys.exit(1)
|
|
434
|
+
s = status(db_path)
|
|
435
|
+
print(f"{s['db']}\n tasks {s['tasks']} commits {s['commits']} ({s['linked_commits']} linked to a task)"
|
|
436
|
+
f" (commit, file) rows {s['rows']} files {s['files']}\n from {s['first']} to {s['last']}")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
if __name__ == "__main__":
|
|
440
|
+
main()
|