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
# 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 "$@"
+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
# 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 "$@"
+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())
+22 -4
View File
@@ -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,6 +499,7 @@ def main() -> int:
try:
if args.command == "fetch":
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)
@@ -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:
+5 -10
View File
@@ -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,11 +58,12 @@ class ContactCache:
return str(entry.get("updated_on") or "") != str(stub.get("updated_on") or "")
def write_contact(self, contact: dict[str, Any]) -> 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))
path.write_text(json.dumps(contact, indent=2, sort_keys=True) + "\n", encoding="utf-8")
atomic_write_json(path, contact)
manifest = self.load_manifest()
contacts = manifest.setdefault("contacts", {})
contacts[str(int(cid))] = {
+113 -110
View File
@@ -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 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 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}",
)
return failed(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}",
)
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 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=False,
problem=f"NMI CLI does not exist: {selected_nmi_bin}",
)
return NmiRuntimeProfile(
mode=normalized_mode,
env_file=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,
+2 -2
View File
@@ -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:
+4 -4
View File
@@ -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:
+61 -20
View File
@@ -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,26 +1064,45 @@ 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:
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", {})
@@ -1099,6 +1120,9 @@ class ManifestTicketStateStore:
result: str,
metadata: dict[str, Any] | None = None,
) -> None:
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", {})
@@ -1146,10 +1170,11 @@ 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")
# 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,
{
@@ -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__":
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
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={
+1 -1
View File
@@ -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())