factorio-mod-workshop 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.
- factorio_mod_workshop-0.1.0.dist-info/METADATA +189 -0
- factorio_mod_workshop-0.1.0.dist-info/RECORD +20 -0
- factorio_mod_workshop-0.1.0.dist-info/WHEEL +4 -0
- factorio_mod_workshop-0.1.0.dist-info/entry_points.txt +2 -0
- factorio_mod_workshop-0.1.0.dist-info/licenses/LICENSE +617 -0
- fmw/__init__.py +3 -0
- fmw/cli.py +1431 -0
- fmw/config.py +187 -0
- fmw/discover.py +193 -0
- fmw/help.py +387 -0
- fmw/portal.py +336 -0
- fmw/store.py +141 -0
- fmw/templates/workspace/.gitignore +5 -0
- fmw/templates/workspace/README.md +31 -0
- fmw/templates/workspace/config.example.yaml +11 -0
- fmw/templates/workspace/config.yaml +11 -0
- fmw/templates/workspace/mods.yaml +6 -0
- fmw/templates/workspace/tracker/README.md +40 -0
- fmw/threadstate.py +150 -0
- fmw/tracker.py +223 -0
fmw/cli.py
ADDED
|
@@ -0,0 +1,1431 @@
|
|
|
1
|
+
"""fmw command line interface.
|
|
2
|
+
|
|
3
|
+
Every command accepts --json so an agent can consume structured output;
|
|
4
|
+
without it the output is compact human-readable text.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import NamedTuple
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from . import discover, help as helptext, store, threadstate, tracker
|
|
21
|
+
from .config import WORKSPACE_ENV, Workspace, WorkspaceError, template_dir
|
|
22
|
+
from .portal import Portal, PortalError, abbrev_tag, parse_marker
|
|
23
|
+
|
|
24
|
+
URL_RE = re.compile(r"mods\.factorio\.com/mod/([^/\s]+)(?:/discussion/([0-9a-f]{24}))?")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def out(data, as_json: bool, text: str | None = None) -> None:
|
|
28
|
+
if as_json:
|
|
29
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
30
|
+
elif text is not None:
|
|
31
|
+
print(text)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_target(value: str) -> tuple[str | None, str | None]:
|
|
35
|
+
"""Accept a mod name, a thread id, or any mods.factorio.com URL."""
|
|
36
|
+
m = URL_RE.search(value)
|
|
37
|
+
if m:
|
|
38
|
+
return m.group(1), m.group(2)
|
|
39
|
+
if re.fullmatch(r"[0-9a-f]{24}", value):
|
|
40
|
+
return None, value
|
|
41
|
+
return value, None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def with_marker(t: dict) -> dict:
|
|
45
|
+
"""Annotate a cached thread summary with its derived title marker."""
|
|
46
|
+
m = parse_marker(t.get("title", ""))
|
|
47
|
+
return {
|
|
48
|
+
**t,
|
|
49
|
+
"marker": m.marker,
|
|
50
|
+
"resolved_on_portal": m.resolved,
|
|
51
|
+
"wrong_on_portal": m.wrong,
|
|
52
|
+
"clean_title": m.clean,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Detection(NamedTuple):
|
|
57
|
+
mod: str | None
|
|
58
|
+
source: str # how it was determined, for the announcement
|
|
59
|
+
candidates: list # mods below cwd when the answer was ambiguous
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def detect_mod_from_cwd(ws: Workspace, start: Path | None = None) -> Detection:
|
|
63
|
+
"""Infer which mod the user is working on from the current directory.
|
|
64
|
+
|
|
65
|
+
Three cases, in order of confidence:
|
|
66
|
+
|
|
67
|
+
1. An info.json in the cwd or an ancestor - that is what defines a mod.
|
|
68
|
+
2. The cwd sits inside a checkout recorded by `fmi discover`.
|
|
69
|
+
3. The cwd *contains* exactly one recorded checkout. This is the normal
|
|
70
|
+
Factorio single-mod repository, where the git root wraps a directory
|
|
71
|
+
that must be named after the mod. Ambiguous when the cwd contains
|
|
72
|
+
several - a monorepo root - and then no mod is inferred.
|
|
73
|
+
"""
|
|
74
|
+
cwd = (start or Path.cwd()).resolve()
|
|
75
|
+
for d in [cwd, *cwd.parents]:
|
|
76
|
+
info = d / "info.json"
|
|
77
|
+
if info.is_file():
|
|
78
|
+
try:
|
|
79
|
+
name = json.loads(info.read_text()).get("name")
|
|
80
|
+
except (json.JSONDecodeError, UnicodeDecodeError, OSError):
|
|
81
|
+
name = None
|
|
82
|
+
if name:
|
|
83
|
+
return Detection(name, str(info), [])
|
|
84
|
+
|
|
85
|
+
paths = {name: Path(p).resolve() for name, p in ws.load_paths().items() if p}
|
|
86
|
+
for name, path in paths.items():
|
|
87
|
+
if cwd == path or path in cwd.parents:
|
|
88
|
+
return Detection(name, str(path), [])
|
|
89
|
+
|
|
90
|
+
below = sorted(name for name, path in paths.items() if cwd in path.parents)
|
|
91
|
+
if len(below) == 1:
|
|
92
|
+
return Detection(below[0], f"the only mod under {cwd}", below)
|
|
93
|
+
return Detection(None, "", below)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def announce(detection: Detection, stream=None) -> None:
|
|
97
|
+
"""Say which scope was chosen, including when nothing was inferred.
|
|
98
|
+
|
|
99
|
+
Silence on the fallback path is how a plausible answer about the wrong
|
|
100
|
+
scope goes unnoticed, so both branches report.
|
|
101
|
+
"""
|
|
102
|
+
stream = stream or sys.stderr
|
|
103
|
+
if detection.mod:
|
|
104
|
+
print(f"(using {detection.mod}, detected from {detection.source})", file=stream)
|
|
105
|
+
elif detection.candidates:
|
|
106
|
+
print(f"(cwd contains {len(detection.candidates)} mods - too ambiguous to narrow; "
|
|
107
|
+
"name one explicitly)", file=stream)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def open_ws(args) -> Workspace:
|
|
111
|
+
try:
|
|
112
|
+
return Workspace.open(getattr(args, "workspace", None))
|
|
113
|
+
except WorkspaceError as exc:
|
|
114
|
+
sys.exit(str(exc))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_mods(ws: Workspace, names: list[str], all_flag: bool, quiet: bool = False) -> list[str]:
|
|
118
|
+
registry = ws.load_mods()["mods"]
|
|
119
|
+
if not names and not all_flag:
|
|
120
|
+
# Working inside a mod checkout is a clear enough signal to act on just
|
|
121
|
+
# that mod. Always say which way it went, so neither the narrow scope
|
|
122
|
+
# nor the fallback to every mod is a surprise.
|
|
123
|
+
detection = detect_mod_from_cwd(ws)
|
|
124
|
+
if not quiet:
|
|
125
|
+
announce(detection)
|
|
126
|
+
if detection.mod:
|
|
127
|
+
return [detection.mod]
|
|
128
|
+
if all_flag or not names:
|
|
129
|
+
chosen = [n for n, m in registry.items() if m.get("on_portal") is not False]
|
|
130
|
+
if not all_flag and not names:
|
|
131
|
+
chosen = [n for n in chosen if registry[n].get("maintained") != "frozen"]
|
|
132
|
+
return chosen
|
|
133
|
+
resolved = []
|
|
134
|
+
for n in names:
|
|
135
|
+
mod, _ = parse_target(n)
|
|
136
|
+
if mod:
|
|
137
|
+
resolved.append(mod)
|
|
138
|
+
return resolved
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ----------------------------------------------------------------------
|
|
142
|
+
# commands
|
|
143
|
+
# ----------------------------------------------------------------------
|
|
144
|
+
def cmd_init(args) -> None:
|
|
145
|
+
path = Path(args.path).expanduser().resolve()
|
|
146
|
+
copied, skipped = [], []
|
|
147
|
+
with template_dir() as template:
|
|
148
|
+
if not template.is_dir():
|
|
149
|
+
sys.exit(f"Bundled template directory is missing or unreadable: {template}")
|
|
150
|
+
for src in sorted(template.rglob("*")):
|
|
151
|
+
if src.is_dir():
|
|
152
|
+
continue
|
|
153
|
+
rel = src.relative_to(template)
|
|
154
|
+
dst = path / rel
|
|
155
|
+
if dst.exists():
|
|
156
|
+
skipped.append(str(rel))
|
|
157
|
+
continue
|
|
158
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
shutil.copy2(src, dst)
|
|
160
|
+
copied.append(str(rel))
|
|
161
|
+
if not copied and not skipped:
|
|
162
|
+
sys.exit("Bundled template directory is empty - the installation is broken.")
|
|
163
|
+
for sub in ("cache", "tracker", "notes"):
|
|
164
|
+
(path / sub).mkdir(parents=True, exist_ok=True)
|
|
165
|
+
|
|
166
|
+
if args.json:
|
|
167
|
+
out({"workspace": str(path), "copied": copied, "kept": skipped}, True)
|
|
168
|
+
return
|
|
169
|
+
print(f"Workspace ready at {path}")
|
|
170
|
+
for rel in copied:
|
|
171
|
+
print(f" + {rel}")
|
|
172
|
+
for rel in skipped:
|
|
173
|
+
print(f" = {rel} (kept, already present)")
|
|
174
|
+
print("\nNext:")
|
|
175
|
+
print(f" fmw launcher --workspace {path} # one-time: puts `fmw` on your PATH, pinned to this workspace")
|
|
176
|
+
print(f" $EDITOR {path / 'config.yaml'} # set mod_repos")
|
|
177
|
+
print(" fmw discover")
|
|
178
|
+
if not (path / ".git").exists():
|
|
179
|
+
print("\nTip: `git init` inside it to version your mod data separately.")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
WINDOWS = os.name == "nt"
|
|
183
|
+
|
|
184
|
+
# Pins a workspace so `fmw` runs from anywhere - typically from inside a mod
|
|
185
|
+
# repository - without setting FMW_WORKSPACE by hand. A value already in the
|
|
186
|
+
# environment still wins, so one-off overrides keep working.
|
|
187
|
+
LAUNCHER_SH = """#!/usr/bin/env sh
|
|
188
|
+
# Generated by `fmw launcher`. Regenerate if either directory moves.
|
|
189
|
+
# POSIX sh, so it works under bash, zsh, dash, ash and friends.
|
|
190
|
+
set -e
|
|
191
|
+
|
|
192
|
+
: "${{FMW_WORKSPACE:={workspace}}}"
|
|
193
|
+
export FMW_WORKSPACE
|
|
194
|
+
|
|
195
|
+
exec {runner} "$@"
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
LAUNCHER_CMD = """@echo off
|
|
199
|
+
rem Generated by `fmw launcher`. Regenerate if either directory moves.
|
|
200
|
+
if not defined FMW_WORKSPACE set "FMW_WORKSPACE={workspace}"
|
|
201
|
+
{runner} %*
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _path_dirs() -> list[Path]:
|
|
206
|
+
return [Path(p).expanduser() for p in os.environ.get("PATH", "").split(os.pathsep) if p]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _bin_candidates() -> list[Path]:
|
|
210
|
+
"""Conventional per-user executable directories for this platform."""
|
|
211
|
+
if WINDOWS:
|
|
212
|
+
# Where `uv tool install` places shims, plus the Unix-style fallback
|
|
213
|
+
# uv also honours.
|
|
214
|
+
appdata = os.environ.get("APPDATA")
|
|
215
|
+
dirs = [Path(appdata) / "uv" / "bin"] if appdata else []
|
|
216
|
+
return dirs + [Path.home() / ".local" / "bin"]
|
|
217
|
+
return [Path.home() / ".local" / "bin", Path.home() / "bin"]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _default_bin_dir() -> tuple[Path, bool]:
|
|
221
|
+
"""Pick a directory to install the launcher into, and whether it is on PATH."""
|
|
222
|
+
on_path = _path_dirs()
|
|
223
|
+
for candidate in _bin_candidates():
|
|
224
|
+
if candidate in on_path:
|
|
225
|
+
return candidate, True
|
|
226
|
+
return _bin_candidates()[0], False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _path_hint(directory: Path) -> str:
|
|
230
|
+
"""How to put `directory` on PATH, in the user's actual shell."""
|
|
231
|
+
if WINDOWS:
|
|
232
|
+
return f' setx PATH "%PATH%;{directory}"'
|
|
233
|
+
shell = Path(os.environ.get("SHELL", "sh")).name
|
|
234
|
+
profile = {
|
|
235
|
+
"zsh": "~/.zshrc",
|
|
236
|
+
"bash": "~/.bashrc",
|
|
237
|
+
"fish": "~/.config/fish/config.fish",
|
|
238
|
+
}.get(shell, "your shell profile")
|
|
239
|
+
if shell == "fish":
|
|
240
|
+
return f" fish_add_path {directory}"
|
|
241
|
+
return f" echo 'export PATH=\"{directory}:$PATH\"' >> {profile}"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _is_ephemeral_install(path: Path) -> bool:
|
|
245
|
+
"""True if the interpreter lives in a throwaway environment.
|
|
246
|
+
|
|
247
|
+
`uvx` builds the package into ~/.cache/uv/archive-v0/<hash>/ and that
|
|
248
|
+
directory is removed by `uv cache prune`, so baking its path into a
|
|
249
|
+
launcher produces a wrapper that works until the next cache cleanup.
|
|
250
|
+
"""
|
|
251
|
+
return "archive-v0" in path.parts
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _under_uv_tool_dir(path: Path) -> bool:
|
|
255
|
+
"""True if `path` belongs to a `uv tool install` environment."""
|
|
256
|
+
roots = []
|
|
257
|
+
env = os.environ.get("UV_TOOL_DIR")
|
|
258
|
+
if env:
|
|
259
|
+
roots.append(Path(env).expanduser())
|
|
260
|
+
if WINDOWS:
|
|
261
|
+
appdata = os.environ.get("APPDATA")
|
|
262
|
+
if appdata:
|
|
263
|
+
roots.append(Path(appdata) / "uv" / "tools")
|
|
264
|
+
else:
|
|
265
|
+
roots.append(Path.home() / ".local" / "share" / "uv" / "tools")
|
|
266
|
+
for root in roots:
|
|
267
|
+
try:
|
|
268
|
+
path.relative_to(root.resolve())
|
|
269
|
+
return True
|
|
270
|
+
except (ValueError, OSError):
|
|
271
|
+
continue
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def cmd_launcher(args) -> None:
|
|
276
|
+
ws = open_ws(args)
|
|
277
|
+
venv_bin = Path(sys.executable).parent
|
|
278
|
+
if _is_ephemeral_install(venv_bin):
|
|
279
|
+
sys.exit(
|
|
280
|
+
f"Refusing to write a launcher pointing at {venv_bin}.\n"
|
|
281
|
+
"This looks like an ephemeral `uvx` environment, which `uv cache prune` "
|
|
282
|
+
"deletes - the launcher would break silently.\n"
|
|
283
|
+
"For a persistent install use: uv tool install "
|
|
284
|
+
"git+https://github.com/AivanF/factorio-mod-workshop\n"
|
|
285
|
+
f"then set the workspace with: export FMW_WORKSPACE={ws.root}"
|
|
286
|
+
)
|
|
287
|
+
console = venv_bin / ("fmw.exe" if WINDOWS else "fmw")
|
|
288
|
+
runner = f'"{console}"' if console.exists() else f'"{sys.executable}" -m fmw.cli'
|
|
289
|
+
|
|
290
|
+
warn_path = False
|
|
291
|
+
if args.out:
|
|
292
|
+
dest = Path(args.out).expanduser()
|
|
293
|
+
else:
|
|
294
|
+
bin_dir, on_path = _default_bin_dir()
|
|
295
|
+
warn_path = not on_path
|
|
296
|
+
dest = bin_dir / (f"{args.name}.cmd" if WINDOWS else args.name)
|
|
297
|
+
|
|
298
|
+
if dest.exists() and not args.force:
|
|
299
|
+
extra = ""
|
|
300
|
+
if dest.is_symlink() and _under_uv_tool_dir(dest.resolve()):
|
|
301
|
+
extra = (
|
|
302
|
+
"\nThat is the shim from `uv tool install`, so `fmw` already works everywhere; "
|
|
303
|
+
f"you only need the workspace:\n export FMW_WORKSPACE={ws.root}\n"
|
|
304
|
+
"Or install this wrapper under another name with --name."
|
|
305
|
+
)
|
|
306
|
+
sys.exit(
|
|
307
|
+
f"{dest} already exists. Pass --force to overwrite, or --out/--name to "
|
|
308
|
+
f"choose another.{extra}"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
template = LAUNCHER_CMD if WINDOWS else LAUNCHER_SH
|
|
313
|
+
dest.write_text(template.format(workspace=ws.root, runner=runner))
|
|
314
|
+
if not WINDOWS:
|
|
315
|
+
dest.chmod(0o755)
|
|
316
|
+
|
|
317
|
+
if args.json:
|
|
318
|
+
out({"launcher": str(dest), "workspace": str(ws.root), "runner": runner,
|
|
319
|
+
"on_path": not warn_path}, True)
|
|
320
|
+
return
|
|
321
|
+
print(f"Installed {dest}")
|
|
322
|
+
print(f" workspace: {ws.root}")
|
|
323
|
+
print(f" runner: {runner}")
|
|
324
|
+
if warn_path:
|
|
325
|
+
print(f"\n{dest.parent} is not on your PATH. Add it:")
|
|
326
|
+
print(_path_hint(dest.parent))
|
|
327
|
+
else:
|
|
328
|
+
print(f"\n`{dest.name}` is now runnable from any directory.")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# Files `fmw init` copies that stay owned by the tool: they document the current
|
|
332
|
+
# schema, so a workspace created before a schema change keeps teaching the old
|
|
333
|
+
# one. config.yaml is deliberately absent - it becomes yours on first edit.
|
|
334
|
+
TEMPLATE_MANAGED = ("README.md", "tracker/README.md", "config.example.yaml", ".gitignore")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def outdated_template_files(ws) -> list[str]:
|
|
338
|
+
stale = []
|
|
339
|
+
with template_dir() as template:
|
|
340
|
+
if not template.is_dir():
|
|
341
|
+
return []
|
|
342
|
+
for rel in TEMPLATE_MANAGED:
|
|
343
|
+
src, dst = template / rel, ws.root / rel
|
|
344
|
+
if not src.is_file():
|
|
345
|
+
continue
|
|
346
|
+
if not dst.exists() or dst.read_text() != src.read_text():
|
|
347
|
+
stale.append(rel)
|
|
348
|
+
return stale
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def cmd_upgrade(args) -> None:
|
|
352
|
+
"""Refresh the tool-owned documentation inside a workspace.
|
|
353
|
+
|
|
354
|
+
Only files listed in TEMPLATE_MANAGED are touched; your registry, tickets,
|
|
355
|
+
thread judgements, cache and config.yaml are never rewritten.
|
|
356
|
+
"""
|
|
357
|
+
ws = open_ws(args)
|
|
358
|
+
changed = []
|
|
359
|
+
with template_dir() as template:
|
|
360
|
+
if not template.is_dir():
|
|
361
|
+
sys.exit(f"Bundled templates not found: {template}")
|
|
362
|
+
for rel in TEMPLATE_MANAGED:
|
|
363
|
+
src, dst = template / rel, ws.root / rel
|
|
364
|
+
if not src.is_file():
|
|
365
|
+
continue
|
|
366
|
+
new_text = src.read_text()
|
|
367
|
+
if dst.exists() and dst.read_text() == new_text:
|
|
368
|
+
continue
|
|
369
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
370
|
+
action = "updated" if dst.exists() else "added"
|
|
371
|
+
if not args.dry_run:
|
|
372
|
+
dst.write_text(new_text)
|
|
373
|
+
changed.append(f"{action} {rel}")
|
|
374
|
+
# mods.yaml: bring `maintained` onto the current vocabulary.
|
|
375
|
+
registry = ws.load_mods() # load_mods already normalises on read
|
|
376
|
+
raw = yaml.safe_load(ws.mods_file.read_text()) if ws.mods_file.exists() else None
|
|
377
|
+
if raw:
|
|
378
|
+
for name, entry in (raw.get("mods") or {}).items():
|
|
379
|
+
before = entry.get("maintained")
|
|
380
|
+
after = discover.normalise_maintained(before)
|
|
381
|
+
if before != after:
|
|
382
|
+
changed.append(f"maintained: {name} {before!r} -> {after}")
|
|
383
|
+
if not args.dry_run and any(c.startswith("maintained:") for c in changed):
|
|
384
|
+
ws.save_mods(registry)
|
|
385
|
+
|
|
386
|
+
ticket_changes = migrate_tickets(ws, dry_run=args.dry_run)
|
|
387
|
+
changed += [f"ticket {old} -> {new}" for old, new in ticket_changes]
|
|
388
|
+
|
|
389
|
+
if args.json:
|
|
390
|
+
out({"changed": changed, "dry_run": args.dry_run}, True)
|
|
391
|
+
return
|
|
392
|
+
for c in changed:
|
|
393
|
+
print(f" {c}")
|
|
394
|
+
if not changed:
|
|
395
|
+
print("Workspace is already current.")
|
|
396
|
+
elif args.dry_run:
|
|
397
|
+
print(f"{len(changed)} change(s) pending. Re-run without --dry-run.")
|
|
398
|
+
else:
|
|
399
|
+
print(f"{len(changed)} change(s) applied. Your tickets, judgements and cache "
|
|
400
|
+
"keep their content.")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def cmd_repo(args) -> None:
|
|
404
|
+
"""Manage the directories `fmw discover` looks in."""
|
|
405
|
+
ws = open_ws(args)
|
|
406
|
+
if args.repo_cmd == "add":
|
|
407
|
+
path = Path(args.path).expanduser()
|
|
408
|
+
if not path.is_dir():
|
|
409
|
+
sys.exit(f"Not a directory: {path}")
|
|
410
|
+
found, _ = discover.scan_local([path])
|
|
411
|
+
repos = ws.edit_mod_repos(add=args.path)
|
|
412
|
+
if args.json:
|
|
413
|
+
out({"repos": repos, "found": [e["name"] for e in found]}, True)
|
|
414
|
+
return
|
|
415
|
+
print(f"Added {args.path} ({len(found)} mod(s) found)")
|
|
416
|
+
for e in found:
|
|
417
|
+
print(f" {e['name']} {e['local_version']}")
|
|
418
|
+
print("\nRun `fmw discover` to add them to the registry.")
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
if args.repo_cmd == "remove":
|
|
422
|
+
before = ws.mod_repos()
|
|
423
|
+
repos = ws.edit_mod_repos(remove=args.path)
|
|
424
|
+
if args.json:
|
|
425
|
+
out({"repos": repos, "removed": len(before) - len(repos)}, True)
|
|
426
|
+
return
|
|
427
|
+
if len(repos) == len(before):
|
|
428
|
+
print(f"{args.path} was not listed. Current repositories:")
|
|
429
|
+
for r in repos:
|
|
430
|
+
print(f" {r}")
|
|
431
|
+
else:
|
|
432
|
+
print(f"Removed {args.path}. Registry entries are kept - "
|
|
433
|
+
"delete them from mods.yaml if you want them gone.")
|
|
434
|
+
return
|
|
435
|
+
|
|
436
|
+
repos = ws.mod_repos()
|
|
437
|
+
paths = ws.load_paths()
|
|
438
|
+
if args.json:
|
|
439
|
+
out({"repos": [
|
|
440
|
+
{"path": r,
|
|
441
|
+
"mods": sorted(n for n, p in paths.items()
|
|
442
|
+
if str(Path(p)).startswith(str(Path(r).expanduser().resolve())))}
|
|
443
|
+
for r in repos]}, True)
|
|
444
|
+
return
|
|
445
|
+
if not repos:
|
|
446
|
+
print("No repositories configured. Add one with `fmw repo add <path>`.")
|
|
447
|
+
return
|
|
448
|
+
for r in repos:
|
|
449
|
+
root = str(Path(r).expanduser().resolve())
|
|
450
|
+
mods = sorted(n for n, p in paths.items() if str(Path(p)).startswith(root))
|
|
451
|
+
print(f"{r}")
|
|
452
|
+
print(f" {len(mods)} mod(s): {', '.join(mods) if mods else '- run `fmw discover`'}")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def cmd_discover(args) -> None:
|
|
456
|
+
ws = open_ws(args)
|
|
457
|
+
roots = [Path(r) for r in args.scan] if args.scan else []
|
|
458
|
+
if not roots:
|
|
459
|
+
cfg = ws.load_config()
|
|
460
|
+
roots = [Path(r) for r in (cfg.get("mod_repos") or [])]
|
|
461
|
+
if not roots:
|
|
462
|
+
sys.exit("Nothing to scan. Pass --scan DIR or set mod_repos in workspace/config.yaml")
|
|
463
|
+
|
|
464
|
+
entries, warnings = discover.scan_local(roots, max_depth=args.max_depth)
|
|
465
|
+
if not args.no_portal:
|
|
466
|
+
with Portal() as portal:
|
|
467
|
+
warnings += discover.enrich_from_portal(portal, entries)
|
|
468
|
+
|
|
469
|
+
paths = discover.sync_paths(ws, entries)
|
|
470
|
+
registry = discover.build_registry(ws.load_mods(), entries)
|
|
471
|
+
discover.strip_legacy_paths(registry)
|
|
472
|
+
ws.save_mods(registry)
|
|
473
|
+
|
|
474
|
+
if args.json:
|
|
475
|
+
out({"found": len(entries), "warnings": warnings,
|
|
476
|
+
"mods": registry["mods"], "paths": paths}, True)
|
|
477
|
+
return
|
|
478
|
+
print(f"Registered {len(entries)} mods in {ws.mods_file}")
|
|
479
|
+
print(f"Local checkout paths in {ws.paths_file}")
|
|
480
|
+
for w in warnings:
|
|
481
|
+
print(f" ! {w}")
|
|
482
|
+
print("Review `maintained:` (active/frozen/unknown) and `notes:` for each mod.")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def cmd_mods(args) -> None:
|
|
486
|
+
ws = open_ws(args)
|
|
487
|
+
mods = ws.load_mods()["mods"]
|
|
488
|
+
if args.maintained:
|
|
489
|
+
mods = {k: v for k, v in mods.items() if v.get("maintained") == args.maintained}
|
|
490
|
+
if args.json:
|
|
491
|
+
paths = ws.load_paths()
|
|
492
|
+
out({k: {**v, "path": paths.get(k, "")} for k, v in mods.items()}, True)
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
def cell(value) -> str:
|
|
496
|
+
return str(value) if value not in (None, "", 0) else "-"
|
|
497
|
+
|
|
498
|
+
# Single-row header: a two-row one with spanning labels falls apart as soon
|
|
499
|
+
# as the terminal wraps. Abbreviations are expanded in the legend below.
|
|
500
|
+
print(
|
|
501
|
+
f"{'':<2}{'mod':<38} {'maint':<8} {'v.loc':<9} {'v.mp':<9} "
|
|
502
|
+
f"{'F.loc':<6} {'F.mp':<6} {'dl':>9}"
|
|
503
|
+
)
|
|
504
|
+
differing = 0
|
|
505
|
+
for name, m in mods.items():
|
|
506
|
+
loc, mp = m.get("local_version"), m.get("portal_version")
|
|
507
|
+
# Only flag it. Why the two differ is not inferable: unreleased work,
|
|
508
|
+
# an out-of-date checkout, or a deliberate local edit all look alike.
|
|
509
|
+
mark = ""
|
|
510
|
+
if loc and mp and loc != mp:
|
|
511
|
+
mark = "~"
|
|
512
|
+
differing += 1
|
|
513
|
+
print(
|
|
514
|
+
f"{mark:<2}{name:<38} {str(m.get('maintained', '?')):<8} "
|
|
515
|
+
f"{cell(loc):<9} {cell(mp):<9} "
|
|
516
|
+
f"{cell(m.get('factorio_version')):<6} {cell(m.get('portal_factorio_version')):<6} "
|
|
517
|
+
f"{cell(m.get('downloads')):>9}"
|
|
518
|
+
)
|
|
519
|
+
print(f"\n{len(mods)} mods "
|
|
520
|
+
"v = mod version, F = Factorio version, loc = local checkout, "
|
|
521
|
+
"mp = mod portal, dl = downloads")
|
|
522
|
+
if differing:
|
|
523
|
+
print(f"~ = local and published versions differ ({differing} mods). Reasons vary and are "
|
|
524
|
+
"not inferable from the numbers - ask the maintainer before changing such a checkout.")
|
|
525
|
+
|
|
526
|
+
unchecked = [
|
|
527
|
+
n for n, m in mods.items()
|
|
528
|
+
if m.get("on_portal") is not False and not m.get("portal_version")
|
|
529
|
+
]
|
|
530
|
+
if unchecked:
|
|
531
|
+
print(
|
|
532
|
+
f"{len(unchecked)} mods have no portal data yet (portal columns show '-'). "
|
|
533
|
+
"Run `fmw discover` without --no-portal to fill them in."
|
|
534
|
+
)
|
|
535
|
+
unpublished = [n for n, m in mods.items() if m.get("on_portal") is False]
|
|
536
|
+
if unpublished:
|
|
537
|
+
print(f"Not published on the portal: {', '.join(unpublished)}")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def cmd_sync(args) -> None:
|
|
541
|
+
ws = open_ws(args)
|
|
542
|
+
names = resolve_mods(ws, args.mods, args.all)
|
|
543
|
+
if not names:
|
|
544
|
+
sys.exit("No mods selected. Run `fmw discover` first, or name a mod explicitly.")
|
|
545
|
+
|
|
546
|
+
report: dict = {"mods": {}}
|
|
547
|
+
with Portal() as portal:
|
|
548
|
+
for mod in names:
|
|
549
|
+
try:
|
|
550
|
+
threads, warns = portal.thread_list(mod)
|
|
551
|
+
except PortalError as exc:
|
|
552
|
+
report["mods"][mod] = {"error": str(exc)}
|
|
553
|
+
if not args.json:
|
|
554
|
+
print(f"{mod}: ERROR {exc}")
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
changes = store.diff_lists(store.load_list(ws, mod), threads)
|
|
558
|
+
store.save_list(ws, mod, threads, warns)
|
|
559
|
+
store.append_changelog(ws, mod, changes)
|
|
560
|
+
|
|
561
|
+
fetched = []
|
|
562
|
+
if not args.no_threads:
|
|
563
|
+
have = store.cached_thread_ids(ws, mod)
|
|
564
|
+
touched = {c["id"] for c in changes}
|
|
565
|
+
todo = [t for t in threads if t.id in touched or t.id not in have]
|
|
566
|
+
for t in todo:
|
|
567
|
+
store.save_thread(ws, portal.thread(mod, t.id))
|
|
568
|
+
fetched.append(t.id)
|
|
569
|
+
|
|
570
|
+
drift = _status_drift(ws, mod, threads)
|
|
571
|
+
report["mods"][mod] = {
|
|
572
|
+
"threads": len(threads),
|
|
573
|
+
"changes": changes,
|
|
574
|
+
"fetched": fetched,
|
|
575
|
+
"warnings": warns,
|
|
576
|
+
"drift": drift,
|
|
577
|
+
}
|
|
578
|
+
if not args.json:
|
|
579
|
+
head = f"{mod}: {len(threads)} threads"
|
|
580
|
+
if changes:
|
|
581
|
+
head += f", {len(changes)} change(s)"
|
|
582
|
+
print(head)
|
|
583
|
+
for c in changes:
|
|
584
|
+
mark = "NEW " if c["kind"] == "new_thread" else "REPLY"
|
|
585
|
+
print(f" {mark} [{c.get('tag','')}] {c['title']} ({c.get('author')}, {c.get('at','')[:16]})")
|
|
586
|
+
print(f" https://mods.factorio.com/mod/{mod}/discussion/{c['id']}")
|
|
587
|
+
for w in warns:
|
|
588
|
+
print(f" ! {w}")
|
|
589
|
+
for d in drift:
|
|
590
|
+
print(f" ~ {d}")
|
|
591
|
+
if args.json:
|
|
592
|
+
out(report, True)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def require_mod(ws: Workspace, value: str | None) -> str:
|
|
596
|
+
"""A mod name from the argument, or inferred from the current directory."""
|
|
597
|
+
if value:
|
|
598
|
+
mod, _ = parse_target(value)
|
|
599
|
+
if mod:
|
|
600
|
+
return mod
|
|
601
|
+
detection = detect_mod_from_cwd(ws)
|
|
602
|
+
announce(detection)
|
|
603
|
+
if detection.mod:
|
|
604
|
+
return detection.mod
|
|
605
|
+
sys.exit("No mod given, and the current directory does not identify one.")
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _status_drift(ws, mod: str, threads) -> list[str]:
|
|
609
|
+
"""Where the portal title and the ticket disagree about being done.
|
|
610
|
+
|
|
611
|
+
The two are maintained by hand in different places, so they drift; each
|
|
612
|
+
direction means a different piece of unfinished work.
|
|
613
|
+
"""
|
|
614
|
+
index = tracker.index_by_thread(tracker.load_all(ws, mod))
|
|
615
|
+
notes: list[str] = []
|
|
616
|
+
for t in threads:
|
|
617
|
+
tickets = index.get(t.id)
|
|
618
|
+
if not tickets:
|
|
619
|
+
continue
|
|
620
|
+
m = parse_marker(t.title)
|
|
621
|
+
open_ones = [i for i in tickets if i.meta.get("status") in tracker.open_statuses()]
|
|
622
|
+
if m.resolved and open_ones:
|
|
623
|
+
notes.append(
|
|
624
|
+
f"thread marked '{m.marker}' but ticket "
|
|
625
|
+
f"{', '.join(i.id for i in open_ones)} is still open: {m.clean[:44]}"
|
|
626
|
+
)
|
|
627
|
+
elif not m.resolved and tickets and not open_ones:
|
|
628
|
+
notes.append(
|
|
629
|
+
f"ticket {', '.join(i.id for i in tickets)} is done but the thread "
|
|
630
|
+
f"is not marked: {m.clean[:44]}"
|
|
631
|
+
)
|
|
632
|
+
return notes
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def cmd_threads(args) -> None:
|
|
636
|
+
ws = open_ws(args)
|
|
637
|
+
mod = require_mod(ws, args.mod)
|
|
638
|
+
data = store.load_list(ws, mod)
|
|
639
|
+
if not data:
|
|
640
|
+
sys.exit(f"No cached threads for {mod}. Run `fmw sync {mod}` first.")
|
|
641
|
+
threads = data["threads"]
|
|
642
|
+
if args.tag:
|
|
643
|
+
want = args.tag.lower()
|
|
644
|
+
threads = [t for t in threads
|
|
645
|
+
if t["tag"].lower().startswith(want) or abbrev_tag(t["tag"]).startswith(want)]
|
|
646
|
+
if args.limit:
|
|
647
|
+
threads = threads[: args.limit]
|
|
648
|
+
|
|
649
|
+
index = tracker.index_by_thread(tracker.load_all(ws, mod))
|
|
650
|
+
states = threadstate.load(ws, mod)
|
|
651
|
+
|
|
652
|
+
if args.json:
|
|
653
|
+
out({
|
|
654
|
+
"mod": mod,
|
|
655
|
+
"fetched_at": data["fetched_at"],
|
|
656
|
+
"threads": [
|
|
657
|
+
{**with_marker(t),
|
|
658
|
+
"state": states.get(t["id"], threadstate.ThreadState()).to_dict(),
|
|
659
|
+
"tickets": [{"id": i.id, "kind": i.meta.get("kind", ""),
|
|
660
|
+
"status": i.meta.get("status", ""),
|
|
661
|
+
"title": i.meta.get("title", "")}
|
|
662
|
+
for i in index.get(t["id"], [])]}
|
|
663
|
+
for t in threads
|
|
664
|
+
],
|
|
665
|
+
}, True)
|
|
666
|
+
return
|
|
667
|
+
|
|
668
|
+
print(f"{mod} - {len(threads)} threads (cached {data['fetched_at'][:16]})")
|
|
669
|
+
print(f"{'tag':<5} {'last-msg':<11} {'re':>2} title")
|
|
670
|
+
for t in threads:
|
|
671
|
+
# The title is printed verbatim: any [FIXED] / (fixed) marker is part
|
|
672
|
+
# of it, so a separate resolved flag would only restate what is there.
|
|
673
|
+
print(f"{abbrev_tag(t['tag']):<5} {t['last_at'][:10]:<11} {t['reply_count']:>2} {t['title']}")
|
|
674
|
+
st = states.get(t["id"], threadstate.ThreadState())
|
|
675
|
+
bits = [b for b in (st.triage if st.triage != "pending" else "", st.cls) if b]
|
|
676
|
+
if st.needs_response:
|
|
677
|
+
bits.append("needs-response")
|
|
678
|
+
if st.duplicate_of:
|
|
679
|
+
bits.append(f"dup-of {st.duplicate_of}")
|
|
680
|
+
suffix = f" [{', '.join(bits)}]" if bits else ""
|
|
681
|
+
print(f"{'':<5} {t['id']} by {t['author']}{suffix}")
|
|
682
|
+
for i in index.get(t["id"], []):
|
|
683
|
+
state = f"{i.meta.get('kind','?')}/{i.meta.get('status','?')}"
|
|
684
|
+
print(f"{'':<5} -> {i.id} {state:<21} {i.meta.get('title','')}")
|
|
685
|
+
print("\nre = replies, last-msg = newest message, -> = ticket citing this thread")
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def cmd_thread(args) -> None:
|
|
689
|
+
ws = open_ws(args)
|
|
690
|
+
mod, tid = parse_target(args.target)
|
|
691
|
+
if tid is None:
|
|
692
|
+
sys.exit("Give a thread id or a discussion URL.")
|
|
693
|
+
if mod is None:
|
|
694
|
+
mod = store.find_thread_mod(ws, tid)
|
|
695
|
+
data = None
|
|
696
|
+
if not args.refresh and mod:
|
|
697
|
+
data = store.load_thread(ws, mod, tid)
|
|
698
|
+
if data is None:
|
|
699
|
+
if not mod:
|
|
700
|
+
sys.exit(
|
|
701
|
+
"Unknown thread: not in the cache and no mod given. "
|
|
702
|
+
"Pass the full discussion URL, or run `fmw sync <mod>` first."
|
|
703
|
+
)
|
|
704
|
+
with Portal() as portal:
|
|
705
|
+
thread = portal.thread(mod, tid)
|
|
706
|
+
store.save_thread(ws, thread)
|
|
707
|
+
data = store.load_thread(ws, mod, tid)
|
|
708
|
+
|
|
709
|
+
if args.json:
|
|
710
|
+
out(data, True)
|
|
711
|
+
return
|
|
712
|
+
print(f"# {data['title']}")
|
|
713
|
+
print(f"_{data['mod']} - {data['tag']} - {data['url']}_\n")
|
|
714
|
+
for m in data["messages"]:
|
|
715
|
+
edited = f" (edited {m['edited_at'][:16]})" if m.get("edited_at") else ""
|
|
716
|
+
print(f"## {m['author']} - {m['posted_at'][:16]}{edited}")
|
|
717
|
+
print(m["body"])
|
|
718
|
+
print()
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def cmd_changes(args) -> None:
|
|
722
|
+
ws = open_ws(args)
|
|
723
|
+
names = resolve_mods(ws, args.mods, args.all)
|
|
724
|
+
records = []
|
|
725
|
+
for mod in names:
|
|
726
|
+
records.extend(store.read_changelog(ws, mod, args.since))
|
|
727
|
+
records.sort(key=lambda r: r.get("at") or "", reverse=True)
|
|
728
|
+
if args.json:
|
|
729
|
+
out(records, True)
|
|
730
|
+
return
|
|
731
|
+
if not records:
|
|
732
|
+
print("No recorded changes." + (f" (since {args.since})" if args.since else ""))
|
|
733
|
+
return
|
|
734
|
+
print(f"{'seen':<11} {'posted':<11} what")
|
|
735
|
+
for r in records:
|
|
736
|
+
mark = "NEW " if r["kind"] == "new_thread" else "REPLY"
|
|
737
|
+
print(f"{r.get('seen_at','')[:10]:<11} {r.get('at','')[:10]:<11} "
|
|
738
|
+
f"{mark} {r['mod']} [{abbrev_tag(r.get('tag',''))}] {r['title']}")
|
|
739
|
+
print(f"{'':<23}https://mods.factorio.com/mod/{r['mod']}/discussion/{r['id']}")
|
|
740
|
+
print("\nseen = when fmw detected it (what --since filters on), "
|
|
741
|
+
"posted = when the message was written")
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def cmd_track_new(args) -> None:
|
|
745
|
+
ws = open_ws(args)
|
|
746
|
+
mod, tid = parse_target(args.target) if args.target else (None, None)
|
|
747
|
+
if tid and not mod:
|
|
748
|
+
mod = store.find_thread_mod(ws, tid)
|
|
749
|
+
if not mod:
|
|
750
|
+
mod = require_mod(ws, None)
|
|
751
|
+
|
|
752
|
+
sources = list(args.source or [])
|
|
753
|
+
extra: dict = {}
|
|
754
|
+
title = args.title
|
|
755
|
+
if tid:
|
|
756
|
+
sources.append(f"portal:{tid}")
|
|
757
|
+
cached = store.load_thread(ws, mod, tid)
|
|
758
|
+
if cached:
|
|
759
|
+
m = parse_marker(cached.get("title", ""))
|
|
760
|
+
# Default the title to the thread's, minus the maintainer's marker,
|
|
761
|
+
# so creating a ticket from a thread needs no retyping.
|
|
762
|
+
title = title or m.clean
|
|
763
|
+
extra["portal_tag"] = cached.get("tag", "")
|
|
764
|
+
if m.marker:
|
|
765
|
+
extra["portal_marker"] = m.marker
|
|
766
|
+
extra["portal_resolved"] = m.resolved
|
|
767
|
+
if not title:
|
|
768
|
+
sys.exit("Give a title, or a thread whose title can be used.")
|
|
769
|
+
|
|
770
|
+
mods = [mod] + [m for m in (args.also or []) if m != mod]
|
|
771
|
+
item = tracker.create(
|
|
772
|
+
ws, mods=mods, title=title, kind=args.kind, status=args.status,
|
|
773
|
+
sources=sources, extra=extra or None,
|
|
774
|
+
)
|
|
775
|
+
if args.json:
|
|
776
|
+
out({**item.meta, "path": str(item.path)}, True)
|
|
777
|
+
return
|
|
778
|
+
print(f"{item.id} {item.path}")
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def migrate_tickets(ws, dry_run: bool = False) -> list:
|
|
782
|
+
"""Bring tickets written by earlier versions up to the current schema."""
|
|
783
|
+
items = tracker.load_all(ws)
|
|
784
|
+
taken = {i.id for i in items if tracker.ID_RE.match(i.id)}
|
|
785
|
+
changed, warnings = [], []
|
|
786
|
+
for item in items:
|
|
787
|
+
before = dict(item.meta)
|
|
788
|
+
if "mod" in item.meta:
|
|
789
|
+
item.meta = {("mods" if k == "mod" else k):
|
|
790
|
+
([v] if k == "mod" else v) for k, v in item.meta.items()}
|
|
791
|
+
kind = item.meta.get("kind")
|
|
792
|
+
if kind in tracker.LEGACY_KINDS:
|
|
793
|
+
item.meta["kind"] = tracker.LEGACY_KINDS[kind]
|
|
794
|
+
elif kind and kind not in tracker.KINDS:
|
|
795
|
+
warnings.append(f"{item.id}: kind {kind!r} has no equivalent - set it by hand")
|
|
796
|
+
old_path = item.path
|
|
797
|
+
if not tracker.ID_RE.match(str(item.meta.get("id", ""))):
|
|
798
|
+
ident = tracker.new_id(taken)
|
|
799
|
+
taken.add(ident)
|
|
800
|
+
item.meta["id"] = ident
|
|
801
|
+
item.path = old_path.parent / f"{ident}-{tracker.slugify(item.meta.get('title',''), 40)}.md"
|
|
802
|
+
if item.meta != before or item.path != old_path:
|
|
803
|
+
if not dry_run:
|
|
804
|
+
item.save()
|
|
805
|
+
if item.path != old_path:
|
|
806
|
+
old_path.unlink()
|
|
807
|
+
changed.append((str(old_path.name), str(item.path.name)))
|
|
808
|
+
return changed
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def cmd_track_list(args) -> None:
|
|
812
|
+
ws = open_ws(args)
|
|
813
|
+
mod = args.mod
|
|
814
|
+
if not mod:
|
|
815
|
+
detection = detect_mod_from_cwd(ws)
|
|
816
|
+
if not args.json:
|
|
817
|
+
announce(detection)
|
|
818
|
+
mod = detection.mod
|
|
819
|
+
items = tracker.load_all(ws, mod)
|
|
820
|
+
if args.kind:
|
|
821
|
+
items = [i for i in items if i.meta.get("kind") == args.kind]
|
|
822
|
+
if args.status:
|
|
823
|
+
items = [i for i in items if i.meta.get("status") == args.status]
|
|
824
|
+
elif args.open:
|
|
825
|
+
items = [i for i in items if i.meta.get("status") in tracker.open_statuses()]
|
|
826
|
+
if args.json:
|
|
827
|
+
out([{**i.meta, "path": str(i.path)} for i in items], True)
|
|
828
|
+
return
|
|
829
|
+
for i in items:
|
|
830
|
+
mods = "+".join(i.mods)
|
|
831
|
+
print(f"{i.id} [{i.meta.get('kind','?'):<7}] [{i.meta.get('status','?'):<12}] {mods:<30} {i.meta.get('title','')}")
|
|
832
|
+
print(f"\n{len(items)} items")
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def cmd_testplan(args) -> None:
|
|
836
|
+
"""Collect every awaiting-test fix into one document.
|
|
837
|
+
|
|
838
|
+
Each Factorio run is expensive, so the point is to verify a batch of fixes
|
|
839
|
+
in a single session rather than one per fix.
|
|
840
|
+
"""
|
|
841
|
+
ws = open_ws(args)
|
|
842
|
+
mods = resolve_mods(ws, args.mods, args.all, quiet=args.json)
|
|
843
|
+
batch: list[dict] = []
|
|
844
|
+
for mod in mods:
|
|
845
|
+
for item in tracker.load_all(ws, mod):
|
|
846
|
+
if item.meta.get("status") != "needs-testing":
|
|
847
|
+
continue
|
|
848
|
+
batch.append(
|
|
849
|
+
{
|
|
850
|
+
"mod": mod,
|
|
851
|
+
"id": item.id,
|
|
852
|
+
"title": item.meta.get("title", item.id),
|
|
853
|
+
"kind": item.meta.get("kind", ""),
|
|
854
|
+
"severity": item.meta.get("severity", ""),
|
|
855
|
+
"fixed_in": item.meta.get("fixed_in", ""),
|
|
856
|
+
"path": str(item.path),
|
|
857
|
+
"plan": tracker.test_plan(item),
|
|
858
|
+
"threads": sorted(item.portal_threads()),
|
|
859
|
+
}
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
if args.json:
|
|
863
|
+
out({"items": batch, "mods": sorted({b["mod"] for b in batch})}, True)
|
|
864
|
+
return
|
|
865
|
+
if not batch:
|
|
866
|
+
print("Nothing awaiting testing. Set an item's status to `needs-testing` once it is fixed but unverified.")
|
|
867
|
+
return
|
|
868
|
+
|
|
869
|
+
by_mod: dict[str, list[dict]] = {}
|
|
870
|
+
for b in batch:
|
|
871
|
+
by_mod.setdefault(b["mod"], []).append(b)
|
|
872
|
+
|
|
873
|
+
print(f"# Test plan - {len(batch)} fix(es) across {len(by_mod)} mod(s)\n")
|
|
874
|
+
for mod, items in by_mod.items():
|
|
875
|
+
version = ws.load_mods()["mods"].get(mod, {}).get("local_version", "")
|
|
876
|
+
print(f"## {mod}" + (f" (local {version})" if version else ""))
|
|
877
|
+
paths = ws.load_paths()
|
|
878
|
+
if paths.get(mod):
|
|
879
|
+
print(f"_{paths[mod]}_")
|
|
880
|
+
print()
|
|
881
|
+
for i, b in enumerate(items, 1):
|
|
882
|
+
head = f"{i}. {b['title']}"
|
|
883
|
+
tags = [t for t in (b["kind"], b["severity"]) if t]
|
|
884
|
+
if tags:
|
|
885
|
+
head += f" [{', '.join(tags)}]"
|
|
886
|
+
print(head)
|
|
887
|
+
print(b["plan"] and indent(b["plan"]) or " _no test plan written yet_")
|
|
888
|
+
for tid in b["threads"]:
|
|
889
|
+
print(f" thread: https://mods.factorio.com/mod/{mod}/discussion/{tid}")
|
|
890
|
+
print(f" item: {b['path']}")
|
|
891
|
+
print()
|
|
892
|
+
print("After verifying, set each item to `status: fixed`, then `released` once published.")
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def indent(text: str, prefix: str = " ") -> str:
|
|
896
|
+
return "\n".join(prefix + line if line.strip() else line for line in text.splitlines())
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _changes_from(args) -> dict:
|
|
900
|
+
changes: dict = {}
|
|
901
|
+
if args.triage:
|
|
902
|
+
changes["triage"] = args.triage
|
|
903
|
+
if args.set_class:
|
|
904
|
+
changes["cls"] = args.set_class
|
|
905
|
+
if args.note is not None:
|
|
906
|
+
changes["note"] = args.note
|
|
907
|
+
if args.needs_response:
|
|
908
|
+
changes["needs_response"] = True
|
|
909
|
+
if args.responded:
|
|
910
|
+
changes["needs_response"] = False
|
|
911
|
+
return changes
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def cmd_mark_bulk(args) -> None:
|
|
915
|
+
"""Apply one judgement to every pending thread older than a cutoff.
|
|
916
|
+
|
|
917
|
+
Long-dormant threads accumulate faster than they can be reviewed one at a
|
|
918
|
+
time; without a bulk path the inbox never empties and stops being trusted.
|
|
919
|
+
"""
|
|
920
|
+
ws = open_ws(args)
|
|
921
|
+
changes = _changes_from(args)
|
|
922
|
+
if not changes:
|
|
923
|
+
sys.exit("Give something to set, e.g. --triage handled.")
|
|
924
|
+
touched: list[dict] = []
|
|
925
|
+
mods = resolve_mods(ws, args.targets, args.all, quiet=args.json)
|
|
926
|
+
if not args.targets and not args.all:
|
|
927
|
+
print(f"Sweeping {len(mods)} mod(s). Name mods explicitly, or --all to be sure.",
|
|
928
|
+
file=sys.stderr)
|
|
929
|
+
for mod in mods:
|
|
930
|
+
data = store.load_list(ws, mod)
|
|
931
|
+
if not data:
|
|
932
|
+
continue
|
|
933
|
+
states = threadstate.load(ws, mod)
|
|
934
|
+
for t in data["threads"]:
|
|
935
|
+
if t["last_at"][:10] >= args.before:
|
|
936
|
+
continue
|
|
937
|
+
st = states.get(t["id"], threadstate.ThreadState())
|
|
938
|
+
if st.triage != "pending" or st.needs_response:
|
|
939
|
+
continue # never silently overwrite a decision already made
|
|
940
|
+
if args.tag and not t["tag"].lower().startswith(args.tag.lower()):
|
|
941
|
+
continue
|
|
942
|
+
for key, value in changes.items():
|
|
943
|
+
setattr(st, key, value)
|
|
944
|
+
states[t["id"]] = st
|
|
945
|
+
touched.append({"mod": mod, "id": t["id"], "title": t["title"]})
|
|
946
|
+
threadstate.save(ws, mod, states)
|
|
947
|
+
|
|
948
|
+
if args.json:
|
|
949
|
+
out({"changed": touched, "count": len(touched)}, True)
|
|
950
|
+
return
|
|
951
|
+
for t in touched[:10]:
|
|
952
|
+
print(f" {t['mod']} {t['id']} {t['title'][:56]}")
|
|
953
|
+
if len(touched) > 10:
|
|
954
|
+
print(f" ... and {len(touched) - 10} more")
|
|
955
|
+
print(f"{len(touched)} thread(s) updated (already-decided threads left alone)")
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def cmd_mark(args) -> None:
|
|
959
|
+
"""Record your judgement about a thread."""
|
|
960
|
+
ws = open_ws(args)
|
|
961
|
+
if args.before:
|
|
962
|
+
return cmd_mark_bulk(args)
|
|
963
|
+
if len(args.targets) != 1:
|
|
964
|
+
sys.exit("Give exactly one thread id or URL, or --before DATE for a bulk update.")
|
|
965
|
+
mod, tid = parse_target(args.targets[0])
|
|
966
|
+
if not tid:
|
|
967
|
+
sys.exit("Give a thread id or a discussion URL.")
|
|
968
|
+
if not mod:
|
|
969
|
+
mod = store.find_thread_mod(ws, tid)
|
|
970
|
+
if not mod:
|
|
971
|
+
sys.exit(f"Thread {tid} is not in the cache. Run `fmw sync` first.")
|
|
972
|
+
|
|
973
|
+
changes: dict = {}
|
|
974
|
+
if args.triage:
|
|
975
|
+
changes["triage"] = args.triage
|
|
976
|
+
if args.set_class:
|
|
977
|
+
changes["cls"] = args.set_class
|
|
978
|
+
if args.note is not None:
|
|
979
|
+
changes["note"] = args.note
|
|
980
|
+
if args.duplicate_of:
|
|
981
|
+
changes["duplicate_of"] = args.duplicate_of
|
|
982
|
+
changes.setdefault("cls", "duplicate")
|
|
983
|
+
changes.setdefault("triage", "handled")
|
|
984
|
+
if args.needs_response:
|
|
985
|
+
changes["needs_response"] = True
|
|
986
|
+
if args.responded:
|
|
987
|
+
changes["needs_response"] = False
|
|
988
|
+
state = threadstate.get(ws, mod, tid)
|
|
989
|
+
tickets = tracker.index_by_thread(tracker.load_all(ws, mod)).get(tid, [])
|
|
990
|
+
pending = threadstate.pending_announcements(state, tickets)
|
|
991
|
+
if pending:
|
|
992
|
+
changes["announced"] = sorted(set(state.announced) | set(pending))
|
|
993
|
+
if not changes:
|
|
994
|
+
sys.exit("Nothing to change. Pass --triage/--class/--note/--needs-response/--responded.")
|
|
995
|
+
|
|
996
|
+
state = threadstate.update(ws, mod, tid, **changes)
|
|
997
|
+
if args.json:
|
|
998
|
+
out({"mod": mod, "thread": tid, **state.to_dict()}, True)
|
|
999
|
+
return
|
|
1000
|
+
bits = [f"triage={state.triage}"]
|
|
1001
|
+
if state.needs_response:
|
|
1002
|
+
bits.append("needs-response")
|
|
1003
|
+
if state.cls:
|
|
1004
|
+
bits.append(f"class={state.cls}")
|
|
1005
|
+
if state.duplicate_of:
|
|
1006
|
+
bits.append(f"duplicate-of={state.duplicate_of}")
|
|
1007
|
+
print(f"{mod} {tid}: " + ", ".join(bits))
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def collect_inbox(ws, mods, all_items, include_resolved: bool = False):
|
|
1011
|
+
"""Threads still asking for a decision, plus the count hidden as resolved.
|
|
1012
|
+
|
|
1013
|
+
The single definition of "awaiting triage", shared by `inbox` and `status`
|
|
1014
|
+
so the dashboard number always matches the list you work from. A thread
|
|
1015
|
+
with a ticket stays listed - the ticket tracks the code change, not the
|
|
1016
|
+
conversation, which may still need a reply or a classification.
|
|
1017
|
+
"""
|
|
1018
|
+
registry = ws.load_mods()["mods"]
|
|
1019
|
+
ticket_index = tracker.index_by_thread(all_items)
|
|
1020
|
+
out: list[dict] = []
|
|
1021
|
+
hidden_resolved = 0
|
|
1022
|
+
for mod in mods:
|
|
1023
|
+
data = store.load_list(ws, mod)
|
|
1024
|
+
if not data:
|
|
1025
|
+
continue
|
|
1026
|
+
states = threadstate.load(ws, mod)
|
|
1027
|
+
for t in data["threads"]:
|
|
1028
|
+
st = states.get(t["id"], threadstate.ThreadState())
|
|
1029
|
+
m = parse_marker(t["title"])
|
|
1030
|
+
tickets = ticket_index.get(t["id"], [])
|
|
1031
|
+
reason = threadstate.reply_reason(st, tickets)
|
|
1032
|
+
if st.triage == "ignored":
|
|
1033
|
+
continue
|
|
1034
|
+
if st.triage == "handled" and not reason:
|
|
1035
|
+
continue
|
|
1036
|
+
if st.triage == "pending" and m.resolved and not tickets:
|
|
1037
|
+
hidden_resolved += 1
|
|
1038
|
+
if not include_resolved:
|
|
1039
|
+
continue
|
|
1040
|
+
out.append({
|
|
1041
|
+
"mod": mod, "id": t["id"], "title": m.clean or t["title"],
|
|
1042
|
+
"tag": t["tag"], "last_at": t["last_at"], "replies": t["reply_count"],
|
|
1043
|
+
"needs_response": bool(reason), "reply_reason": reason,
|
|
1044
|
+
"triage": st.triage,
|
|
1045
|
+
"class": st.cls, "marker": m.marker, "wrong": m.wrong,
|
|
1046
|
+
"tickets": [i.id for i in tickets],
|
|
1047
|
+
"downloads": registry.get(mod, {}).get("downloads") or 0,
|
|
1048
|
+
})
|
|
1049
|
+
return out, hidden_resolved
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _snippet(text: str, needle: str, width: int = 88) -> str:
|
|
1053
|
+
"""The first matching line, trimmed around the match."""
|
|
1054
|
+
for line in text.splitlines():
|
|
1055
|
+
pos = line.lower().find(needle)
|
|
1056
|
+
if pos < 0:
|
|
1057
|
+
continue
|
|
1058
|
+
line = line.strip()
|
|
1059
|
+
pos = line.lower().find(needle)
|
|
1060
|
+
if len(line) <= width:
|
|
1061
|
+
return line
|
|
1062
|
+
start = max(0, pos - width // 3)
|
|
1063
|
+
return ("..." if start else "") + line[start:start + width] + "..."
|
|
1064
|
+
return ""
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def cmd_search(args) -> None:
|
|
1068
|
+
"""Find threads and tickets mentioning a phrase.
|
|
1069
|
+
|
|
1070
|
+
Grouping duplicate reports is the workflow this tool exists for, and it
|
|
1071
|
+
needs one command that answers "has anyone said this before" with thread
|
|
1072
|
+
ids you can hand straight to `fmw thread`.
|
|
1073
|
+
"""
|
|
1074
|
+
ws = open_ws(args)
|
|
1075
|
+
mods = resolve_mods(ws, args.mods, args.all, quiet=args.json)
|
|
1076
|
+
needle = args.query.lower()
|
|
1077
|
+
hits: list[dict] = []
|
|
1078
|
+
|
|
1079
|
+
if args.where in ("threads", "both"):
|
|
1080
|
+
for mod in mods:
|
|
1081
|
+
for path in sorted((ws.mod_cache_dir(mod) / "threads").glob("*.json")):
|
|
1082
|
+
data = json.loads(path.read_text())
|
|
1083
|
+
bodies = [m for m in data["messages"] if needle in m.get("body", "").lower()]
|
|
1084
|
+
in_title = needle in data.get("title", "").lower()
|
|
1085
|
+
if not bodies and not in_title:
|
|
1086
|
+
continue
|
|
1087
|
+
# Prefer a body match for the snippet; echoing the title back
|
|
1088
|
+
# under the title is noise.
|
|
1089
|
+
hits.append({
|
|
1090
|
+
"kind": "thread", "id": data["id"], "mod": mod,
|
|
1091
|
+
"title": data.get("title", ""), "tag": data.get("tag", ""),
|
|
1092
|
+
"snippet": _snippet(bodies[0]["body"], needle) if bodies else "",
|
|
1093
|
+
"matched_authors": sorted({m["author"] for m in bodies}),
|
|
1094
|
+
"matched_title": in_title,
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
if args.where in ("tickets", "both"):
|
|
1098
|
+
for item in tracker.load_all(ws):
|
|
1099
|
+
if mods and not any(m in mods for m in item.mods):
|
|
1100
|
+
continue
|
|
1101
|
+
blob = f"{item.meta.get('title','')}\n{item.body}"
|
|
1102
|
+
if needle not in blob.lower():
|
|
1103
|
+
continue
|
|
1104
|
+
hits.append({
|
|
1105
|
+
"kind": "ticket", "id": item.id, "mod": "+".join(item.mods),
|
|
1106
|
+
"title": item.meta.get("title", ""),
|
|
1107
|
+
"tag": f"{item.meta.get('kind','?')}/{item.meta.get('status','?')}",
|
|
1108
|
+
"snippet": _snippet(blob, needle), "path": str(item.path),
|
|
1109
|
+
})
|
|
1110
|
+
|
|
1111
|
+
if args.json:
|
|
1112
|
+
out({"query": args.query, "hits": hits, "count": len(hits)}, True)
|
|
1113
|
+
return
|
|
1114
|
+
if not hits:
|
|
1115
|
+
print(f"No match for {args.query!r} in {len(mods)} mod(s).")
|
|
1116
|
+
return
|
|
1117
|
+
for h in hits[: args.limit]:
|
|
1118
|
+
label = "ticket" if h["kind"] == "ticket" else "thread"
|
|
1119
|
+
print(f"{label} {h['id']} {h['mod']} [{h['tag'][:18]}]")
|
|
1120
|
+
print(f" {h['title'][:80]}")
|
|
1121
|
+
if h["snippet"]:
|
|
1122
|
+
who = f" ({', '.join(h['matched_authors'])})" if h.get("matched_authors") else ""
|
|
1123
|
+
print(f" > {h['snippet']}{who}")
|
|
1124
|
+
if len(hits) > args.limit:
|
|
1125
|
+
print(f"... {len(hits) - args.limit} more (--limit N)")
|
|
1126
|
+
print(f"\n{len(hits)} match(es). Read one with `fmw thread <id>`.")
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def cmd_inbox(args) -> None:
|
|
1130
|
+
"""Everything awaiting a decision, threads and tickets in one view."""
|
|
1131
|
+
ws = open_ws(args)
|
|
1132
|
+
mods = resolve_mods(ws, args.mods, args.all, quiet=args.json)
|
|
1133
|
+
all_items = tracker.load_all(ws)
|
|
1134
|
+
threads_out, hidden_resolved = collect_inbox(ws, mods, all_items, args.all_threads)
|
|
1135
|
+
|
|
1136
|
+
open_tickets = [
|
|
1137
|
+
i for i in all_items
|
|
1138
|
+
if i.meta.get("status") in tracker.open_statuses()
|
|
1139
|
+
and (args.all or not mods or any(m in mods for m in i.mods))
|
|
1140
|
+
]
|
|
1141
|
+
|
|
1142
|
+
if args.json:
|
|
1143
|
+
out({"threads": threads_out, "hidden_resolved": hidden_resolved, "tickets": [
|
|
1144
|
+
{**i.meta, "path": str(i.path)} for i in open_tickets]}, True)
|
|
1145
|
+
return
|
|
1146
|
+
|
|
1147
|
+
needs_reply = [t for t in threads_out if t["needs_response"]]
|
|
1148
|
+
undecided = [t for t in threads_out if not t["needs_response"]]
|
|
1149
|
+
# Bugs before ideas, then a busier mod outranks a quiet one, then recency.
|
|
1150
|
+
undecided.sort(key=lambda t: (t["tag"] != "Bugs", -t["downloads"], t["last_at"]), reverse=False)
|
|
1151
|
+
undecided.sort(key=lambda t: t["last_at"], reverse=True)
|
|
1152
|
+
|
|
1153
|
+
def line(t):
|
|
1154
|
+
flags = "".join([
|
|
1155
|
+
"T" if t["tickets"] else " ",
|
|
1156
|
+
"R" if t["needs_response"] else " ",
|
|
1157
|
+
"!" if t["wrong"] else " ",
|
|
1158
|
+
])
|
|
1159
|
+
return (f" {flags} [{t['tag'][:5]:<5}] {t['last_at'][:10]} {t['replies']:>2}r "
|
|
1160
|
+
f"{t['title'][:58]}\n {t['id']} {t['mod']}")
|
|
1161
|
+
|
|
1162
|
+
if needs_reply:
|
|
1163
|
+
print(f"== You owe a reply ({len(needs_reply)}) ==")
|
|
1164
|
+
for t in needs_reply:
|
|
1165
|
+
print(line(t))
|
|
1166
|
+
if t["reply_reason"].startswith("released"):
|
|
1167
|
+
print(f" fix shipped, reporter not told - {t['reply_reason']}")
|
|
1168
|
+
print()
|
|
1169
|
+
awaiting = [i for i in open_tickets if i.meta.get("status") == "needs-testing"]
|
|
1170
|
+
if awaiting:
|
|
1171
|
+
print(f"== Awaiting your testing ({len(awaiting)}) - run `fmw testplan` ==")
|
|
1172
|
+
for i in awaiting:
|
|
1173
|
+
print(f" {i.id} {'+'.join(i.mods)}: {i.meta.get('title','')}")
|
|
1174
|
+
print()
|
|
1175
|
+
working = [i for i in open_tickets if i.meta.get("status") != "needs-testing"]
|
|
1176
|
+
if working:
|
|
1177
|
+
print(f"== Open tickets ({len(working)}) ==")
|
|
1178
|
+
for i in working:
|
|
1179
|
+
print(f" {i.id} [{i.meta.get('status','?'):<12}] {'+'.join(i.mods)}: {i.meta.get('title','')}")
|
|
1180
|
+
print()
|
|
1181
|
+
if undecided:
|
|
1182
|
+
extra = f", {hidden_resolved} hidden as resolved on the portal" if hidden_resolved else ""
|
|
1183
|
+
print(f"== Threads awaiting triage ({len(undecided)}{extra}) ==")
|
|
1184
|
+
for t in undecided[: args.limit]:
|
|
1185
|
+
print(line(t))
|
|
1186
|
+
if len(undecided) > args.limit:
|
|
1187
|
+
print(f" ... {len(undecided) - args.limit} more (--limit N)")
|
|
1188
|
+
print()
|
|
1189
|
+
if not (needs_reply or open_tickets or undecided):
|
|
1190
|
+
print("Inbox clear.")
|
|
1191
|
+
return
|
|
1192
|
+
print("T = has a ticket, R = you owe a reply, ! = reporter was mistaken")
|
|
1193
|
+
print("Next: `fmw mark <thread> --triage handled` or `fmw track new <thread>`")
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def cmd_help(args) -> None:
|
|
1197
|
+
"""Prose help, without needing the repository checked out."""
|
|
1198
|
+
if args.all:
|
|
1199
|
+
print(helptext.OVERVIEW)
|
|
1200
|
+
for name in sorted(helptext.TOPICS):
|
|
1201
|
+
print(f"\n{'=' * 72}\nfmw help {name}\n{'=' * 72}\n")
|
|
1202
|
+
print(helptext.TOPICS[name].rstrip())
|
|
1203
|
+
for name in sorted(helptext.COMMANDS):
|
|
1204
|
+
print(f"\n{'=' * 72}\nfmw {name}\n{'=' * 72}\n")
|
|
1205
|
+
print(helptext.COMMANDS[name].rstrip())
|
|
1206
|
+
return
|
|
1207
|
+
if args.command:
|
|
1208
|
+
if args.command in helptext.TOPICS:
|
|
1209
|
+
print(helptext.TOPICS[args.command].rstrip())
|
|
1210
|
+
return
|
|
1211
|
+
text = helptext.COMMANDS.get(args.command)
|
|
1212
|
+
if not text:
|
|
1213
|
+
known = ", ".join(sorted({*helptext.COMMANDS, *helptext.TOPICS}))
|
|
1214
|
+
sys.exit(f"No such command or topic: {args.command}.\nKnown: {known}")
|
|
1215
|
+
print(f"fmw {args.command}\n")
|
|
1216
|
+
print(text.rstrip())
|
|
1217
|
+
print(f"\nFlags: fmw {args.command} --help")
|
|
1218
|
+
return
|
|
1219
|
+
print(helptext.OVERVIEW)
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def cmd_status(args) -> None:
|
|
1223
|
+
ws = open_ws(args)
|
|
1224
|
+
registry = ws.load_mods()["mods"]
|
|
1225
|
+
items = tracker.load_all(ws)
|
|
1226
|
+
open_items = [i for i in items if i.meta.get("status") in tracker.open_statuses()]
|
|
1227
|
+
in_scope = [n for n, m in registry.items()
|
|
1228
|
+
if m.get("on_portal") is not False and m.get("maintained") != "frozen"]
|
|
1229
|
+
threads_out, hidden_resolved = collect_inbox(ws, in_scope, items)
|
|
1230
|
+
replies_owed = sum(1 for t in threads_out if t["needs_response"])
|
|
1231
|
+
summary = {
|
|
1232
|
+
"workspace": str(ws.root),
|
|
1233
|
+
"mods": len(registry),
|
|
1234
|
+
# Two different senses of "maintained" used to share one label here.
|
|
1235
|
+
"mods_marked_active": sum(1 for m in registry.values() if m.get("maintained") == "active"),
|
|
1236
|
+
"mods_in_default_scope": len(in_scope),
|
|
1237
|
+
"tracker_items": len(items),
|
|
1238
|
+
"open_items": len(open_items),
|
|
1239
|
+
"awaiting_test": sum(1 for i in items if i.meta.get("status") == "needs-testing"),
|
|
1240
|
+
"replies_owed": replies_owed,
|
|
1241
|
+
"awaiting_triage": len(threads_out),
|
|
1242
|
+
"hidden_as_resolved": hidden_resolved,
|
|
1243
|
+
}
|
|
1244
|
+
stale = outdated_template_files(ws)
|
|
1245
|
+
summary["stale_workspace_docs"] = stale
|
|
1246
|
+
summary["workspace_under_git"] = (ws.root / ".git").is_dir()
|
|
1247
|
+
if args.json:
|
|
1248
|
+
out(summary, True)
|
|
1249
|
+
return
|
|
1250
|
+
for k, v in summary.items():
|
|
1251
|
+
if k in ("stale_workspace_docs", "workspace_under_git"):
|
|
1252
|
+
continue
|
|
1253
|
+
print(f"{k:<22} {v}")
|
|
1254
|
+
if stale:
|
|
1255
|
+
print(f"\n! {len(stale)} workspace doc(s) describe an older schema "
|
|
1256
|
+
f"({', '.join(stale)}). Run `fmw upgrade`.")
|
|
1257
|
+
if not summary["workspace_under_git"]:
|
|
1258
|
+
print(f"\n! {ws.root} is not a git repository. Your tickets and thread "
|
|
1259
|
+
"judgements are the only irreplaceable data here; `git init` there.")
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
# ----------------------------------------------------------------------
|
|
1263
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1264
|
+
p = argparse.ArgumentParser(
|
|
1265
|
+
prog="fmw",
|
|
1266
|
+
description=helptext.OVERVIEW,
|
|
1267
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1268
|
+
)
|
|
1269
|
+
p.add_argument("--workspace", help="workspace directory (overrides $FMW_WORKSPACE; no implicit default)")
|
|
1270
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
1271
|
+
|
|
1272
|
+
def common(sp):
|
|
1273
|
+
"""Options accepted both before and after the subcommand.
|
|
1274
|
+
|
|
1275
|
+
argparse only matches a parent-level option before the subcommand, so
|
|
1276
|
+
`fmw --workspace X status` works but `fmw status --workspace X` does
|
|
1277
|
+
not. Repeating it on each subparser fixes that; SUPPRESS is required so
|
|
1278
|
+
an absent flag does not overwrite the value parsed by the parent.
|
|
1279
|
+
"""
|
|
1280
|
+
sp.add_argument("--workspace", default=argparse.SUPPRESS,
|
|
1281
|
+
help="workspace directory (overrides $FMW_WORKSPACE)")
|
|
1282
|
+
sp.add_argument("--json", action="store_true", help="machine-readable output")
|
|
1283
|
+
return sp
|
|
1284
|
+
|
|
1285
|
+
def add(name, fn, help_):
|
|
1286
|
+
# The prose lives in help.py and surfaces through --help, so there is
|
|
1287
|
+
# no second copy to fall out of step with the code.
|
|
1288
|
+
sp = sub.add_parser(
|
|
1289
|
+
name, help=help_,
|
|
1290
|
+
description=helptext.COMMANDS.get(name),
|
|
1291
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1292
|
+
)
|
|
1293
|
+
sp.set_defaults(func=fn)
|
|
1294
|
+
return common(sp)
|
|
1295
|
+
|
|
1296
|
+
sp = add("init", cmd_init, "create a workspace directory")
|
|
1297
|
+
sp.add_argument("path", help="where to create it (no default: be explicit)")
|
|
1298
|
+
|
|
1299
|
+
rp = sub.add_parser("repo", help="manage the directories holding your mods",
|
|
1300
|
+
description=helptext.COMMANDS.get("repo"),
|
|
1301
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1302
|
+
rsub = rp.add_subparsers(dest="repo_cmd")
|
|
1303
|
+
rp.set_defaults(func=cmd_repo, repo_cmd="list")
|
|
1304
|
+
ra = rsub.add_parser("add", help="register a directory to scan for mods")
|
|
1305
|
+
ra.set_defaults(func=cmd_repo); ra.add_argument("path"); common(ra)
|
|
1306
|
+
rr = rsub.add_parser("remove", help="stop scanning a directory")
|
|
1307
|
+
rr.set_defaults(func=cmd_repo); rr.add_argument("path"); common(rr)
|
|
1308
|
+
rl = rsub.add_parser("list", help="show configured repositories")
|
|
1309
|
+
rl.set_defaults(func=cmd_repo); common(rl)
|
|
1310
|
+
common(rp)
|
|
1311
|
+
|
|
1312
|
+
sp = add("discover", cmd_discover, "find mods in your repos and read their portal metadata")
|
|
1313
|
+
sp.add_argument("--scan", nargs="*", metavar="DIR",
|
|
1314
|
+
help="scan these directories just this once, without registering them "
|
|
1315
|
+
"(use `fmw repo add` to keep one)")
|
|
1316
|
+
sp.add_argument("--no-portal", action="store_true", help="skip portal API lookups")
|
|
1317
|
+
sp.add_argument("--max-depth", type=int, default=2,
|
|
1318
|
+
help="how deep below each root to look for info.json (default: 2)")
|
|
1319
|
+
|
|
1320
|
+
sp = add("mods", cmd_mods, "list the mod registry")
|
|
1321
|
+
sp.add_argument("--maintained", choices=discover.MAINTAINED,
|
|
1322
|
+
help="filter by maintained value")
|
|
1323
|
+
|
|
1324
|
+
sp = add("sync", cmd_sync, "fetch discussions and report what changed")
|
|
1325
|
+
sp.add_argument("mods", nargs="*", help="mods to sync; inferred from the cwd if omitted")
|
|
1326
|
+
sp.add_argument("--all", action="store_true", help="include mods marked frozen")
|
|
1327
|
+
sp.add_argument("--no-threads", action="store_true", help="only refresh the thread list")
|
|
1328
|
+
|
|
1329
|
+
sp = add("threads", cmd_threads, "list cached threads of a mod")
|
|
1330
|
+
sp.add_argument("mod", nargs="?", help="mod name; inferred from the current directory if omitted")
|
|
1331
|
+
sp.add_argument("--tag", help="filter by tag name or abbreviation, e.g. bug / idea")
|
|
1332
|
+
sp.add_argument("--limit", type=int)
|
|
1333
|
+
|
|
1334
|
+
sp = add("thread", cmd_thread, "print a full discussion thread")
|
|
1335
|
+
sp.add_argument("target", help="thread id or discussion URL")
|
|
1336
|
+
sp.add_argument("--refresh", action="store_true", help="re-fetch instead of using cache")
|
|
1337
|
+
|
|
1338
|
+
sp = add("changes", cmd_changes, "replay recorded portal changes")
|
|
1339
|
+
sp.add_argument("mods", nargs="*")
|
|
1340
|
+
sp.add_argument("--all", action="store_true")
|
|
1341
|
+
sp.add_argument("--since", metavar="ISO",
|
|
1342
|
+
help="only changes detected on or after this time (detection time, not post time)")
|
|
1343
|
+
|
|
1344
|
+
sp = add("search", cmd_search, "find threads and tickets mentioning a phrase")
|
|
1345
|
+
sp.add_argument("query")
|
|
1346
|
+
sp.add_argument("mods", nargs="*", help="mods to search; inferred from the cwd if omitted")
|
|
1347
|
+
sp.add_argument("--all", action="store_true", help="search every mod")
|
|
1348
|
+
sp.add_argument("--where", choices=("threads", "tickets", "both"), default="both",
|
|
1349
|
+
help="what to search (default: both)")
|
|
1350
|
+
sp.add_argument("--limit", type=int, default=20, help="max results to print")
|
|
1351
|
+
|
|
1352
|
+
sp = add("inbox", cmd_inbox, "everything awaiting a decision, threads and tickets")
|
|
1353
|
+
sp.add_argument("mods", nargs="*", help="mods to show; inferred from the cwd if omitted")
|
|
1354
|
+
sp.add_argument("--all", action="store_true", help="every mod, including frozen ones")
|
|
1355
|
+
sp.add_argument("--all-threads", action="store_true",
|
|
1356
|
+
help="include threads the portal title already marks resolved")
|
|
1357
|
+
sp.add_argument("--limit", type=int, default=20, help="max untriaged threads to list")
|
|
1358
|
+
|
|
1359
|
+
sp = add("mark", cmd_mark, "record your judgement about a thread")
|
|
1360
|
+
sp.add_argument("targets", nargs="*",
|
|
1361
|
+
help="a thread id or URL; with --before, the mods to sweep instead")
|
|
1362
|
+
sp.add_argument("--before", metavar="YYYY-MM-DD",
|
|
1363
|
+
help="bulk mode: apply to every still-pending thread last active before this date")
|
|
1364
|
+
sp.add_argument("--tag", help="bulk mode: restrict to a portal tag")
|
|
1365
|
+
sp.add_argument("--all", action="store_true", help="bulk mode: every mod")
|
|
1366
|
+
sp.add_argument("--triage", choices=threadstate.TRIAGE,
|
|
1367
|
+
help="how you have dealt with the conversation")
|
|
1368
|
+
sp.add_argument("--class", dest="set_class", choices=threadstate.CLASSES,
|
|
1369
|
+
help="what the thread really is, regardless of its portal tag")
|
|
1370
|
+
sp.add_argument("--note", help="why you decided that")
|
|
1371
|
+
sp.add_argument("--duplicate-of", help="another thread id this repeats")
|
|
1372
|
+
sp.add_argument("--needs-response", action="store_true", help="you owe the reporter a reply")
|
|
1373
|
+
sp.add_argument("--responded", action="store_true", help="clear the reply flag")
|
|
1374
|
+
|
|
1375
|
+
sp = add("testplan", cmd_testplan, "collect every awaiting-test fix into one document")
|
|
1376
|
+
sp.add_argument("mods", nargs="*", help="mods to include; inferred from the cwd if omitted")
|
|
1377
|
+
sp.add_argument("--all", action="store_true", help="every mod, including frozen ones")
|
|
1378
|
+
|
|
1379
|
+
sp = sub.add_parser("help", help="prose help for a command, or an overview")
|
|
1380
|
+
sp.set_defaults(func=cmd_help)
|
|
1381
|
+
sp.add_argument("command", nargs="?",
|
|
1382
|
+
help="a command, or a topic: workspace, workflow")
|
|
1383
|
+
sp.add_argument("--all", action="store_true",
|
|
1384
|
+
help="print every command's help at once (one call, for agents)")
|
|
1385
|
+
sp.add_argument("--json", action="store_true", help=argparse.SUPPRESS)
|
|
1386
|
+
sp.add_argument("--workspace", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
1387
|
+
|
|
1388
|
+
sp = add("status", cmd_status, "workspace overview")
|
|
1389
|
+
|
|
1390
|
+
sp = add("upgrade", cmd_upgrade,
|
|
1391
|
+
"bring a workspace to the current schema: docs, mods.yaml, tickets")
|
|
1392
|
+
sp.add_argument("--dry-run", action="store_true", help="report what would change")
|
|
1393
|
+
|
|
1394
|
+
sp = add("launcher", cmd_launcher, "install a PATH wrapper pinning a workspace")
|
|
1395
|
+
sp.add_argument("--name", default="fmw", help="command name to install (default: fmw)")
|
|
1396
|
+
sp.add_argument("--out", help="exact path to write instead of a PATH directory")
|
|
1397
|
+
sp.add_argument("--force", action="store_true", help="overwrite an existing file")
|
|
1398
|
+
|
|
1399
|
+
tp = sub.add_parser("track", help="tickets: code changes, error or feature",
|
|
1400
|
+
description=helptext.COMMANDS.get("track"),
|
|
1401
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1402
|
+
tsub = tp.add_subparsers(dest="track_cmd", required=True)
|
|
1403
|
+
|
|
1404
|
+
tn = tsub.add_parser("new", help="create a tracker item")
|
|
1405
|
+
tn.set_defaults(func=cmd_track_new)
|
|
1406
|
+
tn.add_argument("target", nargs="?", help="thread id/URL, or a mod name; inferred from the current directory if omitted")
|
|
1407
|
+
tn.add_argument("title", nargs="?", help="defaults to the thread's title without its marker")
|
|
1408
|
+
tn.add_argument("--also", action="append", help="another mod this ticket applies to")
|
|
1409
|
+
tn.add_argument("--kind", default="error", choices=tracker.KINDS)
|
|
1410
|
+
tn.add_argument("--status", default="new", choices=tracker.STATUSES)
|
|
1411
|
+
tn.add_argument("--source", action="append", help="extra source, e.g. discord:<url>")
|
|
1412
|
+
common(tn)
|
|
1413
|
+
|
|
1414
|
+
tl = tsub.add_parser("list", help="list tracker items")
|
|
1415
|
+
tl.set_defaults(func=cmd_track_list)
|
|
1416
|
+
tl.add_argument("mod", nargs="?", help="mod name; inferred from the current directory if omitted")
|
|
1417
|
+
tl.add_argument("--kind", choices=tracker.KINDS)
|
|
1418
|
+
tl.add_argument("--status", choices=tracker.STATUSES)
|
|
1419
|
+
tl.add_argument("--open", action="store_true", help="only unresolved items")
|
|
1420
|
+
common(tl)
|
|
1421
|
+
|
|
1422
|
+
return p
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def main(argv: list[str] | None = None) -> None:
|
|
1426
|
+
args = build_parser().parse_args(argv)
|
|
1427
|
+
args.func(args)
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
if __name__ == "__main__":
|
|
1431
|
+
main()
|