forked from vhaudiquet/weekly-activity
- `weekly-activity config`: interactive wizard writing ~/.config/weekly-activity.toml (XDG-aware, --config override), one section per source, 0600 permissions, tokens never echoed; re-runs show current settings and pre-fill defaults - `weekly-activity report` (and bare invocation): loads enabled sources, collects each independently, joins blocks under one header; a failing source renders as [skipped: <source> — error] without aborting - config.py: typed schema, tomllib reader with strict validation, hand-formatted TOML writer (stdlib-only, no new dependency) - aggregate.py: source spec building + failure-isolated collection + combined rendering - README: document config/report usage and config schema - tests/: 16 stdlib-unittest cases (config round-trip/escaping/validation, aggregation mapping/isolation)
187 lines
6.9 KiB
Python
187 lines
6.9 KiB
Python
"""Interactive onboarding wizard behind `weekly-activity config`.
|
|
|
|
Asks which sources to enable and the identity/auth details for each, then
|
|
writes the TOML config. Never echoes token values back to the console; does
|
|
not run any OAuth flow — for Launchpad credentials mode it only records the
|
|
choice (launchpadlib's keyring flow runs at report time).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import getpass
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from weekly_activity.config import (
|
|
ActivityConfig,
|
|
BtsSettings,
|
|
ConfigError,
|
|
GithubSettings,
|
|
GitlabSettings,
|
|
LaunchpadSettings,
|
|
load_config,
|
|
save_config,
|
|
)
|
|
|
|
|
|
def run_wizard(config_path: Path) -> None:
|
|
"""Walk through source setup and write ``config_path``."""
|
|
existing = _load_existing(config_path)
|
|
if existing.enabled_sources():
|
|
print(f"Found an existing configuration at {config_path}:")
|
|
print(_describe(existing, indent=" "))
|
|
if not _confirm("Re-run setup?"):
|
|
print("Keeping the current configuration.")
|
|
return
|
|
|
|
print()
|
|
print("Welcome! This wizard sets up the sources aggregated by")
|
|
print("`weekly-activity report`. Answer with Enter to accept [defaults],")
|
|
print("or Ctrl-C to abort without writing anything.")
|
|
|
|
config = ActivityConfig(
|
|
launchpad=_configure_launchpad(existing.launchpad),
|
|
github=_configure_github(existing.github),
|
|
gitlab=_configure_gitlab(existing.gitlab),
|
|
bts=_configure_bts(existing.bts),
|
|
)
|
|
|
|
enabled = config.enabled_sources()
|
|
if not enabled:
|
|
raise SystemExit("warning: no source selected; nothing was written.")
|
|
|
|
save_config(config, config_path)
|
|
print()
|
|
print(f"Configuration written to {config_path}.")
|
|
print(f"Enabled sources: {', '.join(enabled)}.")
|
|
print(_describe(config, indent=""))
|
|
print("Tokens are never printed back. Run `weekly-activity report` to")
|
|
print("aggregate activity across all of them in one output.")
|
|
|
|
|
|
def _load_existing(path: Path) -> ActivityConfig:
|
|
try:
|
|
return load_config(path)
|
|
except FileNotFoundError:
|
|
return ActivityConfig()
|
|
except ConfigError as exc:
|
|
print(f"warning: ignoring unusable existing config: {exc}", file=sys.stderr)
|
|
print(f"warning: {path} will be replaced by a fresh configuration.", file=sys.stderr)
|
|
return ActivityConfig()
|
|
|
|
|
|
# --- Per-source prompts ---
|
|
|
|
|
|
def _configure_launchpad(prev: LaunchpadSettings | None) -> LaunchpadSettings | None:
|
|
if not _confirm("Add Launchpad?", default=False):
|
|
return None
|
|
print("Launchpad identity is the ~name shown on your profile page.")
|
|
username = _ask_required("Launchpad username", prev.username if prev else "")
|
|
anonymous = _confirm(
|
|
"Anonymous mode (public data only, no OAuth)?",
|
|
default=prev.mode == "anonymous" if prev else True,
|
|
)
|
|
settings = LaunchpadSettings(
|
|
username=username, mode="anonymous" if anonymous else "credentials"
|
|
)
|
|
if not anonymous:
|
|
print(
|
|
"OAuth is obtained at report time: launchpadlib opens a browser and\n"
|
|
"stores the token in your system keyring, or reads it from a file."
|
|
)
|
|
settings.credentials_file = _ask(
|
|
"Credentials file (empty to use the system keyring)",
|
|
prev.credentials_file if prev else "",
|
|
)
|
|
return settings
|
|
|
|
|
|
def _configure_github(prev: GithubSettings | None) -> GithubSettings | None:
|
|
if not _confirm("Add GitHub?", default=False):
|
|
return None
|
|
username = _ask_required("GitHub username", prev.username if prev else "")
|
|
print("Leave the token empty to fall back to $GITHUB_TOKEN or `gh auth token`.")
|
|
token = _ask_secret("GitHub token (input hidden)", prev.token if prev else "")
|
|
return GithubSettings(username=username, token=token)
|
|
|
|
|
|
def _configure_gitlab(prev: GitlabSettings | None) -> GitlabSettings | None:
|
|
if not _confirm("Add GitLab?", default=False):
|
|
return None
|
|
defaults = prev or GitlabSettings(username="")
|
|
username = _ask_required("GitLab username", defaults.username)
|
|
url = _ask("Instance URL", defaults.url or "https://gitlab.com")
|
|
print("Leave the token empty to discover it from the environment instead.")
|
|
token = _ask_secret("API token (input hidden)", defaults.token)
|
|
token_env = _ask("Env var holding the token (optional, e.g. SALSA_TOKEN)", defaults.token_env)
|
|
return GitlabSettings(username=username, url=url, token=token, token_env=token_env)
|
|
|
|
|
|
def _configure_bts(prev: BtsSettings | None) -> BtsSettings | None:
|
|
if not _confirm("Add Debian BTS?", default=False):
|
|
return None
|
|
print("The BTS identifies people by the email used on bugs (submitter/owner).")
|
|
email = _ask_required("Email address on Debian bugs", prev.email if prev else "")
|
|
return BtsSettings(email=email)
|
|
|
|
|
|
# --- Prompt helpers ---
|
|
|
|
|
|
def _ask(prompt: str, default: str = "") -> str:
|
|
suffix = f" [{default}]" if default else ""
|
|
answer = input(f"{prompt}{suffix}: ").strip()
|
|
return answer or default
|
|
|
|
|
|
def _ask_required(prompt: str, default: str = "") -> str:
|
|
while True:
|
|
answer = _ask(prompt, default)
|
|
if answer:
|
|
return answer
|
|
print("A value is required here.")
|
|
|
|
|
|
def _ask_secret(prompt: str, default: str = "") -> str:
|
|
"""Read a secret without echo; keep the stored one when input is empty."""
|
|
try:
|
|
entered = getpass.getpass(f"{prompt}: ")
|
|
except (EOFError, KeyboardInterrupt):
|
|
raise SystemExit(130) from None
|
|
return entered.strip() or default
|
|
|
|
|
|
def _confirm(prompt: str, *, default: bool = False) -> bool:
|
|
hint = "[Y/n]" if default else "[y/N]"
|
|
while True:
|
|
answer = input(f"{prompt} {hint}: ").strip().lower()
|
|
if not answer:
|
|
return default
|
|
if answer in {"y", "yes"}:
|
|
return True
|
|
if answer in {"n", "no"}:
|
|
return False
|
|
|
|
|
|
def _describe(config: ActivityConfig, indent: str = " ") -> str:
|
|
"""Human-readable summary that masks every token value."""
|
|
lines: list[str] = []
|
|
if (lp := config.launchpad) is not None:
|
|
auth = (
|
|
"anonymous (public data only)"
|
|
if lp.mode == "anonymous"
|
|
else f"OAuth via {lp.credentials_file or 'system keyring'}"
|
|
)
|
|
lines.append(f"{indent}launchpad: {lp.username} — {auth}")
|
|
if (gh := config.github) is not None:
|
|
token_desc = "stored" if gh.token else "not stored (uses GITHUB_TOKEN / gh)"
|
|
lines.append(f"{indent}github: {gh.username} — token {token_desc}")
|
|
if (gl := config.gitlab) is not None:
|
|
fallback = gl.token_env or "uses <HOST>_TOKEN / GITLAB_TOKEN env"
|
|
token_desc = "stored" if gl.token else f"not stored ({fallback})"
|
|
lines.append(f"{indent}gitlab: {gl.username} @ {gl.url} — token {token_desc}")
|
|
if (bts := config.bts) is not None:
|
|
lines.append(f"{indent}bts: {bts.email}")
|
|
return "\n".join(lines) if lines else f"{indent}(no sources configured)"
|