Harden portable Redmine CLI runtime and cache writes
This commit is contained in:
Executable
+5
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
|
||||
Executable
+5
@@ -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 "$@"
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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 `<root>/redmine_tickets/`.
|
||||
- Other endpoint/project combinations get deterministic distinct ticket directories.
|
||||
- Fresh contacts setups use `<root>/redmine_contacts/<host>_<project>.json`.
|
||||
- If historical `<root>/redmine_contacts` is already a **file** for normal live use, the wrapper preserves and uses it. There is no automatic migration.
|
||||
- Tickets’ `<ticket-cache>/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.
|
||||
@@ -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
|
||||
@@ -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())
|
||||
+42
-24
@@ -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:
|
||||
|
||||
+20
-25
@@ -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)
|
||||
|
||||
+115
-112
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+85
-44
@@ -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)
|
||||
|
||||
@@ -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}]
|
||||
@@ -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
|
||||
@@ -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('<?php // test fixture\n')
|
||||
return p
|
||||
|
||||
|
||||
def doctor_result(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 0, json.dumps({
|
||||
'ready': True, 'mode': 'production', 'env_file': None,
|
||||
'endpoint': 'https://nmi.example.test/api', 'problem': None,
|
||||
}), '')
|
||||
|
||||
|
||||
def test_environment_only_runtime_uses_actual_doctor(monkeypatch, tmp_path):
|
||||
cli = fake_cli(tmp_path)
|
||||
calls = []
|
||||
def invoke(cmd, **kwargs):
|
||||
calls.append((cmd, kwargs))
|
||||
return doctor_result(cmd, **kwargs)
|
||||
monkeypatch.setattr(bridge.subprocess, 'run', invoke)
|
||||
profile = bridge.resolve_runtime_profile(environ={
|
||||
'NMI_CLI': str(cli), 'NMI_SECRETKEY': 'fixture-secret',
|
||||
'NMI_MRSC_ACCESS_CODE': 'fixture-access',
|
||||
'NMI_ENDPOINT': 'https://nmi.example.test/api',
|
||||
})
|
||||
assert profile.ready
|
||||
assert profile.mode == 'production'
|
||||
assert profile.env_file is None
|
||||
assert len(calls) == 1
|
||||
cmd, kwargs = calls[0]
|
||||
assert '--doctor' in cmd and '--json' in cmd
|
||||
assert not any(arg.startswith('--env-file') for arg in cmd)
|
||||
assert 'fixture-secret' not in ' '.join(cmd)
|
||||
assert kwargs['env']['NMI_SECRETKEY'] == 'fixture-secret'
|
||||
|
||||
|
||||
def test_mode_and_binary_are_resolved_at_call_time(monkeypatch, tmp_path):
|
||||
cli = fake_cli(tmp_path)
|
||||
monkeypatch.setenv('NMI_CLI', str(cli))
|
||||
monkeypatch.setenv('NMI_MODE', 'dev')
|
||||
monkeypatch.delenv('NMI_ENV_FILE', raising=False)
|
||||
calls = []
|
||||
def invoke(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, 0, '{}', '')
|
||||
monkeypatch.setattr(bridge.subprocess, 'run', invoke)
|
||||
bridge.run_nmi(['resolve', '--item-no=200000001'])
|
||||
assert str(cli) in calls[0]
|
||||
assert '--mode=dev' in calls[0]
|
||||
assert not any('None' in value for value in calls[0])
|
||||
|
||||
|
||||
def test_explicit_missing_env_blocks_child(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(bridge.subprocess, 'run', lambda *a, **k: pytest.fail('must not spawn'))
|
||||
p = bridge.resolve_runtime_profile(env_file=str(tmp_path / 'absent'), nmi_bin=str(fake_cli(tmp_path)), environ={})
|
||||
assert not p.ready
|
||||
assert 'does not exist' in p.problem
|
||||
|
||||
|
||||
def test_doctor_nonzero_is_not_ready(monkeypatch, tmp_path):
|
||||
def invoke(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 2, json.dumps({'ready': False, 'problem': 'NMI_SECRETKEY is missing'}), '')
|
||||
monkeypatch.setattr(bridge.subprocess, 'run', invoke)
|
||||
p = bridge.resolve_runtime_profile(nmi_bin=str(fake_cli(tmp_path)), environ={})
|
||||
assert not p.ready
|
||||
assert 'NMI_SECRETKEY' in p.problem
|
||||
|
||||
|
||||
def test_doctor_timeout_actionable(monkeypatch, tmp_path):
|
||||
def invoke(cmd, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd, 15)
|
||||
monkeypatch.setattr(bridge.subprocess, 'run', invoke)
|
||||
p = bridge.resolve_runtime_profile(nmi_bin=str(fake_cli(tmp_path)), environ={})
|
||||
assert not p.ready
|
||||
assert 'timed out' in p.problem.lower()
|
||||
|
||||
|
||||
def test_brief_executes_resolved_profile_not_import_defaults(monkeypatch, tmp_path):
|
||||
cli = fake_cli(tmp_path)
|
||||
env_file = tmp_path / 'selected.env'
|
||||
env_file.write_text('NMI_MODE=production\n')
|
||||
profile = bridge.NmiRuntimeProfile('production', env_file, cli, 'https://nmi.example.test/api', True)
|
||||
monkeypatch.setattr(bridge, 'resolve_runtime_profile', lambda **kwargs: profile)
|
||||
calls = []
|
||||
def brief(plan, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return {'ok': True, 'text': '=== NMI CS BRIEF ===\nfixture'}
|
||||
monkeypatch.setattr(bridge, 'try_cs_brief', brief)
|
||||
result = bridge.brief_for_issue({'id': 1}, [{'type': 'item_no', 'value': '200000001'}], with_history=False)
|
||||
assert result['ok']
|
||||
assert calls[0]['nmi_bin'] == str(cli)
|
||||
assert calls[0]['env_file'] == str(env_file)
|
||||
assert calls[0]['endpoint'] == profile.endpoint
|
||||
|
||||
|
||||
def test_history_uses_one_item_identifier(monkeypatch):
|
||||
seen = []
|
||||
monkeypatch.setattr(bridge, 'try_item_history', lambda item, **kwargs: seen.append(item) or {'ok': True})
|
||||
bridge.enrich_with_history({'plan': {'item_no': ['200000001', '200000002']}})
|
||||
assert seen == ['200000001']
|
||||
|
||||
|
||||
def test_real_php_doctor_integration(tmp_path):
|
||||
"""Executes sibling NMI's real config resolver, not its network API."""
|
||||
import os
|
||||
import shutil
|
||||
cli = Path(bridge.DEFAULT_NMI_BIN)
|
||||
if not cli.is_file() or not shutil.which('php'):
|
||||
pytest.skip('Sibling NMI/PHP not available')
|
||||
env_file = tmp_path / 'test profile.env'
|
||||
env_file.write_text('NMI_MRSC_ACCESS_CODE=fixture-access\nNMI_SECRETKEY=fixture-secret\nNMI_ENDPOINT=https://fixture.example.test\n')
|
||||
profile = bridge.resolve_runtime_profile(environ={
|
||||
'HOME': str(tmp_path), 'PATH': '/usr/bin:/bin', 'NMI_CLI': str(cli),
|
||||
'NMI_ENV_FILE': str(env_file),
|
||||
})
|
||||
assert profile.ready, profile.problem
|
||||
assert profile.mode == 'production'
|
||||
assert profile.env_file == env_file
|
||||
assert profile.endpoint == 'https://fixture.example.test'
|
||||
|
||||
|
||||
def test_real_php_child_receives_same_runtime_for_brief_and_history(tmp_path, monkeypatch):
|
||||
"""Fake PHP service-facing CLI; exercises real subprocess argv forwarding."""
|
||||
import shutil
|
||||
if not shutil.which('php'):
|
||||
pytest.skip('PHP unavailable')
|
||||
cli = tmp_path / 'fake nmi.php'
|
||||
log = tmp_path / 'calls.jsonl'
|
||||
env_file = tmp_path / 'profile.env'
|
||||
env_file.write_text('NMI_MODE=dev\n')
|
||||
cli.write_text('''<?php
|
||||
file_put_contents(getenv('BRIDGE_TEST_LOG'), json_encode($argv)."\\n", FILE_APPEND);
|
||||
if (in_array('--doctor', $argv, true)) {
|
||||
echo json_encode(['ready'=>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]
|
||||
@@ -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={
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user