Harden portable Redmine CLI runtime and cache writes

This commit is contained in:
Jason Thistlethwaite
2026-10-18 03:18:36 -04:00
parent f6915face2
commit 2e686215ed
19 changed files with 1353 additions and 224 deletions
+5
View File
@@ -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 "$@"
+26
View File
@@ -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
+3 -4
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Muscle-memory wrapper: LAN/dev customer-service cache + base URL. # Portable tickets dev entry point. No shell evaluation of credentials.
# Usage: rtd note --issue-id 39305 --body 'test' --apply
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")"
exec "$ROOT/redmine_tickets.py" --config "$ROOT/configs/dev.json" "$@" exec python3 "$ROOT/redmine_cli_runtime.py" tickets dev "$@"
+5
View File
@@ -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 "$@"
+3 -7
View File
@@ -1,9 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Muscle-memory wrapper: prod customer-service cache + base URL. # Portable tickets live entry point. No shell evaluation of credentials.
# Usage: rtl search --where 'assigned_to IS NULL' --max-tickets 10
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")"
# rtl is the production Redmine wrapper. Keep an explicitly supplied mode, but exec python3 "$ROOT/redmine_cli_runtime.py" tickets live "$@"
# 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" "$@"
+87
View File
@@ -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.
+125
View File
@@ -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
+308
View File
@@ -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
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import argparse import argparse
import json import json
from functools import wraps
from redmine_cache_io import CacheError, cache_lock, atomic_write_json
import os import os
import re import re
import subprocess import subprocess
@@ -497,28 +499,29 @@ def main() -> int:
try: try:
if args.command == "fetch": if args.command == "fetch":
client = client_from_args(args) with cache_lock(resolve_cache_path(args)):
contacts = client.fetch_contacts(limit=args.limit) client = client_from_args(args)
filtered_contacts = filter_contacts_from_args(contacts, args) contacts = client.fetch_contacts(limit=args.limit)
cache_path = resolve_cache_path(args) filtered_contacts = filter_contacts_from_args(contacts, args)
effective_cache_mode = resolve_fetch_cache_mode(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 cache_contacts = filtered_contacts if effective_cache_mode == "filtered" else contacts
write_cache(cache_path, args, cache_contacts) write_cache(cache_path, args, cache_contacts)
if effective_cache_mode == "filtered": if effective_cache_mode == "filtered":
print( print(
f"Cached {len(filtered_contacts)} filtered contacts " f"Cached {len(filtered_contacts)} filtered contacts "
f"(from {len(contacts)} fetched) in {cache_path}" f"(from {len(contacts)} fetched) in {cache_path}"
) )
elif has_contact_filters(args): elif has_contact_filters(args):
print( print(
f"Cached {len(contacts)} contacts in {cache_path}. " f"Cached {len(contacts)} contacts in {cache_path}. "
f"{len(filtered_contacts)} contacts currently match the supplied filters." f"{len(filtered_contacts)} contacts currently match the supplied filters."
) )
else: else:
print(f"Cached {len(contacts)} contacts in {cache_path}") print(f"Cached {len(contacts)} contacts in {cache_path}")
return 0 return 0
if args.command == "search": if args.command == "search":
contacts = load_or_refresh(args) contacts = load_or_refresh(args)
@@ -627,7 +630,7 @@ def main() -> int:
client = client_from_args(args) client = client_from_args(args)
print_json(client.helpdesk_timeline(args.contact_id, args.limit)) print_json(client.helpdesk_timeline(args.contact_id, args.limit))
return 0 return 0
except RedmineError as exc: except (RedmineError, CacheError) as exc:
print(f"error: {exc}", file=sys.stderr) print(f"error: {exc}", file=sys.stderr)
return 1 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]]: def load_or_refresh(args: argparse.Namespace) -> list[dict[str, Any]]:
cache_path = resolve_cache_path(args) cache_path = resolve_cache_path(args)
if args.refresh or not cache_path.exists(): 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 return data
@snapshot_transaction
def resolve_contact_from_args(args: argparse.Namespace) -> dict[str, Any]: def resolve_contact_from_args(args: argparse.Namespace) -> dict[str, Any]:
source = args.source source = args.source
cache_path = resolve_cache_path(args) 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]]: def load_contacts_for_batch(args: argparse.Namespace) -> list[dict[str, Any]]:
cache_path = resolve_cache_path(args) 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()), "cached_at": int(time.time()),
"contacts": contacts, "contacts": contacts,
} }
with cache_path.open("w", encoding="utf-8") as handle: atomic_write_json(cache_path, payload)
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write("\n")
def resolve_cache_path(args: argparse.Namespace) -> Path: def resolve_cache_path(args: argparse.Namespace) -> Path:
+20 -25
View File
@@ -12,6 +12,7 @@ enough for sync + local search + show next to tickets.
from __future__ import annotations from __future__ import annotations
import json import json
from redmine_cache_io import cache_lock, read_json, atomic_write_json
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
@@ -34,12 +35,7 @@ class ContactCache:
def load_manifest(self) -> dict[str, Any]: def load_manifest(self) -> dict[str, Any]:
if not self.manifest_path.is_file(): if not self.manifest_path.is_file():
return {"version": 1, "contacts": {}, "updated_at": None} return {"version": 1, "contacts": {}, "updated_at": None}
try: data = read_json(self.manifest_path)
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.setdefault("version", 1) data.setdefault("version", 1)
data.setdefault("contacts", {}) data.setdefault("contacts", {})
return data return data
@@ -48,9 +44,7 @@ class ContactCache:
data = dict(data) data = dict(data)
data["version"] = 1 data["version"] = 1
data["updated_at"] = now_iso() data["updated_at"] = now_iso()
tmp = self.manifest_path.with_suffix(".tmp") atomic_write_json(self.manifest_path, data)
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
tmp.replace(self.manifest_path)
def should_fetch(self, stub: dict[str, Any]) -> bool: def should_fetch(self, stub: dict[str, Any]) -> bool:
cid = stub.get("id") cid = stub.get("id")
@@ -64,22 +58,23 @@ class ContactCache:
return str(entry.get("updated_on") or "") != str(stub.get("updated_on") or "") return str(entry.get("updated_on") or "") != str(stub.get("updated_on") or "")
def write_contact(self, contact: dict[str, Any]) -> Path: def write_contact(self, contact: dict[str, Any]) -> Path:
cid = contact.get("id") with cache_lock(self.manifest_path):
if cid is None: cid = contact.get("id")
raise ValueError("contact missing id") if cid is None:
path = self.contact_path(int(cid)) raise ValueError("contact missing id")
path.write_text(json.dumps(contact, indent=2, sort_keys=True) + "\n", encoding="utf-8") path = self.contact_path(int(cid))
manifest = self.load_manifest() atomic_write_json(path, contact)
contacts = manifest.setdefault("contacts", {}) manifest = self.load_manifest()
contacts[str(int(cid))] = { contacts = manifest.setdefault("contacts", {})
"id": int(cid), contacts[str(int(cid))] = {
"updated_on": contact.get("updated_on"), "id": int(cid),
"last_synced": now_iso(), "updated_on": contact.get("updated_on"),
"email": primary_email(contact), "last_synced": now_iso(),
"name": contact_display_name(contact), "email": primary_email(contact),
} "name": contact_display_name(contact),
self.save_manifest(manifest) }
return path self.save_manifest(manifest)
return path
def load_contact(self, contact_id: int) -> dict[str, Any]: def load_contact(self, contact_id: int) -> dict[str, Any]:
path = self.contact_path(contact_id) path = self.contact_path(contact_id)
+115 -112
View File
@@ -16,16 +16,21 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Iterable, Optional from typing import Any, Iterable, Optional
# Prefer env overrides for agents/profiles # Compatibility constants are environment-independent. Resolve overrides at call
DEFAULT_NMI_BIN = os.environ.get( # time so a TUI/worker cannot retain a profile captured during module import.
"NMI_CLI", DEFAULT_NMI_BIN = str(Path(__file__).resolve().parent.parent / "mrscClient-php" / "bin" / "nmi")
"/home/jason/projects/mrscClient-php/bin/nmi", DEFAULT_ENV_FILE = None # PHP owns default env-file discovery.
) DEFAULT_MODE = "production"
DEFAULT_ENV_FILE = os.environ.get(
"NMI_ENV_FILE",
"/home/jason/projects/mrscClient-php/examples/modern/.env", def discover_nmi_bin(value: Optional[str] = None, *, environ=None) -> Path:
) env = os.environ if environ is None else environ
DEFAULT_MODE = os.environ.get("NMI_MODE", "dev") 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 # Entity types that map cleanly into NMI resolve ids
_TRACKING_TYPES = { _TRACKING_TYPES = {
@@ -64,7 +69,7 @@ class NmiRuntimeProfile:
"""Resolved NMI execution settings plus a preflight result.""" """Resolved NMI execution settings plus a preflight result."""
mode: str mode: str
env_file: Path env_file: Optional[Path]
nmi_bin: Path nmi_bin: Path
endpoint: Optional[str] endpoint: Optional[str]
ready: bool ready: bool
@@ -79,61 +84,65 @@ def resolve_runtime_profile(
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
environ: Optional[dict[str, str]] = None, environ: Optional[dict[str, str]] = None,
) -> NmiRuntimeProfile: ) -> 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 With no explicit env file, let NMI perform its own documented discovery.
the checked-in example `.env` to otherwise select a surprising dev/default Environment-only credentials are valid. No service requests are performed.
path while making the TUI look successful.
""" """
env = dict(os.environ if environ is None else environ)
env = os.environ if environ is None else environ
requested_mode = (mode or env.get("NMI_MODE") or DEFAULT_MODE).strip().lower() 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 normalized_mode = "production" if requested_mode == "prod" else requested_mode
selected_env_file = Path(env_file or env.get("NMI_ENV_FILE") or DEFAULT_ENV_FILE).expanduser() requested_file = env_file or env.get("NMI_ENV_FILE")
selected_nmi_bin = Path(nmi_bin or env.get("NMI_CLI") or DEFAULT_NMI_BIN).expanduser() 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() def failed(problem):
selected_endpoint = ( return NmiRuntimeProfile(normalized_mode, selected_env_file, selected_nmi_bin, endpoint, False, problem)
endpoint
or env.get(f"NMI_ENDPOINT_{mode_upper}")
or env.get("NMI_ENDPOINT")
or None
)
if not selected_env_file.is_file(): if normalized_mode not in {"production", "dev"}:
return NmiRuntimeProfile( return failed("NMI_MODE must be production or dev.")
mode=normalized_mode, if selected_env_file is not None:
env_file=selected_env_file, try:
nmi_bin=selected_nmi_bin, if not selected_env_file.is_file():
endpoint=selected_endpoint, return failed(f"NMI env file does not exist: {selected_env_file}")
ready=False, if selected_env_file.stat().st_size == 0:
problem=f"NMI env file does not exist: {selected_env_file}", return failed(f"NMI env file is empty: {selected_env_file}")
) except OSError as exc:
if selected_env_file.stat().st_size == 0: return failed(f"Cannot inspect NMI env file: {exc}")
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 not selected_nmi_bin.is_file(): if not selected_nmi_bin.is_file():
return NmiRuntimeProfile( return failed(f"NMI CLI does not exist: {selected_nmi_bin}; set NMI_CLI.")
mode=normalized_mode, php = shutil.which("php", path=env.get("PATH", os.defpath))
env_file=selected_env_file, if not php:
nmi_bin=selected_nmi_bin, return failed("PHP is not on PATH; NMI requires the php CLI.")
endpoint=selected_endpoint, cmd = [php, str(selected_nmi_bin), "--doctor", "--json"]
ready=False, # Preserve NMI env-file mode when no caller/environment mode was supplied.
problem=f"NMI CLI does not exist: {selected_nmi_bin}", 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( return NmiRuntimeProfile(
mode=normalized_mode, mode=data.get("mode") or normalized_mode,
env_file=selected_env_file, env_file=Path(resolved_file) if resolved_file else selected_env_file,
nmi_bin=selected_nmi_bin, nmi_bin=selected_nmi_bin,
endpoint=selected_endpoint, endpoint=data.get("endpoint") or endpoint,
ready=True, 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( def run_nmi(
args: list[str], args: list[str],
*, *,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
timeout: int = 60, timeout: int = 60,
) -> subprocess.CompletedProcess[str]: ) -> 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: if endpoint:
cmd.append(f"--endpoint={endpoint}") cmd.append(f"--endpoint={endpoint}")
return subprocess.run( try:
cmd, return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
capture_output=True, except subprocess.TimeoutExpired:
text=True, return subprocess.CompletedProcess(cmd, 124, "", f"NMI command timed out after {timeout} seconds.")
timeout=timeout, except OSError as exc:
check=False, return subprocess.CompletedProcess(cmd, 127, "", f"Could not start NMI CLI: {exc}")
)
def try_resolve_ids( def try_resolve_ids(
plan: NmiLookupPlan, plan: NmiLookupPlan,
*, *,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
include_pictures: bool = False, include_pictures: bool = False,
max_attempts: int = 6, max_attempts: int = 6,
@@ -294,9 +308,9 @@ def try_resolve_ids(
def try_cs_brief( def try_cs_brief(
plan: NmiLookupPlan, plan: NmiLookupPlan,
*, *,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
max_attempts: int = 6, max_attempts: int = 6,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -336,9 +350,9 @@ def brief_for_issue(
issue: dict[str, Any], issue: dict[str, Any],
entities: list[dict[str, str]], entities: list[dict[str, str]],
*, *,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
with_history: bool = True, with_history: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -350,7 +364,7 @@ def brief_for_issue(
) )
runtime = { runtime = {
"mode": profile.mode, "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), "nmi_bin": str(profile.nmi_bin),
"endpoint": profile.endpoint, "endpoint": profile.endpoint,
"ready": profile.ready, "ready": profile.ready,
@@ -400,6 +414,11 @@ def brief_for_issue(
out["error"] = "no_nmi_candidates" out["error"] = "no_nmi_candidates"
return out 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) brief = try_cs_brief(plan, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint)
out["brief"] = brief out["brief"] = brief
out["ok"] = bool(brief.get("ok")) 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( def try_item_history(
item_no: str, item_no: str,
*, *,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
limit: int = 50, limit: int = 50,
snapshot: bool = True, snapshot: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Call nmi item history for a single item_no. Returns structured result.""" """Call nmi item history for a single item_no. Returns structured result."""
cmd = [ args = ["item", "history", f"--item-no={item_no}", f"--limit={limit}"]
"php",
nmi_bin,
"item",
"history",
f"--item-no={item_no}",
f"--limit={limit}",
]
if snapshot: if snapshot:
cmd.append("--snapshot") args.append("--snapshot")
if mode:
cmd.extend(["--mode", mode])
if env_file:
cmd.extend(["--env-file", env_file])
if endpoint:
cmd.extend(["--endpoint", endpoint])
try: 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: if res.returncode != 0:
return {"ok": False, "error": res.stderr.strip() or res.stdout.strip(), "cmd": cmd} return {"ok": False, "error": res.stderr.strip() or res.stdout.strip(), "cmd": cmd}
# Try JSON first # Try JSON first
@@ -546,9 +552,9 @@ def try_item_history(
def enrich_with_history( def enrich_with_history(
payload: dict[str, Any], payload: dict[str, Any],
*, *,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""If an item_no is present in the plan, fetch its history and attach.""" """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: if not item_no:
return payload return payload
hist = try_item_history( hist = try_item_history(
str(item_no), str(item_no[0] if isinstance(item_no, list) else item_no),
mode=mode, mode=mode,
env_file=env_file, env_file=env_file,
nmi_bin=nmi_bin, nmi_bin=nmi_bin,
@@ -574,9 +580,9 @@ def try_auto_link_redmine_contact(
tracking: list[str] | None = None, tracking: list[str] | None = None,
order_id: list[str] | None = None, order_id: list[str] | None = None,
item_no: list[str] | None = None, item_no: list[str] | None = None,
mode: str = DEFAULT_MODE, mode: Optional[str] = None,
env_file: str = DEFAULT_ENV_FILE, env_file: Optional[str] = None,
nmi_bin: str = DEFAULT_NMI_BIN, nmi_bin: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""If we have a Redmine contact_id and identifiers from a ticket, try to resolve in NMI """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. and automatically create the redmine_contact_links entry.
@@ -602,13 +608,10 @@ def try_auto_link_redmine_contact(
if not ids: if not ids:
return {"ok": False, "reason": "no_identifiers"} return {"ok": False, "reason": "no_identifiers"}
cmd = [nmi_bin, "link-contact", "--redmine-contact-id", str(contact_id), "--mode", mode] args = ["link-contact", "--redmine-contact-id", str(contact_id), *ids]
if env_file and env_file != DEFAULT_ENV_FILE:
cmd.extend(["--env-file", env_file])
cmd.extend(ids)
try: 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 ok = proc.returncode == 0
result = { result = {
"ok": ok, "ok": ok,
+2 -2
View File
@@ -989,8 +989,8 @@ class RedmineTextualTUI(App):
except Exception: except Exception:
pass pass
mode = os.environ.get("NMI_MODE", "dev") mode = os.environ.get("NMI_MODE")
env_file = os.environ.get("NMI_ENV_FILE") or "/home/jason/projects/mrscClient-php/examples/modern/.env" env_file = os.environ.get("NMI_ENV_FILE")
if ch.entity_extract and issue and extract_entities_from_issue: if ch.entity_extract and issue and extract_entities_from_issue:
try: try:
+4 -4
View File
@@ -442,16 +442,16 @@ class RedmineTUI:
except Exception: except Exception:
pass pass
mode = os.environ.get("NMI_MODE", "dev") mode = os.environ.get("NMI_MODE")
env_file = os.environ.get("NMI_ENV_FILE") or "/home/jason/projects/mrscClient-php/examples/modern/.env" env_file = os.environ.get("NMI_ENV_FILE")
runtime_profile = ( runtime_profile = (
resolve_runtime_profile(mode=mode, env_file=env_file) 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 else None
) )
if runtime_profile is not None: if runtime_profile is not None:
mode = runtime_profile.mode 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: if ch.entity_extract and issue_for_special and extract_entities_from_issue:
try: try:
+85 -44
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import argparse import argparse
import json import json
import copy
from redmine_cache_io import CacheError, cache_lock, read_json, atomic_write_json, update_manifest_atomic
import os import os
import re import re
import sys import sys
@@ -1062,31 +1064,50 @@ class ManifestTicketStateStore:
self.project = project self.project = project
self.manifest_path = cache_dir / "manifest.json" self.manifest_path = cache_dir / "manifest.json"
self.manifest = self.load_manifest() self.manifest = self.load_manifest()
self._baseline = copy.deepcopy(self.manifest)
def load_manifest(self) -> dict[str, Any]: def load_manifest(self) -> dict[str, Any]:
if not self.manifest_path.is_file(): if not self.manifest_path.is_file():
return {"version": 1, "tickets": {}} return {"version": 1, "tickets": {}}
with self.manifest_path.open("r", encoding="utf-8") as handle: data = read_json(self.manifest_path)
data = json.load(handle)
data.setdefault("version", 1) data.setdefault("version", 1)
data.setdefault("tickets", {}) data.setdefault("tickets", {})
return data return data
def save(self) -> None: def save(self) -> None:
self.cache_dir.mkdir(parents=True, exist_ok=True) # Apply only locally changed leaves to current disk state. This preserves
with self.manifest_path.open("w", encoding="utf-8") as handle: # updates made by another long-lived cache instance since our last read.
json.dump(self.manifest, handle, indent=2, sort_keys=True) def delta(current, before, after):
handle.write("\n") 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: 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: def upsert_ticket_state(self, issue_id: int | str, fields: dict[str, Any]) -> None:
ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {}) with cache_lock(self.manifest_path):
ticket_entry.setdefault("project", self.project) self.manifest = self.load_manifest()
ticket_entry.setdefault("actions", {}) self._baseline = copy.deepcopy(self.manifest)
ticket_entry.update(fields) ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {})
self.save() 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: def has_action(self, issue_id: int | str, action_name: str) -> bool:
entry = self.get_ticket_state(issue_id) or {} entry = self.get_ticket_state(issue_id) or {}
@@ -1099,15 +1120,18 @@ class ManifestTicketStateStore:
result: str, result: str,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
) -> None: ) -> None:
ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {}) with cache_lock(self.manifest_path):
ticket_entry.setdefault("project", self.project) self.manifest = self.load_manifest()
actions = ticket_entry.setdefault("actions", {}) self._baseline = copy.deepcopy(self.manifest)
actions[action_name] = { ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {})
"last_run": now_iso(), ticket_entry.setdefault("project", self.project)
"result": result, actions = ticket_entry.setdefault("actions", {})
"metadata": metadata or {}, actions[action_name] = {
} "last_run": now_iso(),
self.save() "result": result,
"metadata": metadata or {},
}
self.save()
class TicketCache: class TicketCache:
@@ -1146,19 +1170,20 @@ class TicketCache:
def write_issue(self, issue: dict[str, Any]) -> None: def write_issue(self, issue: dict[str, Any]) -> None:
issue_id = issue["id"] issue_id = issue["id"]
self.project_dir.mkdir(parents=True, exist_ok=True) # Use one lock for a record + manifest transaction. Alternate stores keep
with self.issue_path(issue_id).open("w", encoding="utf-8") as handle: # their own state transaction semantics, but JSON still writes atomically.
json.dump(issue, handle, indent=2, sort_keys=True) lock_target = self.manifest_path or (self.cache_dir / "manifest.json")
handle.write("\n") with cache_lock(lock_target):
self.state_store.upsert_ticket_state( atomic_write_json(self.issue_path(issue_id), issue)
issue_id, self.state_store.upsert_ticket_state(
{ issue_id,
"project": self.project, {
"updated_on": issue.get("updated_on"), "project": self.project,
"last_synced": now_iso(), "updated_on": issue.get("updated_on"),
"path": str(self.issue_path(issue_id)), "last_synced": now_iso(),
}, "path": str(self.issue_path(issue_id)),
) },
)
def iter_cached_issues(self) -> Iterable[dict[str, Any]]: def iter_cached_issues(self) -> Iterable[dict[str, Any]]:
if not self.project_dir.is_dir(): 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: def client_from_args(args: argparse.Namespace) -> TicketRedmineClient:
api_key = os.getenv("REDMINE_API_KEY") api_key = os.getenv("REDMINE_API_KEY")
if not api_key and getattr(args, "api_key_file", None): if getattr(args, "api_key_file", None):
key_path = Path(args.api_key_file) key_path = Path(args.api_key_file).expanduser()
if key_path.is_file(): try:
api_key = key_path.read_text(encoding="utf-8").strip() 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: if not api_key:
raise RedmineError("Set REDMINE_API_KEY in the environment or supply --api-key-file.") 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) 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 # Parent-parser options that take a following value. Used when injecting the
# default `sync` subcommand so sync-only flags like --updated-since parse correctly. # 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]: 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) normalized_argv = inject_default_sync_command(raw_argv)
parser = build_parser() parser = build_parser()
args = parser.parse_args(normalized_argv) 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 # Post-process common globals so subparsers (tui/browse/...) that re-declare them
# (to support "cmd --allow-prod-writes ...") don't leave them as None. # (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"contact_id={cid}")
print(f"handoff_hint=contact-show --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(f"handoff_hint=contact-set --contact-id {cid} --add-tag maybe-customer")
print( from redmine_cli_runtime import contacts_handoff
"handoff_hint_full_crm=" print("handoff_hint_full_crm=" + contacts_handoff(args, cid))
f"redmine_contacts.py --base-url {args.base_url} --project {args.project} "
f"show --contact-id {cid}"
)
# --fetch implies show; alone it used to silently do nothing (bug). # --fetch implies show; alone it used to silently do nothing (bug).
want_show = bool(getattr(args, "show", False) or getattr(args, "fetch", False)) want_show = bool(getattr(args, "show", False) or getattr(args, "fetch", False))
want_fetch = bool(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__": 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)
+165
View File
@@ -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}]
+187
View File
@@ -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
+160
View File
@@ -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]
+10 -1
View File
@@ -9,9 +9,18 @@ import pytest
import redmine_ticket_nmi 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 = tmp_path / "production.env"
production_env.write_text("NMI_ENDPOINT_PRODUCTION=https://app.ldrprep.com/api.php\n") 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( profile = redmine_ticket_nmi.resolve_runtime_profile(
environ={ environ={
+1 -1
View File
@@ -224,7 +224,7 @@ class RedmineTicketsSyncTest(unittest.TestCase):
self.assertEqual(2, result.seen) self.assertEqual(2, result.seen)
self.assertEqual(1, result.skipped_cached) self.assertEqual(1, result.skipped_cached)
self.assertEqual(1, result.fetched) 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()) self.assertTrue(cache.issue_path(101).is_file())