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.
Files changed (44) hide show
  1. mokosh/__init__.py +11 -0
  2. mokosh/__main__.py +3 -0
  3. mokosh/cli.py +440 -0
  4. mokosh/db_manager.py +404 -0
  5. mokosh/exporter.py +256 -0
  6. mokosh/git_connector.py +151 -0
  7. mokosh/schema.py +45 -0
  8. mokosh/task_extractor.py +141 -0
  9. mokosh/task_fetcher.py +121 -0
  10. mokosh/text.py +103 -0
  11. mokosh/tokens.py +91 -0
  12. mokosh/trackers/__init__.py +20 -0
  13. mokosh/trackers/github.py +83 -0
  14. mokosh/trackers/gitlab.py +54 -0
  15. mokosh/trackers/jira_api.py +34 -0
  16. mokosh/trackers/jira_html.py +30 -0
  17. mokosh/trackers/jira_selenium.py +41 -0
  18. mokosh/trackers/youtrack.py +49 -0
  19. mokosh/ui/__init__.py +0 -0
  20. mokosh/ui/server.py +315 -0
  21. mokosh/ui/webui/app.js +203 -0
  22. mokosh/ui/webui/i18n.js +39 -0
  23. mokosh/ui/webui/index.html +120 -0
  24. mokosh/ui/webui/lang/de.js +71 -0
  25. mokosh/ui/webui/lang/en.js +71 -0
  26. mokosh/ui/webui/lang/es.js +71 -0
  27. mokosh/ui/webui/lang/fr.js +71 -0
  28. mokosh/ui/webui/lang/pt.js +71 -0
  29. mokosh/ui/webui/lang/uk.js +71 -0
  30. mokosh/ui/webui/style.css +251 -0
  31. mokosh/ui/webui/themes/beach.css +19 -0
  32. mokosh/ui/webui/themes/dark.css +19 -0
  33. mokosh/ui/webui/themes/light.css +19 -0
  34. mokosh/ui/webui/themes/mono-dark.css +19 -0
  35. mokosh/ui/webui/themes/mono-light.css +19 -0
  36. mokosh/ui/webui/themes/ocean.css +19 -0
  37. mokosh/ui/webui/themes/soft.css +19 -0
  38. mokosh/ui/webui/themes/summer.css +19 -0
  39. mokosh-0.1.0.dist-info/METADATA +239 -0
  40. mokosh-0.1.0.dist-info/RECORD +44 -0
  41. mokosh-0.1.0.dist-info/WHEEL +5 -0
  42. mokosh-0.1.0.dist-info/entry_points.txt +2 -0
  43. mokosh-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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()