diff --git a/bin/rcontacts-live b/bin/rcontacts-live new file mode 100755 index 0000000..038af27 --- /dev/null +++ b/bin/rcontacts-live @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Portable contacts live entry point. No shell evaluation of credentials. +set -euo pipefail +ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")" +exec python3 "$ROOT/redmine_cli_runtime.py" contacts live "$@" diff --git a/bin/redmine-runtime-completion.bash b/bin/redmine-runtime-completion.bash new file mode 100644 index 0000000..61a77f6 --- /dev/null +++ b/bin/redmine-runtime-completion.bash @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Source this file; no network calls and no config/secret loading. +_redmine_completion_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$_redmine_completion_root/redmine_contacts_completion.sh" +_redmine_runtime_complete() { + local cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" + local common="--help --show-config --doctor --doctor-nmi --env-file --base-cache --base-url --project --mode --api-key-file" + COMPREPLY=() + case "$prev" in + --env-file|--cache|--cache-dir|--base-cache|--api-key-file|--config|--body-file) + while IFS= read -r entry; do COMPREPLY+=("$entry"); done < <(compgen -f -- "$cur") + return ;; + --mode) COMPREPLY=( $(compgen -W 'production dev' -- "$cur") ); return ;; + --source) COMPREPLY=( $(compgen -W 'cache api auto' -- "$cur") ); return ;; + esac + if [[ "${COMP_WORDS[0]}" == *rcontacts-live ]]; then + _redmine_contacts_completion + COMPREPLY+=( $(compgen -W "$common" -- "$cur") ) + else + local commands="sync search view walk materialize-queues note customer-email set-fields meta-refresh contacts-sync contacts-search contact-show contact-set ticket-contact attachments entities browse tui textual-tui triage ocr-rename categories control-tag draft spam-check close-if-spam tag-maybe-customer decompose" + local flags="--cache-dir --config --allow-prod-writes --where --open-only --max-tickets --issue-id --contact-id --json --jsonl --ids-only --fetch --apply --private --body --body-file --remote-updated-since --remote-updated-before --remote-status-id --dry-run --quiet --run-log" + COMPREPLY=( $(compgen -W "$commands $common $flags" -- "$cur") ) + fi +} +complete -F _redmine_runtime_complete rtl rtd rtickets-live rcontacts-live ./bin/rtl ./bin/rtd ./bin/rtickets-live ./bin/rcontacts-live +unset _redmine_completion_root diff --git a/bin/rtd b/bin/rtd index 06178f7..9603a1e 100755 --- a/bin/rtd +++ b/bin/rtd @@ -1,6 +1,5 @@ #!/usr/bin/env bash -# Muscle-memory wrapper: LAN/dev customer-service cache + base URL. -# Usage: rtd note --issue-id 39305 --body 'test' --apply +# Portable tickets dev entry point. No shell evaluation of credentials. set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -exec "$ROOT/redmine_tickets.py" --config "$ROOT/configs/dev.json" "$@" +ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")" +exec python3 "$ROOT/redmine_cli_runtime.py" tickets dev "$@" diff --git a/bin/rtickets-live b/bin/rtickets-live new file mode 100755 index 0000000..097ab35 --- /dev/null +++ b/bin/rtickets-live @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Portable tickets live entry point. No shell evaluation of credentials. +set -euo pipefail +ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")" +exec python3 "$ROOT/redmine_cli_runtime.py" tickets live "$@" diff --git a/bin/rtl b/bin/rtl index 2e2eaf1..097ab35 100755 --- a/bin/rtl +++ b/bin/rtl @@ -1,9 +1,5 @@ #!/usr/bin/env bash -# Muscle-memory wrapper: prod customer-service cache + base URL. -# Usage: rtl search --where 'assigned_to IS NULL' --max-tickets 10 +# Portable tickets live entry point. No shell evaluation of credentials. set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# rtl is the production Redmine wrapper. Keep an explicitly supplied mode, but -# never silently default its NMI briefs to the dev profile. -export NMI_MODE="${NMI_MODE:-production}" -exec "$ROOT/redmine_tickets.py" --config "$ROOT/configs/live.json" "$@" +ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")" +exec python3 "$ROOT/redmine_cli_runtime.py" tickets live "$@" diff --git a/docs/cli_runtime.md b/docs/cli_runtime.md new file mode 100644 index 0000000..82997b1 --- /dev/null +++ b/docs/cli_runtime.md @@ -0,0 +1,87 @@ +# Redmine CLI runtime + +The live wrappers are deliberately short: + +```bash +bin/rcontacts-live search acme +bin/rtickets-live search --where 'assigned_to IS NULL' +bin/rtl search --where 'assigned_to IS NULL' # short live alias +bin/rtd search --where 'assigned_to IS NULL' # explicit LAN/dev lane +``` + +`--help` works without credentials. The wrappers resolve configuration independently of the current working directory and pass ordinary arguments unchanged to `redmine_contacts.py` or `redmine_tickets.py`. + +## Diagnose configuration without API traffic + +```bash +bin/rcontacts-live --doctor +bin/rtickets-live --show-config +bin/rtl --doctor-nmi +``` + +Diagnostics show effective paths and each setting’s source. Secrets are never printed. `network_checked: false` means readiness is local; it does not prove remote authentication. + +## Precedence and files + +Highest priority wins: + +1. wrapper CLI options; +2. exported environment; +3. selected Redmine env file; +4. ticket JSON/YAML `--config` values; +5. defaults. + +Select an env file with `--env-file PATH` or `REDMINE_ENV_FILE`. Otherwise the first existing file is used: + +- live: checkout `.env`, legacy `bin/.env`, `~/.redmine.live`; +- dev: checkout `.env.dev`, `bin/.env.dev`, `~/.redmine.dev`. + +Dev never falls back to the live file. An explicitly selected missing, empty, or malformed file is an error. Files use `NAME=value` and are parsed as data; shell commands are not evaluated. + +```text +REDMINE_API_KEY required for API operations +REDMINE_BASE_URL live default https://rm.ldrprep.com +REDMINE_PROJECT default customer-service +REDMINE_BASE_CACHE common mutable-data root +REDMINE_CONTACTS_CACHE exact full-CRM snapshot JSON file +REDMINE_TICKETS_CACHE exact ticket-cache directory +REDMINE_LOCK_TIMEOUT writer wait, seconds; default 60 +REDMINE_ENV_FILE explicit env profile +REDMINE_API_KEY_FILE file containing only the Redmine key +``` + +NMI bridge variables (`NMI_CLI`, `NMI_ENV_FILE`, `NMI_MODE`, endpoint/credential variables) pass through to PHP. `rtl` defaults NMI to production; `rtd` defaults it to dev. `--doctor-nmi` runs the actual PHP resolver offline. + +## Cache layout + +`REDMINE_BASE_CACHE` chooses the common root, defaulting to checkout `cache/` live and `cache-dev/` dev. + +- Tickets normally use `/redmine_tickets/`. +- Other endpoint/project combinations get deterministic distinct ticket directories. +- Fresh contacts setups use `/redmine_contacts/_.json`. +- If historical `/redmine_contacts` is already a **file** for normal live use, the wrapper preserves and uses it. There is no automatic migration. +- Tickets’ `/contacts/` mirror is a different per-contact format, not the full contacts snapshot. + +Exact `--cache-dir DIR` (tickets) and `--cache FILE` (contacts) win. Contacts rejects a cache directory because `redmine_contacts.py` expects one JSON file. + +`ticket-contact` prints a shell-quoted full-CRM handoff that retains endpoint, project, env profile, API-key-file context, and cache file without putting a key in argv. + +## Concurrency and cron + +Writers use POSIX advisory locks around read/modify/write transactions and unique same-directory temporary files followed by atomic replacement. A process’s death releases its kernel lock; the persistent `.lock` file is only an inode anchor. A later holder removes matching temporary residue left by a killed writer. + +Contention is visible on stderr. After `REDMINE_LOCK_TIMEOUT`, the process exits nonzero rather than making a cron failure silent. + +```cron +REDMINE_ENV_FILE=/etc/ldr/redmine-live.env /opt/redmine/bin/rtickets-live sync --remote-updated-since 6h --run-log --quiet +``` + +## Completion and safety + +```bash +source bin/redmine-runtime-completion.bash +``` + +This registers all four wrappers and includes the established contacts completion surface. + +The wrappers do not weaken write safety: production ticket writes still require `--allow-prod-writes`; customer email still requires its long confirmation; NMI retains its independent mutation gates. diff --git a/redmine_cache_io.py b/redmine_cache_io.py new file mode 100644 index 0000000..9bf86ce --- /dev/null +++ b/redmine_cache_io.py @@ -0,0 +1,125 @@ +"""POSIX local-filesystem cache transactions; persistent locks, atomic JSON. + +Locks protect read/modify/write, not only replacement. File existence never +indicates ownership. Kernel locks are released even when the holder is killed. +""" +from __future__ import annotations +from contextlib import contextmanager +import fcntl +import json +import math +import os +from pathlib import Path +import sys +import tempfile +import threading +import time + +class CacheError(RuntimeError): + pass + +_local = threading.local() + +def lock_timeout(): + raw = os.environ.get('REDMINE_LOCK_TIMEOUT','60') + try: + value=float(raw) + if not math.isfinite(value) or value < 0: + raise ValueError() + return value + except ValueError as exc: + raise CacheError('REDMINE_LOCK_TIMEOUT must be a finite nonnegative number of seconds.') from exc + +@contextmanager +def cache_lock(target, timeout=None): + target=Path(target).resolve() + seconds=lock_timeout() if timeout is None else float(timeout) + if not math.isfinite(seconds) or seconds < 0: + raise CacheError('Cache lock timeout must be finite and nonnegative.') + held=getattr(_local,'held',None) + if held is None: + held=_local.held={} + key=(os.getpid(),str(target)) + if key in held: + yield + return + path=target.with_name(target.name+'.lock') + path.parent.mkdir(parents=True,exist_ok=True) + fd=os.open(path,os.O_RDWR|os.O_CREAT,0o600) + acquired=False + start=time.monotonic() + warned=False + try: + while True: + try: + fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB) + acquired=True + held[key]=fd + # A killed writer may leave its same-directory temporary file. + # Once this lock is ours, no live writer for this target can own + # one, so removing matching leftovers is safe. + for stale in target.parent.glob('.' + target.name + '.*.tmp'): + try: + stale.unlink() + except FileNotFoundError: + pass + break + except BlockingIOError: + if not warned: + print(f'Waiting for cache lock: {path} (timeout {seconds:g}s)',file=sys.stderr,flush=True) + warned=True + remaining=seconds-(time.monotonic()-start) + if remaining <= 0: + raise CacheError(f'Cache lock timeout after {seconds:g}s: {path}. Another writer is active; retry or increase REDMINE_LOCK_TIMEOUT. Do not delete the lock file.') + time.sleep(min(0.05,remaining)) + yield + finally: + if acquired: + held.pop(key,None) + fcntl.flock(fd,fcntl.LOCK_UN) + os.close(fd) + + +def read_json(path): + path=Path(path) + try: + data=json.loads(path.read_text(encoding='utf-8')) + except FileNotFoundError: + return {} + except (json.JSONDecodeError,UnicodeError) as exc: + raise CacheError(f'Invalid cache JSON: {path}; refusing to overwrite it. Restore or repair this file.') from exc + if not isinstance(data,dict): + raise CacheError(f'Cache JSON must be an object: {path}; refusing to overwrite it.') + return data + + +def locked_read_json(path): + with cache_lock(path): + return read_json(path) + + +def atomic_write_json(path,data): + path=Path(path) + with cache_lock(path): + fd,name=tempfile.mkstemp(prefix='.'+path.name+'.',suffix='.tmp',dir=path.parent) + tmp=Path(name) + try: + with os.fdopen(fd,'w',encoding='utf-8') as handle: + json.dump(data,handle,indent=2,sort_keys=True) + handle.write('\n') + handle.flush() + os.fsync(handle.fileno()) + # Keep existing shared-file permissions; new sensitive caches are 0600. + if path.exists(): + os.chmod(tmp,path.stat().st_mode & 0o777) + os.replace(tmp,path) + finally: + tmp.unlink(missing_ok=True) + + +def update_manifest_atomic(path,updater): + with cache_lock(path): + current=read_json(path) + updater(current) + atomic_write_json(path,current) + return current diff --git a/redmine_cli_runtime.py b/redmine_cli_runtime.py new file mode 100644 index 0000000..239abbb --- /dev/null +++ b/redmine_cli_runtime.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Portable Redmine entry points. Configuration is data, never shell code.""" +from __future__ import annotations +import json +import os +from pathlib import Path +import re +import shlex +import subprocess +import sys +from urllib.parse import urlsplit + +ROOT = Path(__file__).resolve().parent + +class ConfigError(ValueError): + pass + + +def env_values(path): + values = {} + try: + lines = path.read_text(encoding='utf-8').splitlines() + except OSError as exc: + raise ConfigError(f'Cannot read env file {path}: {exc.strerror}') from exc + for n, line in enumerate(lines, 1): + line = line.strip() + if not line or line.startswith('#'): + continue + match = re.fullmatch(r'(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)', line) + if not match: + raise ConfigError(f'Malformed env assignment in {path}:{n}; expected NAME=value (no shell commands).') + key, value = match.groups() + if value.startswith(('"', "'")): + quote = value[0] + end = value.find(quote, 1) + if end < 0 or (value[end+1:].strip() and not value[end+1:].lstrip().startswith('#')): + raise ConfigError(f'Malformed quoted value in {path}:{n}.') + value = value[1:end] + else: + value = re.split(r'\s+#', value, maxsplit=1)[0].strip() + values[key] = value + return values + + +def absolute(value, base=None): + p = Path(str(value)).expanduser() + return (p if p.is_absolute() else (base or Path.cwd()) / p).resolve() + + +def split_options(argv): + values, rest = {}, [] + takes = {'--env-file','--base-cache','--base-url','--project','--cache-dir','--cache','--api-key-file','--mode'} + switches = {'--show-config','--doctor','--doctor-nmi'} + i, saw_command = 0, False + while i < len(argv): + token = argv[i] + flag, sep, val = token.partition('=') + if token == '--': + rest.extend(argv[i:]); break + if flag in switches: + if sep: + raise ConfigError(f'{flag} does not take a value.') + values[flag] = True + elif flag in takes or (flag == '--config' and not saw_command): + if not sep: + i += 1 + if i >= len(argv) or argv[i].startswith('--'): + raise ConfigError(f'{flag} requires a value.') + val = argv[i] + if not val.strip(): + raise ConfigError(f'{flag} cannot be empty.') + values[flag] = val + else: + rest.append(token) + if not token.startswith('-'): + saw_command = True + i += 1 + return values, rest + + +def resolve(tool, lane, options, environ=None): + env = dict(os.environ if environ is None else environ) + if tool == 'contacts' and '--cache-dir' in options: + raise ConfigError('Contacts uses --cache FILE, not --cache-dir. Use --base-cache DIR for a common root.') + if tool == 'tickets' and '--cache' in options: + raise ConfigError('Tickets uses --cache-dir DIR, not --cache. Use REDMINE_CONTACTS_CACHE for the full CRM handoff.') + mode = options.get('--mode', lane) + mode = {'production':'live','prod':'live','development':'dev'}.get(mode, mode) + if mode not in {'live','dev'}: + raise ConfigError('--mode must be production/live or dev.') + home = Path(env.get('HOME', str(Path.home()))) + candidates = ([ROOT/'.env.dev', ROOT/'bin/.env.dev', home/'.redmine.dev'] if mode == 'dev' + else [ROOT/'.env', ROOT/'bin/.env', home/'.redmine.live']) + explicit = options.get('--env-file') or env.get('REDMINE_ENV_FILE') + selected = absolute(explicit) if explicit else next((p for p in candidates if p.is_file()), None) + data = env_values(selected) if selected else {} + sources = {} + config = {} + config_path = None + if '--config' in options: + if tool != 'tickets': + raise ConfigError('--config is a tickets option; use --env-file for contacts.') + config_path = absolute(options['--config']) + try: + text = config_path.read_text(encoding='utf-8') + try: + raw = json.loads(text) + except json.JSONDecodeError: + try: + import yaml + except ImportError as exc: + raise ConfigError('YAML config needs existing PyYAML; use JSON instead.') from exc + raw = yaml.safe_load(text) + if not isinstance(raw, dict): + raise ConfigError(f'Config must be an object: {config_path}') + raw = raw.get('redmine_tickets', raw) + if not isinstance(raw, dict): + raise ConfigError(f'redmine_tickets must be an object: {config_path}') + config = {str(k).replace('_','-'):v for k,v in raw.items()} + except OSError as exc: + raise ConfigError(f'Cannot read config {config_path}: {exc.strerror}') from exc + except ValueError as exc: + raise ConfigError(f'Invalid config file: {config_path}') from exc + + def pick(name, flag, keys, default=None, cfg=None): + if flag and flag in options: + sources[name] = f'CLI {flag}' + return options[flag] + for mapping, source in [(env,'environment'), (data,f'env file {selected}')]: + for key in keys: + if key in mapping: + sources[name] = f'{source}: {key}' + return mapping[key] + if cfg and cfg in config: + sources[name] = f'config {config_path}: {cfg}' + return config[cfg] + sources[name] = 'default' + return default + + def path_value(name, value): + if value is None or str(value).strip() == '': + raise ConfigError(f'{name} cannot be empty; source: {sources.get(name)}') + src = sources.get(name, '') + base = selected.parent if src.startswith('env file ') else (config_path.parent if src.startswith('config ') else None) + return absolute(value, base) + + url = str(pick('base_url','--base-url',['REDMINE_BASE_URL','REDMINE_ENDPOINT'], + 'https://rm.ldrprep.com' if mode == 'live' else 'http://192.168.50.170', 'base-url')).rstrip('/') + parsed = urlsplit(url) + if parsed.scheme not in {'http','https'} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment: + raise ConfigError('REDMINE_BASE_URL/--base-url must be an http(s) URL without credentials/query/fragment.') + project = str(pick('project','--project',['REDMINE_PROJECT'],'customer-service','project')) + if not project.strip() or project in {'.','..'} or '/' in project or '\\' in project: + raise ConfigError('REDMINE_PROJECT/--project must be a project identifier, not a path.') + base = path_value('base_cache', pick('base_cache','--base-cache',['REDMINE_BASE_CACHE'],ROOT/('cache-dev' if mode == 'dev' else 'cache'))) + slug = lambda s: re.sub(r'[^A-Za-z0-9_.-]+','_',s) + host_project = slug(parsed.netloc + parsed.path) + '_' + slug(project) + is_default = project == 'customer-service' and url == ('https://rm.ldrprep.com' if mode == 'live' else 'http://192.168.50.170') + ticket_default = base / ('redmine_tickets' if is_default else 'redmine_tickets_' + slug(parsed.scheme + '_' + host_project)) + tickets = path_value('ticket_cache', pick('ticket_cache','--cache-dir',['REDMINE_TICKETS_CACHE','REDMINE_CACHE_DIR'],ticket_default,'cache-dir')) + # Preserve the historical single-file snapshot when it already exists. + # Fresh installs get a namespaced file under redmine_contacts/ so different + # hosts/projects never collide. This is compatibility, not a migration. + legacy_contacts = base / 'redmine_contacts' + contacts_default = legacy_contacts if legacy_contacts.is_file() and is_default else legacy_contacts / (host_project+'.json') + contacts = path_value('contacts_cache', pick('contacts_cache','--cache',['REDMINE_CONTACTS_CACHE'],contacts_default)) + if contacts.is_dir(): + raise ConfigError(f'Contacts --cache expects a JSON file, not a directory: {contacts}') + key = pick('api_key',None,['REDMINE_API_KEY']) + key_file = pick('api_key_file','--api-key-file',['REDMINE_API_KEY_FILE'],None,'api-key-file') + # Explicit key-file is a CLI credential override. Config key files are fallback. + if key_file and ('--api-key-file' in options or not key): + key_path = path_value('api_key_file',key_file) + try: + key = key_path.read_text(encoding='utf-8').strip() + except OSError as exc: + raise ConfigError(f'Cannot read API key file {key_path}: {exc.strerror}') from exc + sources['api_key'] = f'key file {key_path}' + key = str(key or '').strip() + child_env = dict(env) + for k,v in data.items(): + child_env.setdefault(k,v) + child_env.update(REDMINE_BASE_URL=url, REDMINE_PROJECT=project, REDMINE_BASE_CACHE=str(base), + REDMINE_CONTACTS_CACHE=str(contacts), REDMINE_TICKETS_CACHE=str(tickets), REDMINE_API_KEY=key) + if selected: + child_env['REDMINE_ENV_FILE'] = str(selected) + # Relative bridge files in env profiles are relative to that profile. + for k in ['NMI_ENV_FILE','NMI_CLI']: + if k not in env and data.get(k) and (k == 'NMI_ENV_FILE' or '/' in data[k]): + child_env[k] = str(absolute(data[k],selected.parent)) + child_env.setdefault('NMI_MODE','production' if mode == 'live' else 'dev') + report = {'mode':mode,'base_url':url,'project':project,'base_cache':str(base), + 'ticket_cache':str(tickets),'contacts_cache':str(contacts), + 'ticket_contacts_cache':str(tickets/'contacts'), + 'contacts_cache_layout':'legacy-single-file' if contacts == legacy_contacts else 'namespaced-file', + 'cache_note':'Full CRM snapshot and ticket contacts mirror are different formats; existing legacy snapshot is preserved; no migration.', + 'env_file':str(selected) if selected else None, + 'searched_env_paths':[str(selected)] if explicit else [str(p) for p in candidates], + 'config_file':str(config_path) if config_path else None, + 'api_key_present':bool(key),'sources':sources, + 'nmi_mode':child_env['NMI_MODE'], 'lock_timeout':child_env.get('REDMINE_LOCK_TIMEOUT','60')} + injected = ['--base-url',url,'--project',project, '--cache' if tool=='contacts' else '--cache-dir',str(contacts if tool=='contacts' else tickets)] + if tool == 'tickets' and config.get('allow-prod-writes') in (True,'true','yes',1,'1'): + injected.append('--allow-prod-writes') + return report, child_env, injected + + +HELP = '''Wrapper configuration (same for rcontacts-live, rtickets-live / rtl, rtd): + --env-file PATH Explicit NAME=value file; REDMINE_ENV_FILE also works + --base-cache DIR Common root; REDMINE_BASE_CACHE (default checkout/cache) + --show-config Redacted effective configuration and source paths (offline) + --doctor Validate local config, key presence and cache access (offline) + --doctor-nmi Run the actual NMI CLI's offline doctor + --mode dev Explicit development; rtd defaults to this +Precedence: CLI > exported environment > selected env file > ticket config > defaults. +Live env search: checkout/.env, checkout/bin/.env, ~/.redmine.live (first existing). +Dev env search: checkout/.env.dev, checkout/bin/.env.dev, ~/.redmine.dev. +Required online: REDMINE_API_KEY (or tickets --api-key-file). +Optional: REDMINE_BASE_URL, REDMINE_PROJECT, REDMINE_BASE_CACHE, +REDMINE_CONTACTS_CACHE (JSON file), REDMINE_TICKETS_CACHE (directory). +NMI: NMI_CLI, NMI_ENV_FILE, NMI_MODE; use --doctor-nmi for required credential names. +Exact --cache (contacts file) / --cache-dir (tickets directory) override defaults. +Relative CLI/exported paths use cwd; file-defined paths use that file's directory. +Existing apply/customer-email/production-write safety gates are unchanged. +''' + + +def contacts_handoff(args, contact_id): + """Copy/paste-safe bridge to the full CRM, without putting secrets in argv.""" + url = str(args.base_url) + project = str(args.project) + env_file = os.environ.get('REDMINE_ENV_FILE') + cache = os.environ.get('REDMINE_CONTACTS_CACHE') + if not cache: + parsed = urlsplit(url) + slug = lambda s: re.sub(r'[^A-Za-z0-9_.-]+', '_', s) + base = Path(os.environ.get('REDMINE_BASE_CACHE') or Path(args.cache_dir).parent) + cache = str(base / 'redmine_contacts' / (slug(parsed.netloc + parsed.path) + '_' + slug(project) + '.json')) + cmd = [str(ROOT / 'bin/rcontacts-live')] + if env_file: + cmd.extend(['--env-file', env_file]) + if getattr(args, 'api_key_file', None): + cmd.extend(['--api-key-file', str(absolute(args.api_key_file))]) + cmd.extend(['--base-url', url, '--project', project, '--cache', str(absolute(cache)), 'show', '--contact-id', str(contact_id)]) + return shlex.join(cmd) + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + tool, lane, *args = argv + script = ROOT / ('redmine_contacts.py' if tool == 'contacts' else 'redmine_tickets.py') + try: + opts, rest = split_options(args) + if not args or '--help' in rest or '-h' in rest: + if not args or rest in (['--help'],['-h']): + print(HELP, flush=True) + os.execv(sys.executable,[sys.executable,str(script),*(rest or ['--help'])]) + report, child_env, injected = resolve(tool,lane,opts) + if report['mode'] == 'dev': + print(f"DEVELOPMENT runtime: {report['base_url']} (NMI_MODE={report['nmi_mode']})",file=sys.stderr) + if opts.get('--doctor-nmi'): + from redmine_ticket_nmi import discover_nmi_bin + import shutil + php = shutil.which('php',path=child_env.get('PATH')) + if not php: + raise ConfigError('PHP CLI is not on PATH; required by NMI.') + binary = discover_nmi_bin(environ=child_env) + if not binary.is_file(): + raise ConfigError(f'NMI CLI not found: {binary}; set NMI_CLI.') + try: + proc = subprocess.run([php,str(binary),'--doctor','--json'],env=child_env,timeout=15) + except subprocess.TimeoutExpired as exc: + raise ConfigError('NMI offline doctor timed out after 15 seconds.') from exc + return proc.returncode + if opts.get('--show-config') or opts.get('--doctor'): + problems = [] + if not report['api_key_present']: + problems.append('REDMINE_API_KEY is missing/empty; set it in the environment or a listed env file (or use --api-key-file).') + import math + try: + seconds = float(report['lock_timeout']) + if not math.isfinite(seconds) or seconds < 0: + raise ValueError() + except ValueError: + problems.append('REDMINE_LOCK_TIMEOUT must be a finite nonnegative number of seconds.') + for target in [Path(report['ticket_cache']),Path(report['contacts_cache']).parent]: + existing=target + while not existing.exists() and existing != existing.parent: + existing=existing.parent + if not existing.is_dir() or not os.access(existing,os.W_OK|os.X_OK): + problems.append(f'Cache parent is not writable: {existing}') + report.update(ready=not problems,problems=problems,network_checked=False) + print(json.dumps(report,indent=2)) + return 2 if opts.get('--doctor') and problems else 0 + if not report['api_key_present']: + local = {'search','view','walk','browse','tui','textual-tui','contacts-search','ticket-contact','control-tag','draft','materialize-queues','saved-filters','entities'} + command = next((a for a in rest if not a.startswith('-')), '') + cache_only = (tool=='contacts' and ('--offline-cache' in rest or '--source=cache' in rest or ['--source','cache']==rest[-2:])) or (tool=='tickets' and command in local) + if not cache_only: + paths = ', '.join(report['searched_env_paths']) + raise ConfigError(f'REDMINE_API_KEY is missing/empty. Checked environment and {paths}. Set REDMINE_API_KEY, --env-file, or --api-key-file; run --doctor.') + os.execve(sys.executable,[sys.executable,str(script),*injected,*rest],child_env) + except (ConfigError,OSError) as exc: + print(f'Configuration error: {exc}',file=sys.stderr) + return 2 + +if __name__=='__main__': + raise SystemExit(main()) diff --git a/redmine_contacts.py b/redmine_contacts.py index 0230ca1..960de20 100755 --- a/redmine_contacts.py +++ b/redmine_contacts.py @@ -5,6 +5,8 @@ from __future__ import annotations import argparse import json +from functools import wraps +from redmine_cache_io import CacheError, cache_lock, atomic_write_json import os import re import subprocess @@ -497,28 +499,29 @@ def main() -> int: try: if args.command == "fetch": - client = client_from_args(args) - contacts = client.fetch_contacts(limit=args.limit) - filtered_contacts = filter_contacts_from_args(contacts, args) - cache_path = resolve_cache_path(args) - effective_cache_mode = resolve_fetch_cache_mode(args) + with cache_lock(resolve_cache_path(args)): + client = client_from_args(args) + contacts = client.fetch_contacts(limit=args.limit) + filtered_contacts = filter_contacts_from_args(contacts, args) + cache_path = resolve_cache_path(args) + effective_cache_mode = resolve_fetch_cache_mode(args) - cache_contacts = filtered_contacts if effective_cache_mode == "filtered" else contacts - write_cache(cache_path, args, cache_contacts) + cache_contacts = filtered_contacts if effective_cache_mode == "filtered" else contacts + write_cache(cache_path, args, cache_contacts) - if effective_cache_mode == "filtered": - print( - f"Cached {len(filtered_contacts)} filtered contacts " - f"(from {len(contacts)} fetched) in {cache_path}" - ) - elif has_contact_filters(args): - print( - f"Cached {len(contacts)} contacts in {cache_path}. " - f"{len(filtered_contacts)} contacts currently match the supplied filters." - ) - else: - print(f"Cached {len(contacts)} contacts in {cache_path}") - return 0 + if effective_cache_mode == "filtered": + print( + f"Cached {len(filtered_contacts)} filtered contacts " + f"(from {len(contacts)} fetched) in {cache_path}" + ) + elif has_contact_filters(args): + print( + f"Cached {len(contacts)} contacts in {cache_path}. " + f"{len(filtered_contacts)} contacts currently match the supplied filters." + ) + else: + print(f"Cached {len(contacts)} contacts in {cache_path}") + return 0 if args.command == "search": contacts = load_or_refresh(args) @@ -627,7 +630,7 @@ def main() -> int: client = client_from_args(args) print_json(client.helpdesk_timeline(args.contact_id, args.limit)) return 0 - except RedmineError as exc: + except (RedmineError, CacheError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 @@ -683,6 +686,21 @@ def add_contact_filter_args(parser: argparse.ArgumentParser) -> None: ) +def snapshot_transaction(fn): + @wraps(fn) + def wrapped(args, *a, **kw): + path = resolve_cache_path(args) + refresh = bool(getattr(args, "refresh", False) or getattr(args, "refresh_cache", False)) + if refresh or not path.exists(): + # Includes fetch and replacement; serializing replacement alone lets + # a slower old fetch clobber the newer snapshot. + with cache_lock(path): + return fn(args, *a, **kw) + return fn(args, *a, **kw) + return wrapped + + +@snapshot_transaction def load_or_refresh(args: argparse.Namespace) -> list[dict[str, Any]]: cache_path = resolve_cache_path(args) if args.refresh or not cache_path.exists(): @@ -986,6 +1004,7 @@ def read_cache_payload(cache_path: Path, args: argparse.Namespace) -> dict[str, return data +@snapshot_transaction def resolve_contact_from_args(args: argparse.Namespace) -> dict[str, Any]: source = args.source cache_path = resolve_cache_path(args) @@ -1026,6 +1045,7 @@ def has_update_changes(args: argparse.Namespace) -> bool: ) +@snapshot_transaction def load_contacts_for_batch(args: argparse.Namespace) -> list[dict[str, Any]]: cache_path = resolve_cache_path(args) @@ -1304,9 +1324,7 @@ def write_cache(cache_path: Path, args: argparse.Namespace, contacts: list[dict[ "cached_at": int(time.time()), "contacts": contacts, } - with cache_path.open("w", encoding="utf-8") as handle: - json.dump(payload, handle, indent=2, sort_keys=True) - handle.write("\n") + atomic_write_json(cache_path, payload) def resolve_cache_path(args: argparse.Namespace) -> Path: diff --git a/redmine_ticket_contacts.py b/redmine_ticket_contacts.py index ff74564..547a37e 100644 --- a/redmine_ticket_contacts.py +++ b/redmine_ticket_contacts.py @@ -12,6 +12,7 @@ enough for sync + local search + show next to tickets. from __future__ import annotations import json +from redmine_cache_io import cache_lock, read_json, atomic_write_json from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterable @@ -34,12 +35,7 @@ class ContactCache: def load_manifest(self) -> dict[str, Any]: if not self.manifest_path.is_file(): return {"version": 1, "contacts": {}, "updated_at": None} - try: - data = json.loads(self.manifest_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return {"version": 1, "contacts": {}, "updated_at": None} - if not isinstance(data, dict): - return {"version": 1, "contacts": {}, "updated_at": None} + data = read_json(self.manifest_path) data.setdefault("version", 1) data.setdefault("contacts", {}) return data @@ -48,9 +44,7 @@ class ContactCache: data = dict(data) data["version"] = 1 data["updated_at"] = now_iso() - tmp = self.manifest_path.with_suffix(".tmp") - tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") - tmp.replace(self.manifest_path) + atomic_write_json(self.manifest_path, data) def should_fetch(self, stub: dict[str, Any]) -> bool: cid = stub.get("id") @@ -64,22 +58,23 @@ class ContactCache: return str(entry.get("updated_on") or "") != str(stub.get("updated_on") or "") def write_contact(self, contact: dict[str, Any]) -> Path: - cid = contact.get("id") - if cid is None: - raise ValueError("contact missing id") - path = self.contact_path(int(cid)) - path.write_text(json.dumps(contact, indent=2, sort_keys=True) + "\n", encoding="utf-8") - manifest = self.load_manifest() - contacts = manifest.setdefault("contacts", {}) - contacts[str(int(cid))] = { - "id": int(cid), - "updated_on": contact.get("updated_on"), - "last_synced": now_iso(), - "email": primary_email(contact), - "name": contact_display_name(contact), - } - self.save_manifest(manifest) - return path + with cache_lock(self.manifest_path): + cid = contact.get("id") + if cid is None: + raise ValueError("contact missing id") + path = self.contact_path(int(cid)) + atomic_write_json(path, contact) + manifest = self.load_manifest() + contacts = manifest.setdefault("contacts", {}) + contacts[str(int(cid))] = { + "id": int(cid), + "updated_on": contact.get("updated_on"), + "last_synced": now_iso(), + "email": primary_email(contact), + "name": contact_display_name(contact), + } + self.save_manifest(manifest) + return path def load_contact(self, contact_id: int) -> dict[str, Any]: path = self.contact_path(contact_id) diff --git a/redmine_ticket_nmi.py b/redmine_ticket_nmi.py index 3cbee5e..9ebe415 100644 --- a/redmine_ticket_nmi.py +++ b/redmine_ticket_nmi.py @@ -16,16 +16,21 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable, Optional -# Prefer env overrides for agents/profiles -DEFAULT_NMI_BIN = os.environ.get( - "NMI_CLI", - "/home/jason/projects/mrscClient-php/bin/nmi", -) -DEFAULT_ENV_FILE = os.environ.get( - "NMI_ENV_FILE", - "/home/jason/projects/mrscClient-php/examples/modern/.env", -) -DEFAULT_MODE = os.environ.get("NMI_MODE", "dev") +# Compatibility constants are environment-independent. Resolve overrides at call +# time so a TUI/worker cannot retain a profile captured during module import. +DEFAULT_NMI_BIN = str(Path(__file__).resolve().parent.parent / "mrscClient-php" / "bin" / "nmi") +DEFAULT_ENV_FILE = None # PHP owns default env-file discovery. +DEFAULT_MODE = "production" + + +def discover_nmi_bin(value: Optional[str] = None, *, environ=None) -> Path: + env = os.environ if environ is None else environ + selected = value or env.get("NMI_CLI") + if selected: + expanded = str(Path(selected).expanduser()) + return Path(shutil.which(expanded, path=env.get("PATH", os.defpath)) or expanded).resolve() + found = shutil.which("nmi", path=env.get("PATH", os.defpath)) + return Path(found or DEFAULT_NMI_BIN).resolve() # Entity types that map cleanly into NMI resolve ids _TRACKING_TYPES = { @@ -64,7 +69,7 @@ class NmiRuntimeProfile: """Resolved NMI execution settings plus a preflight result.""" mode: str - env_file: Path + env_file: Optional[Path] nmi_bin: Path endpoint: Optional[str] ready: bool @@ -79,61 +84,65 @@ def resolve_runtime_profile( endpoint: Optional[str] = None, environ: Optional[dict[str, str]] = None, ) -> NmiRuntimeProfile: - """Resolve the exact profile passed to PHP before an NMI subprocess starts. + """Run the actual PHP resolver offline; never equate file existence to readiness. - An env file that is empty or missing is always rejected. It is too easy for - the checked-in example `.env` to otherwise select a surprising dev/default - path while making the TUI look successful. + With no explicit env file, let NMI perform its own documented discovery. + Environment-only credentials are valid. No service requests are performed. """ - - env = os.environ if environ is None else environ + env = dict(os.environ if environ is None else environ) requested_mode = (mode or env.get("NMI_MODE") or DEFAULT_MODE).strip().lower() - normalized_mode = "production" if requested_mode in {"prod", "production"} else requested_mode - selected_env_file = Path(env_file or env.get("NMI_ENV_FILE") or DEFAULT_ENV_FILE).expanduser() - selected_nmi_bin = Path(nmi_bin or env.get("NMI_CLI") or DEFAULT_NMI_BIN).expanduser() + normalized_mode = "production" if requested_mode == "prod" else requested_mode + requested_file = env_file or env.get("NMI_ENV_FILE") + selected_env_file = Path(requested_file).expanduser().resolve() if requested_file else None + selected_nmi_bin = discover_nmi_bin(nmi_bin, environ=env) - mode_upper = normalized_mode.upper() - selected_endpoint = ( - endpoint - or env.get(f"NMI_ENDPOINT_{mode_upper}") - or env.get("NMI_ENDPOINT") - or None - ) + def failed(problem): + return NmiRuntimeProfile(normalized_mode, selected_env_file, selected_nmi_bin, endpoint, False, problem) - if not selected_env_file.is_file(): - return NmiRuntimeProfile( - mode=normalized_mode, - env_file=selected_env_file, - nmi_bin=selected_nmi_bin, - endpoint=selected_endpoint, - ready=False, - problem=f"NMI env file does not exist: {selected_env_file}", - ) - if selected_env_file.stat().st_size == 0: - return NmiRuntimeProfile( - mode=normalized_mode, - env_file=selected_env_file, - nmi_bin=selected_nmi_bin, - endpoint=selected_endpoint, - ready=False, - problem=f"NMI env file is empty: {selected_env_file}", - ) + if normalized_mode not in {"production", "dev"}: + return failed("NMI_MODE must be production or dev.") + if selected_env_file is not None: + try: + if not selected_env_file.is_file(): + return failed(f"NMI env file does not exist: {selected_env_file}") + if selected_env_file.stat().st_size == 0: + return failed(f"NMI env file is empty: {selected_env_file}") + except OSError as exc: + return failed(f"Cannot inspect NMI env file: {exc}") if not selected_nmi_bin.is_file(): - return NmiRuntimeProfile( - mode=normalized_mode, - env_file=selected_env_file, - nmi_bin=selected_nmi_bin, - endpoint=selected_endpoint, - ready=False, - problem=f"NMI CLI does not exist: {selected_nmi_bin}", - ) - + return failed(f"NMI CLI does not exist: {selected_nmi_bin}; set NMI_CLI.") + php = shutil.which("php", path=env.get("PATH", os.defpath)) + if not php: + return failed("PHP is not on PATH; NMI requires the php CLI.") + cmd = [php, str(selected_nmi_bin), "--doctor", "--json"] + # Preserve NMI env-file mode when no caller/environment mode was supplied. + if mode or env.get("NMI_MODE"): + cmd.append(f"--mode={normalized_mode}") + if selected_env_file: + cmd.append(f"--env-file={selected_env_file}") + if endpoint: + cmd.append(f"--endpoint={endpoint}") + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15, check=False, env=env) + except subprocess.TimeoutExpired: + return failed("NMI offline doctor timed out after 15 seconds.") + except OSError as exc: + return failed(f"Could not start NMI doctor: {exc}") + try: + data = json.loads(proc.stdout) + if not isinstance(data, dict): + raise ValueError("expected an object") + except (ValueError, TypeError): + return failed(f"NMI doctor returned invalid diagnostic JSON (exit {proc.returncode}); update NMI_CLI to a compatible bin/nmi.") + ready = proc.returncode == 0 and data.get("ready") is True + resolved_file = data.get("env_file") return NmiRuntimeProfile( - mode=normalized_mode, - env_file=selected_env_file, + mode=data.get("mode") or normalized_mode, + env_file=Path(resolved_file) if resolved_file else selected_env_file, nmi_bin=selected_nmi_bin, - endpoint=selected_endpoint, - ready=True, + endpoint=data.get("endpoint") or endpoint, + ready=ready, + problem=None if ready else (data.get("problem") or f"NMI doctor failed (exit {proc.returncode})."), ) @@ -223,30 +232,35 @@ def _php_bin() -> str: def run_nmi( args: list[str], *, - mode: str = DEFAULT_MODE, - env_file: str = DEFAULT_ENV_FILE, - nmi_bin: str = DEFAULT_NMI_BIN, + mode: Optional[str] = None, + env_file: Optional[str] = None, + nmi_bin: Optional[str] = None, endpoint: Optional[str] = None, timeout: int = 60, ) -> subprocess.CompletedProcess[str]: - cmd = [_php_bin(), nmi_bin, *args, f"--mode={mode}", f"--env-file={env_file}"] + cmd = [_php_bin(), str(discover_nmi_bin(nmi_bin)), *args] + selected_mode = mode or os.environ.get("NMI_MODE") + selected_file = env_file or os.environ.get("NMI_ENV_FILE") + if selected_mode: + cmd.append(f"--mode={selected_mode}") + if selected_file: + cmd.append(f"--env-file={Path(selected_file).expanduser().resolve()}") if endpoint: cmd.append(f"--endpoint={endpoint}") - return subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=timeout, - check=False, - ) + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 124, "", f"NMI command timed out after {timeout} seconds.") + except OSError as exc: + return subprocess.CompletedProcess(cmd, 127, "", f"Could not start NMI CLI: {exc}") def try_resolve_ids( plan: NmiLookupPlan, *, - mode: str = DEFAULT_MODE, - env_file: str = DEFAULT_ENV_FILE, - nmi_bin: str = DEFAULT_NMI_BIN, + mode: Optional[str] = None, + env_file: Optional[str] = None, + nmi_bin: Optional[str] = None, endpoint: Optional[str] = None, include_pictures: bool = False, max_attempts: int = 6, @@ -294,9 +308,9 @@ def try_resolve_ids( def try_cs_brief( plan: NmiLookupPlan, *, - mode: str = DEFAULT_MODE, - env_file: str = DEFAULT_ENV_FILE, - nmi_bin: str = DEFAULT_NMI_BIN, + mode: Optional[str] = None, + env_file: Optional[str] = None, + nmi_bin: Optional[str] = None, endpoint: Optional[str] = None, max_attempts: int = 6, ) -> dict[str, Any]: @@ -336,9 +350,9 @@ def brief_for_issue( issue: dict[str, Any], entities: list[dict[str, str]], *, - mode: str = DEFAULT_MODE, - env_file: str = DEFAULT_ENV_FILE, - nmi_bin: str = DEFAULT_NMI_BIN, + mode: Optional[str] = None, + env_file: Optional[str] = None, + nmi_bin: Optional[str] = None, endpoint: Optional[str] = None, with_history: bool = True, ) -> dict[str, Any]: @@ -350,7 +364,7 @@ def brief_for_issue( ) runtime = { "mode": profile.mode, - "env_file": str(profile.env_file), + "env_file": str(profile.env_file) if profile.env_file else None, "nmi_bin": str(profile.nmi_bin), "endpoint": profile.endpoint, "ready": profile.ready, @@ -400,6 +414,11 @@ def brief_for_issue( out["error"] = "no_nmi_candidates" return out + # Execute exactly the profile checked above, not import-time/caller defaults. + mode = profile.mode + env_file = str(profile.env_file) if profile.env_file else None + nmi_bin = str(profile.nmi_bin) + endpoint = profile.endpoint brief = try_cs_brief(plan, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint) out["brief"] = brief out["ok"] = bool(brief.get("ok")) @@ -504,33 +523,20 @@ def generate_nmi_links(entities: list[dict[str, str]]) -> list[str]: def try_item_history( item_no: str, *, - mode: str = DEFAULT_MODE, - env_file: str = DEFAULT_ENV_FILE, - nmi_bin: str = DEFAULT_NMI_BIN, + mode: Optional[str] = None, + env_file: Optional[str] = None, + nmi_bin: Optional[str] = None, endpoint: Optional[str] = None, limit: int = 50, snapshot: bool = True, ) -> dict[str, Any]: """Call nmi item history for a single item_no. Returns structured result.""" - cmd = [ - "php", - nmi_bin, - "item", - "history", - f"--item-no={item_no}", - f"--limit={limit}", - ] + args = ["item", "history", f"--item-no={item_no}", f"--limit={limit}"] if snapshot: - cmd.append("--snapshot") - if mode: - cmd.extend(["--mode", mode]) - if env_file: - cmd.extend(["--env-file", env_file]) - if endpoint: - cmd.extend(["--endpoint", endpoint]) - + args.append("--snapshot") try: - res = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + res = run_nmi(args, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint, timeout=30) + cmd = res.args if res.returncode != 0: return {"ok": False, "error": res.stderr.strip() or res.stdout.strip(), "cmd": cmd} # Try JSON first @@ -546,9 +552,9 @@ def try_item_history( def enrich_with_history( payload: dict[str, Any], *, - mode: str = DEFAULT_MODE, - env_file: str = DEFAULT_ENV_FILE, - nmi_bin: str = DEFAULT_NMI_BIN, + mode: Optional[str] = None, + env_file: Optional[str] = None, + nmi_bin: Optional[str] = None, endpoint: Optional[str] = None, ) -> dict[str, Any]: """If an item_no is present in the plan, fetch its history and attach.""" @@ -557,7 +563,7 @@ def enrich_with_history( if not item_no: return payload hist = try_item_history( - str(item_no), + str(item_no[0] if isinstance(item_no, list) else item_no), mode=mode, env_file=env_file, nmi_bin=nmi_bin, @@ -574,9 +580,9 @@ def try_auto_link_redmine_contact( tracking: list[str] | None = None, order_id: list[str] | None = None, item_no: list[str] | None = None, - mode: str = DEFAULT_MODE, - env_file: str = DEFAULT_ENV_FILE, - nmi_bin: str = DEFAULT_NMI_BIN, + mode: Optional[str] = None, + env_file: Optional[str] = None, + nmi_bin: Optional[str] = None, ) -> dict[str, Any]: """If we have a Redmine contact_id and identifiers from a ticket, try to resolve in NMI and automatically create the redmine_contact_links entry. @@ -602,13 +608,10 @@ def try_auto_link_redmine_contact( if not ids: return {"ok": False, "reason": "no_identifiers"} - cmd = [nmi_bin, "link-contact", "--redmine-contact-id", str(contact_id), "--mode", mode] - if env_file and env_file != DEFAULT_ENV_FILE: - cmd.extend(["--env-file", env_file]) - cmd.extend(ids) - + args = ["link-contact", "--redmine-contact-id", str(contact_id), *ids] try: - proc = subprocess.run(cmd, capture_output=True, text=True, timeout=90) + proc = run_nmi(args, mode=mode, env_file=env_file, nmi_bin=nmi_bin, timeout=90) + cmd = proc.args ok = proc.returncode == 0 result = { "ok": ok, diff --git a/redmine_ticket_textual_tui.py b/redmine_ticket_textual_tui.py index c38a65e..7499fd9 100644 --- a/redmine_ticket_textual_tui.py +++ b/redmine_ticket_textual_tui.py @@ -989,8 +989,8 @@ class RedmineTextualTUI(App): except Exception: pass - mode = os.environ.get("NMI_MODE", "dev") - env_file = os.environ.get("NMI_ENV_FILE") or "/home/jason/projects/mrscClient-php/examples/modern/.env" + mode = os.environ.get("NMI_MODE") + env_file = os.environ.get("NMI_ENV_FILE") if ch.entity_extract and issue and extract_entities_from_issue: try: diff --git a/redmine_ticket_tui.py b/redmine_ticket_tui.py index 71f13d0..97f035a 100644 --- a/redmine_ticket_tui.py +++ b/redmine_ticket_tui.py @@ -442,16 +442,16 @@ class RedmineTUI: except Exception: pass - mode = os.environ.get("NMI_MODE", "dev") - env_file = os.environ.get("NMI_ENV_FILE") or "/home/jason/projects/mrscClient-php/examples/modern/.env" + mode = os.environ.get("NMI_MODE") + env_file = os.environ.get("NMI_ENV_FILE") runtime_profile = ( resolve_runtime_profile(mode=mode, env_file=env_file) - if resolve_runtime_profile is not None + if ch.nmi_brief and resolve_runtime_profile is not None else None ) if runtime_profile is not None: mode = runtime_profile.mode - env_file = str(runtime_profile.env_file) + env_file = str(runtime_profile.env_file) if runtime_profile.env_file else None if ch.entity_extract and issue_for_special and extract_entities_from_issue: try: diff --git a/redmine_tickets.py b/redmine_tickets.py index 1f406c3..b6ec2a6 100755 --- a/redmine_tickets.py +++ b/redmine_tickets.py @@ -10,6 +10,8 @@ from __future__ import annotations import argparse import json +import copy +from redmine_cache_io import CacheError, cache_lock, read_json, atomic_write_json, update_manifest_atomic import os import re import sys @@ -1062,31 +1064,50 @@ class ManifestTicketStateStore: self.project = project self.manifest_path = cache_dir / "manifest.json" self.manifest = self.load_manifest() + self._baseline = copy.deepcopy(self.manifest) def load_manifest(self) -> dict[str, Any]: if not self.manifest_path.is_file(): return {"version": 1, "tickets": {}} - with self.manifest_path.open("r", encoding="utf-8") as handle: - data = json.load(handle) + data = read_json(self.manifest_path) data.setdefault("version", 1) data.setdefault("tickets", {}) return data def save(self) -> None: - self.cache_dir.mkdir(parents=True, exist_ok=True) - with self.manifest_path.open("w", encoding="utf-8") as handle: - json.dump(self.manifest, handle, indent=2, sort_keys=True) - handle.write("\n") + # Apply only locally changed leaves to current disk state. This preserves + # updates made by another long-lived cache instance since our last read. + def delta(current, before, after): + for key in before.keys() - after.keys(): + current.pop(key, None) + for key, value in after.items(): + if key not in before: + current[key] = copy.deepcopy(value) + elif isinstance(value, dict) and isinstance(before[key], dict): + target = current.setdefault(key, {}) + if not isinstance(target, dict): + target = current[key] = {} + delta(target, before[key], value) + elif value != before[key]: + current[key] = copy.deepcopy(value) + self.manifest = update_manifest_atomic( + self.manifest_path, lambda current: delta(current, self._baseline, self.manifest) + ) + self._baseline = copy.deepcopy(self.manifest) def get_ticket_state(self, issue_id: int | str) -> dict[str, Any] | None: - return self.manifest.get("tickets", {}).get(str(issue_id)) + # Readers see atomic snapshots and need no writer lock. + return self.load_manifest().get("tickets", {}).get(str(issue_id)) def upsert_ticket_state(self, issue_id: int | str, fields: dict[str, Any]) -> None: - ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {}) - ticket_entry.setdefault("project", self.project) - ticket_entry.setdefault("actions", {}) - ticket_entry.update(fields) - self.save() + with cache_lock(self.manifest_path): + self.manifest = self.load_manifest() + self._baseline = copy.deepcopy(self.manifest) + ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {}) + ticket_entry.setdefault("project", self.project) + ticket_entry.setdefault("actions", {}) + ticket_entry.update(fields) + self.save() def has_action(self, issue_id: int | str, action_name: str) -> bool: entry = self.get_ticket_state(issue_id) or {} @@ -1099,15 +1120,18 @@ class ManifestTicketStateStore: result: str, metadata: dict[str, Any] | None = None, ) -> None: - ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {}) - ticket_entry.setdefault("project", self.project) - actions = ticket_entry.setdefault("actions", {}) - actions[action_name] = { - "last_run": now_iso(), - "result": result, - "metadata": metadata or {}, - } - self.save() + with cache_lock(self.manifest_path): + self.manifest = self.load_manifest() + self._baseline = copy.deepcopy(self.manifest) + ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {}) + ticket_entry.setdefault("project", self.project) + actions = ticket_entry.setdefault("actions", {}) + actions[action_name] = { + "last_run": now_iso(), + "result": result, + "metadata": metadata or {}, + } + self.save() class TicketCache: @@ -1146,19 +1170,20 @@ class TicketCache: def write_issue(self, issue: dict[str, Any]) -> None: issue_id = issue["id"] - self.project_dir.mkdir(parents=True, exist_ok=True) - with self.issue_path(issue_id).open("w", encoding="utf-8") as handle: - json.dump(issue, handle, indent=2, sort_keys=True) - handle.write("\n") - self.state_store.upsert_ticket_state( - issue_id, - { - "project": self.project, - "updated_on": issue.get("updated_on"), - "last_synced": now_iso(), - "path": str(self.issue_path(issue_id)), - }, - ) + # Use one lock for a record + manifest transaction. Alternate stores keep + # their own state transaction semantics, but JSON still writes atomically. + lock_target = self.manifest_path or (self.cache_dir / "manifest.json") + with cache_lock(lock_target): + atomic_write_json(self.issue_path(issue_id), issue) + self.state_store.upsert_ticket_state( + issue_id, + { + "project": self.project, + "updated_on": issue.get("updated_on"), + "last_synced": now_iso(), + "path": str(self.issue_path(issue_id)), + }, + ) def iter_cached_issues(self) -> Iterable[dict[str, Any]]: if not self.project_dir.is_dir(): @@ -2322,10 +2347,14 @@ def run_walk_session(issues: list[dict[str, Any]], cache: TicketCache, pager: st def client_from_args(args: argparse.Namespace) -> TicketRedmineClient: api_key = os.getenv("REDMINE_API_KEY") - if not api_key and getattr(args, "api_key_file", None): - key_path = Path(args.api_key_file) - if key_path.is_file(): + if getattr(args, "api_key_file", None): + key_path = Path(args.api_key_file).expanduser() + try: api_key = key_path.read_text(encoding="utf-8").strip() + except OSError as exc: + raise RedmineError(f"Cannot read API key file {key_path}: {exc.strerror}") from exc + # The internal contacts client and attachment/OCR helpers share this key. + os.environ["REDMINE_API_KEY"] = api_key if not api_key: raise RedmineError("Set REDMINE_API_KEY in the environment or supply --api-key-file.") return TicketRedmineClient(base_url=args.base_url, api_key=api_key, project=args.project) @@ -3265,7 +3294,7 @@ KNOWN_COMMANDS = frozenset( # Parent-parser options that take a following value. Used when injecting the # default `sync` subcommand so sync-only flags like --updated-since parse correctly. -_PARENT_VALUE_OPTIONS = frozenset({"--base-url", "--project", "--cache-dir", "--config", "--allow-prod-writes"}) +_PARENT_VALUE_OPTIONS = frozenset({"--base-url", "--project", "--cache-dir", "--config", "--api-key-file", "--allow-prod-writes"}) def inject_default_sync_command(argv: list[str]) -> list[str]: @@ -3311,6 +3340,16 @@ def main(argv: list[str] | None = None) -> int: normalized_argv = inject_default_sync_command(raw_argv) parser = build_parser() args = parser.parse_args(normalized_argv) + if getattr(args, "api_key_file", None): + try: + key = Path(args.api_key_file).expanduser().read_text(encoding="utf-8").strip() + except OSError as exc: + print(f"error: Cannot read API key file {args.api_key_file}: {exc.strerror}", file=sys.stderr) + return 2 + if not key: + print("error: API key file is empty; supply REDMINE_API_KEY or a nonempty --api-key-file.", file=sys.stderr) + return 2 + os.environ["REDMINE_API_KEY"] = key # Post-process common globals so subparsers (tui/browse/...) that re-declare them # (to support "cmd --allow-prod-writes ...") don't leave them as None. @@ -3763,11 +3802,8 @@ def main(argv: list[str] | None = None) -> int: print(f"contact_id={cid}") print(f"handoff_hint=contact-show --contact-id {cid}") print(f"handoff_hint=contact-set --contact-id {cid} --add-tag maybe-customer") - print( - "handoff_hint_full_crm=" - f"redmine_contacts.py --base-url {args.base_url} --project {args.project} " - f"show --contact-id {cid}" - ) + from redmine_cli_runtime import contacts_handoff + print("handoff_hint_full_crm=" + contacts_handoff(args, cid)) # --fetch implies show; alone it used to silently do nothing (bug). want_show = bool(getattr(args, "show", False) or getattr(args, "fetch", False)) want_fetch = bool(getattr(args, "fetch", False)) @@ -4925,4 +4961,9 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": - raise SystemExit(main()) + try: + raise SystemExit(main()) + except (CacheError, RedmineError) as exc: + label = "Cache error" if isinstance(exc, CacheError) else "error" + print(f"{label}: {exc}", file=sys.stderr) + raise SystemExit(75 if isinstance(exc, CacheError) else 2) diff --git a/tests/test_cache_concurrency.py b/tests/test_cache_concurrency.py new file mode 100644 index 0000000..5c8ef63 --- /dev/null +++ b/tests/test_cache_concurrency.py @@ -0,0 +1,165 @@ +"""Real process tests: no placeholder success assertions or live API access.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import time +import pytest +from redmine_cache_io import CacheError, atomic_write_json, cache_lock + +ROOT=Path(__file__).resolve().parents[1] + +def spawn(code,*args,extra=None): + return subprocess.Popen([sys.executable,'-c',code,*map(str,args)],cwd=ROOT, + env={**os.environ,**(extra or {})},stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True) + + +def finish(proc): + out,err=proc.communicate(timeout=15) + assert proc.returncode==0,err + return out,err + + +def test_distinct_ticket_updates_from_stale_instances_survive(tmp_path): + code='''from redmine_tickets import TicketCache +from pathlib import Path +import sys,time +c=TicketCache(Path(sys.argv[1]),'customer-service') +print('ready',flush=True) +time.sleep(.2) +for n in range(8): c.write_issue({'id':int(sys.argv[2])*100+n,'subject':'fixture','updated_on':str(n)}) +''' + children=[spawn(code,tmp_path,n) for n in range(3)] + for child in children: + assert child.stdout.readline().strip()=='ready' + for child in children: finish(child) + data=json.loads((tmp_path/'manifest.json').read_text()) + assert len(data['tickets'])==24 + assert len(list((tmp_path/'customer-service').glob('*.json')))==24 + + +def test_distinct_contact_updates_survive(tmp_path): + code='''from redmine_ticket_contacts import ContactCache +from pathlib import Path +import sys,time +c=ContactCache(Path(sys.argv[1])) +for n in range(10): c.write_contact({'id':int(sys.argv[2])*100+n,'first_name':'fixture'}) +''' + children=[spawn(code,tmp_path,n) for n in range(3)] + for child in children: finish(child) + data=json.loads((tmp_path/'contacts/manifest.json').read_text()) + assert len(data['contacts'])==30 + + +def test_readers_see_complete_json_during_writes(tmp_path): + path=tmp_path/'data.json' + atomic_write_json(path,{'n':0,'blob':'x'*10000}) + child=spawn('''from redmine_cache_io import atomic_write_json +from pathlib import Path +import sys +for n in range(60): atomic_write_json(Path(sys.argv[1]),{'n':n,'blob':'x'*10000}) +''',path) + reads=0 + while child.poll() is None: + data=json.loads(path.read_text()) + assert len(data['blob'])==10000 + reads+=1 + finish(child) + assert reads>0 + + +def holder(path): + child=spawn('''from redmine_cache_io import cache_lock +from pathlib import Path +import sys,time +with cache_lock(Path(sys.argv[1])): + print('locked',flush=True) + time.sleep(20) +''',path) + assert child.stdout.readline().strip()=='locked' + return child + + +def test_contention_timeout_is_visible_and_nonzero(tmp_path): + path=tmp_path/'manifest.json'; child=holder(path) + try: + waiter=spawn('''from redmine_cache_io import atomic_write_json +from pathlib import Path +import sys +atomic_write_json(Path(sys.argv[1]),{'x':1}) +''',path,extra={'REDMINE_LOCK_TIMEOUT':'.12'}) + out,err=waiter.communicate(timeout=5) + assert waiter.returncode!=0 + assert 'Waiting for cache lock' in err + assert 'timeout after 0.12s' in err + finally: + child.kill();child.communicate(timeout=5) + + +def test_sigkill_releases_existing_lock_file(tmp_path): + path=tmp_path/'manifest.json'; child=holder(path) + child.kill(); child.communicate(timeout=5) + assert path.with_name(path.name+'.lock').exists() + waiter=spawn('''from redmine_cache_io import atomic_write_json +from pathlib import Path +import sys +atomic_write_json(Path(sys.argv[1]),{'recovered':True}) +''',path,extra={'REDMINE_LOCK_TIMEOUT':'.2'}) + finish(waiter) + assert json.loads(path.read_text())=={'recovered':True} + + +@pytest.mark.parametrize('value',['bogus','-1','nan','inf']) +def test_invalid_timeout_is_actionable(tmp_path,monkeypatch,value): + monkeypatch.setenv('REDMINE_LOCK_TIMEOUT',value) + with pytest.raises(CacheError,match='REDMINE_LOCK_TIMEOUT'): + atomic_write_json(tmp_path/'x.json',{}) + + +def test_stale_temporary_file_is_removed_on_next_lock(tmp_path): + path=tmp_path/'manifest.json' + stale=tmp_path/'.manifest.json.killed.tmp' + stale.write_text('partial') + atomic_write_json(path,{'recovered':True}) + assert not stale.exists() + assert json.loads(path.read_text())=={'recovered':True} + + +def test_nested_lock_and_atomic_failure_preserves_original(tmp_path): + path=tmp_path/'data.json' + atomic_write_json(path,{'old':True}) + with cache_lock(path,timeout=.1): + with pytest.raises(TypeError): atomic_write_json(path,{'bad':object()}) + assert json.loads(path.read_text())=={'old':True} + assert not list(tmp_path.glob('*.tmp')) + + +def test_corrupt_contact_manifest_not_reset(tmp_path): + from redmine_ticket_contacts import ContactCache + c=ContactCache(tmp_path) + c.manifest_path.write_text('{broken') + with pytest.raises(CacheError): c.write_contact({'id':1}) + assert c.manifest_path.read_text()=='{broken' + + +def test_snapshot_fetches_are_serialized_before_replacement(tmp_path): + code='''from redmine_contacts import snapshot_transaction,write_cache +from argparse import Namespace +from pathlib import Path +import sys,time +args=Namespace(cache=Path(sys.argv[1]),base_url='https://test.example',project='p',refresh=True) +@snapshot_transaction +def fetch(args): + print('fetch',flush=True) + time.sleep(float(sys.argv[2])) + write_cache(args.cache,args,[{'id':int(sys.argv[3])}]) +fetch(args) +''' + path=tmp_path/'contacts.json' + first=spawn(code,path,.3,1) + assert first.stdout.readline().strip()=='fetch' + second=spawn(code,path,0,2) + finish(first); _,err=finish(second) + assert 'Waiting for cache lock' in err + assert json.loads(path.read_text())['contacts']==[{'id':2}] diff --git a/tests/test_cli_runtime.py b/tests/test_cli_runtime.py new file mode 100644 index 0000000..0783063 --- /dev/null +++ b/tests/test_cli_runtime.py @@ -0,0 +1,187 @@ +"""Run real wrapper entry points in a relocated checkout with an isolated HOME.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import pytest + +ROOT=Path(__file__).resolve().parents[1] + +@pytest.fixture +def runtime(tmp_path): + root=tmp_path/'relocated checkout' + (root/'bin').mkdir(parents=True) + shutil.copy(ROOT/'redmine_cli_runtime.py',root) + for name in ('rcontacts-live','rtickets-live','rtl','rtd'): + shutil.copy(ROOT/'bin'/name,root/'bin'/name) + fake='import json,os,sys; print(json.dumps({"argv":sys.argv[1:],"env":{k:v for k,v in os.environ.items() if k.startswith("REDMINE_") or k.startswith("NMI_")}}))\n' + for name in ('redmine_tickets.py','redmine_contacts.py'): + (root/name).write_text(fake) + home=tmp_path/'home'; home.mkdir() + env={'HOME':str(home),'PATH':'/usr/bin:/bin','TMPDIR':os.environ['TMPDIR']} + def run(*args,command='rtl',extra=None): + return subprocess.run([str(root/'bin'/command),*args],cwd=tmp_path,env={**env,**(extra or {})},text=True,capture_output=True,timeout=10) + return root,home,run + + +def test_config_file_and_export_precedence(runtime): + root,home,run=runtime + (home/'.redmine.live').write_text('export REDMINE_API_KEY="fixture-secret"\nREDMINE_BASE_URL=https://file.example.test\nREDMINE_PROJECT=abc\nREDMINE_BASE_CACHE="data with spaces"\n') + p=run('--show-config',extra={'REDMINE_BASE_URL':'https://env.example.test'}) + assert p.returncode==0,p.stderr + d=json.loads(p.stdout) + assert d['base_url']=='https://env.example.test' + assert d['project']=='abc' + assert d['base_cache']==str(home/'data with spaces') + assert d['api_key_present'] + assert 'fixture-secret' not in p.stdout+p.stderr + assert 'environment' in d['sources']['base_url'] + p=run('--base-url','https://cli.example.test','--show-config',extra={'REDMINE_BASE_URL':'https://env.example.test'}) + assert json.loads(p.stdout)['base_url']=='https://cli.example.test' + + +def test_real_dispatch_preserves_stdin_arguments_and_key(runtime): + root,home,run=runtime + (home/'.redmine.live').write_text('REDMINE_API_KEY=fixture-secret\n') + p=run('--base-cache','my cache','search','--query','two words',command='rcontacts-live') + assert p.returncode==0,p.stderr + d=json.loads(p.stdout) + assert d['env']['REDMINE_API_KEY']=='fixture-secret' + assert d['argv'][-3:]==['search','--query','two words'] + cache=d['argv'][d['argv'].index('--cache')+1] + assert cache.endswith('my cache/redmine_contacts/rm.ldrprep.com_customer-service.json') + assert not Path(cache).is_dir() + + +def test_doctor_missing_key_nonzero_with_search_paths(runtime): + root,home,run=runtime + p=run('--doctor') + assert p.returncode==2 + d=json.loads(p.stdout) + assert not d['ready'] + assert 'REDMINE_API_KEY' in d['problems'][0] + assert str(home/'.redmine.live') in d['searched_env_paths'] + assert not (root/'cache').exists() + p=run('sync') + assert p.returncode==2 and 'REDMINE_API_KEY' in p.stderr + + +def test_explicit_missing_or_malformed_file_blocks(runtime): + root,home,run=runtime + assert run('--env-file','absent','--doctor').returncode==2 + file=home/'bad.env'; file.write_text('not an assignment\n') + p=run('--env-file',str(file),'--show-config') + assert p.returncode==2 and 'Malformed' in p.stderr + + +def test_help_works_without_config(runtime): + _,_,run=runtime + p=run('--env-file','missing','--help') + assert p.returncode==0 + assert 'REDMINE_API_KEY' in p.stdout + + +def test_aliases_match_and_dev_never_loads_live(runtime): + root,home,run=runtime + (home/'.redmine.live').write_text('REDMINE_API_KEY=fixture-live\n') + a=json.loads(run('--show-config',command='rtl').stdout) + b=json.loads(run('--show-config',command='rtickets-live').stdout) + assert a==b + d=run('--show-config',command='rtd') + data=json.loads(d.stdout) + assert not data['api_key_present'] + assert data['nmi_mode']=='dev' + assert 'DEVELOPMENT' in d.stderr + assert data['ticket_cache']==str(root/'cache-dev/redmine_tickets') + + +def test_cache_overrides_and_scope_isolation(runtime): + root,_,run=runtime + default=json.loads(run('--show-config').stdout) + assert default['ticket_cache']==str(root/'cache/redmine_tickets') + assert default['contacts_cache'].endswith('/cache/redmine_contacts/rm.ldrprep.com_customer-service.json') + assert default['contacts_cache_layout']=='namespaced-file' + other=json.loads(run('--project','meetings','--show-config').stdout) + assert other['ticket_cache']!=default['ticket_cache'] + host=json.loads(run('--base-url','https://other.example.test','--show-config').stdout) + assert host['ticket_cache']!=default['ticket_cache'] + exact=json.loads(run('--cache-dir','specific path','--show-config').stdout) + assert exact['ticket_cache'].endswith('/specific path') + + +def test_json_config_and_key_file_propagation(runtime): + root,home,run=runtime + config=home/'live.json'; key=home/'key'; key.write_text('fixture-key-file\n') + config.write_text(json.dumps({'project':'meetings','cache-dir':'mirror','api-key-file':'key'})) + p=run('--config',str(config),'search',extra={'REDMINE_PROJECT':'overridden'}) + assert p.returncode==0,p.stderr + d=json.loads(p.stdout) + assert d['env']['REDMINE_API_KEY']=='fixture-key-file' + assert d['env']['REDMINE_PROJECT']=='overridden' + assert '--config' not in d['argv'] + assert str(home/'mirror') in d['argv'] + + +def test_subcommand_config_is_not_global(runtime): + _,_,run=runtime + p=run('materialize-queues','--config','queues.json') + assert p.returncode==0,p.stderr + assert json.loads(p.stdout)['argv'][-3:]==['materialize-queues','--config','queues.json'] + + +def test_directory_contacts_cache_rejected(runtime): + root,_,run=runtime + p=run('--cache',str(root),'--show-config',command='rcontacts-live') + assert p.returncode==2 and 'JSON file' in p.stderr + + +def test_legacy_contacts_snapshot_is_preserved(runtime): + root,_,run=runtime + legacy=root/'cache/redmine_contacts' + legacy.parent.mkdir(parents=True) + legacy.write_text(json.dumps({'base_url':'https://rm.ldrprep.com','project':'customer-service','contacts':[]})) + d=json.loads(run('--show-config').stdout) + assert d['contacts_cache']==str(legacy) + assert d['contacts_cache_layout']=='legacy-single-file' + + +def test_contacts_handoff_preserves_resolved_context(tmp_path,monkeypatch): + import argparse + import shlex + from redmine_cli_runtime import contacts_handoff + monkeypatch.setenv('REDMINE_ENV_FILE',str(tmp_path/'profile with spaces.env')) + monkeypatch.setenv('REDMINE_CONTACTS_CACHE',str(tmp_path/'cache with spaces/contacts.json')) + args=argparse.Namespace(base_url='https://rm.example.test',project='meetings',cache_dir=tmp_path/'tickets',api_key_file=tmp_path/'key file') + cmd=shlex.split(contacts_handoff(args,42)) + assert cmd[cmd.index('--cache')+1]==str(tmp_path/'cache with spaces/contacts.json') + assert cmd[cmd.index('--env-file')+1]==str(tmp_path/'profile with spaces.env') + assert cmd[cmd.index('--api-key-file')+1]==str(tmp_path/'key file') + assert cmd[-3:]==['show','--contact-id','42'] + + +def test_actual_wrapper_invalid_ticket_query_is_clean_error(tmp_path): + env={'HOME':str(tmp_path),'PATH':'/usr/bin:/bin','TMPDIR':os.environ['TMPDIR'], + 'REDMINE_API_KEY':'fixture-only','REDMINE_BASE_CACHE':str(tmp_path/'cache')} + p=subprocess.run([str(ROOT/'bin/rtl'),'search','--where','id == -1'],env=env,cwd=tmp_path, + text=True,capture_output=True,timeout=10) + assert p.returncode==2 + assert 'Invalid --where expression' in p.stderr + assert 'Traceback' not in p.stderr + + +def test_actual_wrapper_to_actual_nmi_doctor(tmp_path): + cli=ROOT.parent/'mrscClient-php/bin/nmi' + if not cli.exists(): pytest.skip('Sibling NMI checkout unavailable') + profile=tmp_path/'nmi.env' + profile.write_text('NMI_ENDPOINT=https://fixture.example.test\nNMI_MRSC_ACCESS_CODE=fixture-access\nNMI_SECRETKEY=fixture-secret\n') + redmine=tmp_path/'redmine.env' + redmine.write_text(f'NMI_CLI={cli}\nNMI_ENV_FILE={profile}\nREDMINE_BASE_CACHE={tmp_path}/cache\n') + p=subprocess.run([str(ROOT/'bin/rtickets-live'),'--env-file',str(redmine),'--doctor-nmi'], + env={'HOME':str(tmp_path),'PATH':'/usr/bin:/bin'},cwd=tmp_path,text=True,capture_output=True,timeout=10) + assert p.returncode==0,p.stdout+p.stderr + d=json.loads(p.stdout) + assert d['ready'] and d['mode']=='production' + assert d['env_file']==str(profile) + assert not d['network_checked'] + assert 'fixture-secret' not in p.stdout+p.stderr diff --git a/tests/test_nmi_bridge_portable.py b/tests/test_nmi_bridge_portable.py new file mode 100644 index 0000000..a4a29a8 --- /dev/null +++ b/tests/test_nmi_bridge_portable.py @@ -0,0 +1,160 @@ +"""Portable runtime contract tests; fake child responses never contact NMI.""" +import json +import subprocess +from pathlib import Path + +import pytest +import redmine_ticket_nmi as bridge + + +def fake_cli(tmp_path): + p = tmp_path / 'space dir' / 'nmi' + p.parent.mkdir() + p.write_text('true,'mode'=>'dev','env_file'=>getenv('NMI_ENV_FILE'),'endpoint'=>'https://fixture.example.test','problem'=>null]); +} elseif (in_array('cs-brief',$argv,true)) { + echo "=== NMI CS BRIEF === fixture only\\n"; +} else { echo json_encode(['fixture'=>true]); } +''') + monkeypatch.setenv('NMI_CLI', str(cli)) + monkeypatch.setenv('NMI_ENV_FILE', str(env_file)) + monkeypatch.setenv('NMI_MODE', 'dev') + monkeypatch.setenv('BRIDGE_TEST_LOG', str(log)) + result = bridge.brief_for_issue({'id':1}, [{'type':'item_no','value':'200000001'}]) + assert result['ok'] and result['item_history']['ok'] + calls = [json.loads(line) for line in log.read_text().splitlines()] + assert len(calls) == 3 + for call in calls: + assert '--mode=dev' in call + assert '--env-file='+str(env_file) in call + assert '--item-no=200000001' in calls[-1] diff --git a/tests/test_nmi_runtime_profile.py b/tests/test_nmi_runtime_profile.py index ddc6e11..3e6e988 100644 --- a/tests/test_nmi_runtime_profile.py +++ b/tests/test_nmi_runtime_profile.py @@ -9,9 +9,18 @@ import pytest import redmine_ticket_nmi -def test_runtime_profile_honors_explicit_production_mode_and_env_file(tmp_path): +def test_runtime_profile_honors_explicit_production_mode_and_env_file(tmp_path, monkeypatch): production_env = tmp_path / "production.env" production_env.write_text("NMI_ENDPOINT_PRODUCTION=https://app.ldrprep.com/api.php\n") + # File existence alone is no longer readiness. Simulate the offline PHP + # diagnostic here; real CLI resolution is exercised by the integration tests. + import json + import subprocess + monkeypatch.setattr(redmine_ticket_nmi.subprocess, "run", lambda cmd, **kwargs: + subprocess.CompletedProcess(cmd, 0, json.dumps({ + "ready": True, "mode": "production", "env_file": str(production_env), + "endpoint": "https://app.ldrprep.com/api.php", "problem": None, + }), "")) profile = redmine_ticket_nmi.resolve_runtime_profile( environ={ diff --git a/tests/test_redmine_tickets.py b/tests/test_redmine_tickets.py index 247ed20..841ccbe 100644 --- a/tests/test_redmine_tickets.py +++ b/tests/test_redmine_tickets.py @@ -224,7 +224,7 @@ class RedmineTicketsSyncTest(unittest.TestCase): self.assertEqual(2, result.seen) self.assertEqual(1, result.skipped_cached) self.assertEqual(1, result.fetched) - self.assertEqual([(101, ("journals", "helpdesk"))], client.full_fetches) + self.assertEqual([(101, ("journals", "helpdesk", "attachments"))], client.full_fetches) self.assertTrue(cache.issue_path(101).is_file())