# Power-user Redmine client — usage (`redmine_tickets.py`) Scriptable local client for **humans and agents**: durable ticket cache, property search, Markdown view, and interactive walk. | Env | Redmine | Cache | |-----|---------|--------| | **Prod** | `https://rm.ldrprep.com` | `/home/jason/projects/redmine/cache/redmine_tickets` | | **Dev / mutation testing** | LAN (e.g. `192.168.50.170` / `retired.int.ldrprep.com`) | `/home/jason/projects/redmine/cache-dev/redmine_tickets` | **Safety:** treat prod cache as read/sync. Test any **writes** on LAN first. Mutations impersonate `rebot` when implemented. Related: - Roadmap index: [raw-notes/features-for-redmine_tickets-sync.md](raw-notes/features-for-redmine_tickets-sync.md) - Pre-MVP search snapshot: [2026-07-14_localsearch_before_sanity.md](2026-07-14_localsearch_before_sanity.md) - Sync plan: [ticket_sync_tool_plan.md](ticket_sync_tool_plan.md) - Cron scripts: `cron-scripts/sync-live-customer-service.sh`, `cron-scripts/sync-dev-customer-service.sh` --- ## Setup ```bash cd /home/jason/projects/redmine export REDMINE_API_KEY=… # required for sync / live API only CACHE=/home/jason/projects/redmine/cache/redmine_tickets PROJECT=customer-service ``` Shorthand used below: ```bash RT="./redmine_tickets.py --project $PROJECT --cache-dir $CACHE" ``` --- ## Sync (fill / refresh cache) Remote filters hit Redmine; local cache is updated only for tickets whose `updated_on` changed. ```bash # Initial / catch-up window $RT sync --remote-status-id '*' --remote-updated-since 30d --run-log # Frequent refresh (preferred after warm cache) $RT sync --remote-status-id '*' --remote-updated-since 2h --run-log --quiet ``` | Flag | Meaning | |------|---------| | `--remote-status-id` / `--status-id` | Server: `*`, `open`, `closed`, or numeric | | `--remote-updated-since` | Server: `30d`, `2h`, `YYYY-MM-DD`, … → `updated_on >= …` | | `--remote-updated-on` | Server: raw Redmine operator string | | `--run-log` | Ring-buffer stats → `$CACHE/run-stats.json` | | `--run-log-keep N` | Keep last N runs (default 3; cron uses 20) | | `--quiet` | Less progress noise | **Do not** use local `--updated-since` as a substitute for remote filters on large projects (local only post-filters stubs already listed). Cron note: live scripts currently still use `30d` on a short interval (~3–4s after warm-up). **Tighten later** to `2h`/`6h`. --- ## Search (local cache only) No API key required. Only sees tickets already on disk. ## Search table columns Default table (tab-separated): ```text #id updated_on status assignee contact category control_tags subject ``` Contact comes from `helpdesk_ticket.contact.email` (or name/company/from_address). ### Classic filters ```bash $RT search --status New --max-tickets 50 $RT search --updated-since 2026-07-01 --order newest-updated-first ``` ### Property queries (`--where`) Engine: `redmine_ticket_query.py`. Repeatable `--where` = **AND**. ```bash $RT search --where 'subject.includes("account")' --max-tickets 20 $RT search --where 'assigned_to IS NULL' --max-tickets 10 $RT search \ --where 'helpdesk_ticket.contact.email == helpdesk_ticket.contact.company' \ --ids-only $RT search \ --where '(subject.includes("verification code") OR subject.includes("magic link")) AND assigned_to IS NULL' ``` | Construct | Example | |-----------|---------| | Equality | `status.name == "New"` | | Inequality | `assigned_to != "Jason T."` | | Null | `assigned_to IS NULL` / `IS NOT NULL` | | Substring | `subject.includes("rate sheet")` | | Relative time | `updated_on.since("5 days")` | | Path == path | `helpdesk_ticket.contact.email == helpdesk_ticket.contact.company` | | Boolean | `AND` `OR` `&&` `||` + `()` | Nested dicts compared to strings use `.name` (or email/id) when present. ### Output modes | Flag | Output | |------|--------| | (default) | Table: id, updated_on, status, category, subject | | `--json` | Full issue JSON array | | `--jsonl` | One full issue JSON per line | | `--ids-only` | Issue ids only (best for scripts) | Match count goes to **stderr** on table mode so stdout stays pipe-clean for `--ids-only` / jsonl. ### Automation pattern ```bash $RT search --where 'status.name == "New"' --where 'assigned_to IS NULL' --ids-only \ | while read -r id; do $RT view "$id" --print > "/tmp/ticket-$id.md" done ``` --- ## Saved filters (`saved-filters/`) Put one expression per file (comments with `#` allowed): ```text saved-filters/unassigned.expr saved-filters/helpdeskEmailMatchCompany.expr saved-filters/triage-needed-5d.expr ``` Reference them from CLI: ```bash $RT search --where '@unassigned' --max-tickets 20 $RT search --where 'alias.helpdeskEmailMatchCompany' --ids-only $RT walk --where '@triage-needed-5d' --max-tickets 10 --pager cat ``` Bare names that match a file also work: `--where unassigned`. ### Short configs / wrappers ```bash ./bin/rtl search --where 'assigned_to IS NULL' --max-tickets 10 # prod CS ./bin/rtd note --issue-id 39305 --body 'LAN' --apply # dev CS ./redmine_tickets.py --config configs/live.json ... ``` `--allow-prod-writes` is a **parent** flag (before the subcommand): ```bash ./bin/rtl --allow-prod-writes draft apply ``` --- ## Named queue folders (`materialize-queues`) Config: [queues.example.json](../queues.example.json) ```bash $RT materialize-queues --config queues.example.json # writes: $CACHE/queues//*.json → symlinks into $CACHE/$PROJECT/.json ``` Agents that only understand files can list `$CACHE/queues/unassigned/`. Dry-run: ```bash $RT materialize-queues --config queues.example.json --dry-run ``` Refresh after cron sync if you want queues kept current (optional second cron step). --- ## View (single ticket → Markdown) Cache-first. Does not call Redmine unless you add that later. ```bash $RT view 41934 --print $RT view --issue-id 41934 --pager auto # glow → bat → batcat → cat $RT view 41934 --write-md --pager cat ``` | Flag | Meaning | |------|---------| | `--print` | Markdown to stdout (no pager) | | `--pager auto\|glow\|bat\|batcat\|cat` | How to display when not `--print` | | `--write-md` | Force write under markdown cache | | `--force-md` | Regen even if `updated_on` stamp matches | Markdown cache path: ```text $CACHE/$PROJECT/markdown/.md $CACHE/$PROJECT/markdown/.md.updated_on ``` Regenerates when ticket `updated_on` changes (stamp file). --- ## Walk (interactive feed) ```bash # From property search $RT walk --where 'assigned_to IS NULL' --max-tickets 10 --pager cat # Explicit ids $RT walk --ids 41934,41899 --pager cat ``` Controls: `n` / Enter = next, `p` = previous, `q` = quit, `r` = redraw. Same filter flags as search (`--where`, `--status`, `--max-tickets`, …). `--ids` overrides search selection. --- ## Internal note vs customer email (critical) These are **different Redmine/Helpdesk operations**. The CLI uses **separate commands** so they are hard to confuse. | | `note` | `customer-email` | |--|--------|------------------| | **What it is** | Internal journal note on the issue | Helpdesk “send note” / customer reply | | **Emails customer?** | **No** | **Yes** (when applied) | | **API** | `PUT /issues/:id.json` with `notes` (+ optional `private_notes`) | `POST /helpdesk/email_note.json` with `message.content` | | **Private?** | Optional `--private` | **Never** (private cannot email) | | **Confirm flag** | None needed | **Required:** `--i-understand-this-emails-the-customer` | | **Default** | Queue local draft | Queue local draft | | **Apply now** | `--apply` | `--apply` (+ confirm flag already required) | | **Prod writes** | Need `--allow-prod-writes` | Need `--allow-prod-writes` | | **Impersonation** | `--impersonate-user rebot` (default) on apply | same | ### Queue an internal note (safe default — no email) ```bash $RT note --issue-id 41934 --body 'Staff-only follow-up' --private # later: $RT draft apply # still does NOT email ``` ### Apply internal note immediately (LAN/test preferred) ```bash $RT --base-url http://192.168.50.170 note --issue-id 123 \ --body 'LAN test note' --apply ``` ### Queue a customer email (explicit, scary name + confirm flag) ```bash $RT customer-email --issue-id 41934 \ --body 'Thanks for writing — we received your request.' \ --i-understand-this-emails-the-customer # later: $RT draft apply --i-understand-this-emails-the-customer ``` ### Send customer email immediately ```bash $RT --base-url http://192.168.50.170 customer-email --issue-id 123 \ --body 'LAN test customer reply' \ --i-understand-this-emails-the-customer \ --apply ``` ### Production writes Writes to hosts outside the LAN allow-list (e.g. `https://rm.ldrprep.com`) are **refused** unless you pass `--allow-prod-writes`. Prefer testing on LAN first. ### Draft list ```bash $RT draft list # columns include EMAILS_CUSTOMER vs no-email ``` **Do not** use `note` when you mean to email a customer. **Do not** use `customer-email` for staff chatter. --- ## set-fields (status / assignee / priority / category / due date) Does **not** email the customer. Default queues a draft; `--apply` writes (LAN-safe by default). ```bash # By numeric id $RT set-fields --issue-id 41934 --status-id 5 --assigned-to-id 0 # By human name (uses meta cache; run meta-refresh once) $RT meta-refresh --force $RT set-fields --issue-id 41934 --status Closed --assigned-to unassigned \ --notes 'Unassign + close' --private ``` | Flag | Meaning | |------|---------| | `--status` / `--status-id` | name or numeric status | | `--assigned-to` / `--assigned-to-id` | name/login or id; `unassigned` / `0` clears | | `--priority` / `--priority-id` | name or id | | `--category` / `--category-id` | name or id; `none` / `0` clears | | `--due-date` | `YYYY-MM-DD` | | `--subject` | replace subject | | `--notes` / `--private` | optional internal journal note with the update | | `--refresh-meta` | force name→id cache refresh | Meta cache: `$CACHE/meta//` --- ## Contacts (local mirror + light mutations) Separate from full `redmine_contacts.py` CRM CLI on purpose (different cache + surface), but **workflows can hand off**. ```bash # Mirror / search / show $RT contacts-sync --max-contacts 100 $RT contacts-search --query bavaria --max-contacts 20 $RT contact-show --contact-id 1890 # Ticket → contact bridge (starts on ticket, ends on contact tools) $RT ticket-contact --issue-id 41934 $RT ticket-contact --issue-id 41934 --show --fetch # Contact mutation (draft/apply, same safety as tickets; does NOT email) $RT contact-set --contact-id 4515 --add-tag maybe-customer $RT --allow-prod-writes draft apply # if draft was for prod # LAN apply: $RT --base-url http://192.168.50.170 contact-set --contact-id 2284 \ --add-tag maybe-customer --apply --refresh-cache ``` | Piece | Path | |-------|------| | Local mirror | `$CACHE/contacts/.json` | | Full CRM (batch/loops/custom fields) | `redmine_contacts.py` | | Ticket workbench contacts | `contacts-*`, `contact-set`, `ticket-contact` | Store: `$CACHE/contacts/.json` + `manifest.json`. --- ## Local control tags (not Redmine tags) ```bash $RT control-tag add --tag bavaria --issue-id 41934 --property subject --annotation '…' $RT control-tag list $RT search --control-tag bavaria $RT control-tag suggest --tag account --where 'subject.includes("account")' --max-tickets 20 $RT control-tag suggest --tag account --where 'subject.includes("account")' --apply $RT control-tag wipe --i-understand-this-wipes-local-control-tags ``` Store: `$CACHE/control-tags/tags.json` --- ## Attachments ```bash $RT attachments list --issue-id 39305 --refresh $RT attachments download --issue-id 39305 --all $RT attachments upload --issue-id 39305 --file ./doc.pdf # dry-run $RT --base-url http://192.168.50.170 attachments upload \ --issue-id 39305 --file ./doc.pdf --apply --notes 'attached scan' ``` Default download dir: `$CACHE/$PROJECT/attachments//`. Does **not** email the customer. Prod write needs `--allow-prod-writes`. --- ## Entities (tracking / order patterns) ```bash $RT entities show --issue-id 41394 $RT entities scan --where 'subject.includes("1Z")' --max-tickets 20 $RT entities scan --type tracking_ups --ids-only ``` Heuristic only — not NMI-backed. Also appears in `view` Markdown. --- ## Browse (interactive loop: list → pager → actions) ```bash # Default: fzf (preview pane) → glow/bat pager → action menu → back to fzf ./bin/rtl browse --where 'assigned_to IS NULL' --max-tickets 80 --open-only # Explicit pager / no preview ./bin/rtl browse --where '@unassigned' --pager glow ./bin/rtl browse --status New --no-preview # One-shot (pick once, page, exit — no menu/loop) ./bin/rtl browse --where 'subject.includes("invoice")' --once ``` ### Loop keys | Step | What happens | |------|----------------| | **List** | fzf list (Esc quits). Header lists actions. Preview shows ticket snippet. | | **Enter** | Open ticket Markdown in glow/bat/batcat (`--pager`) | | **After pager** | Action menu for that ticket | ### Action menu | Key | Action | |-----|--------| | Enter / `v` | View again | | `n` | Queue **internal note** (no email) | | `p` | Queue **private** internal note | | `f` | Queue **field update** (status/assignee by name) | | `t` | Add **local control-tag** | | `c` | Show ticket→contact handoff | | `e` | Extract entities (patterns) | | `m` | **NMI brief** → confirm → queue as **private** note draft | | `i` | Print issue id | | `l` | Back to list | | `q` | Quit browse | Mutations from browse **queue drafts only** (loud `draft_id=` banner). Apply later with `draft apply`. Customer email is **not** in the menu (use CLI deliberately). ```bash # Notes from file (+ optional Textile via pandoc) ./bin/rtl note --issue-id 41934 --body-file ./reply.md --private ./bin/rtl note --issue-id 41934 --body-file ./reply.md --to-textile --private # Open tickets only (search/browse) ./bin/rtl search --open-only --where 'assigned_to IS NULL' --max-tickets 20 ``` Non-interactive (no TTY): prints list lines and exits. --- ## OCR rename (scanned attachments → better subject) ```bash # One ticket ./bin/rtl ocr-rename --issue-id 12345 # Scan for generic "Scanned document…" tickets ./bin/rtl ocr-rename --where '@scanned-document' --only-generic-subjects --max-tickets 15 # Apply subject on LAN immediately (note still queued private by default) ./bin/rtd ocr-rename --issue-id 12345 --apply-subject ``` Uses `pdftotext`, else `pdftoppm`+`tesseract`. Queues subject `fields` draft + private OCR note (disable with `--no-queue-subject` / `--no-queue-note`). --- ## Categories ```bash ./bin/rtl categories list ./bin/rtl categories list --refresh ``` --- ## Entities + NMI ```bash ./bin/rtl entities show --issue-id 41394 ./bin/rtl entities nmi --issue-id 41394 # Env: NMI_CLI, NMI_ENV_FILE, NMI_MODE — see docs/raw-notes/features/nmi-bridge.md # Browse action `m` queues NMI brief as private note draft ``` --- ## Local drafts (queue + apply) ```bash $RT draft list $RT draft cancel $RT draft cancel --all-pending $RT draft apply # internal notes $RT draft apply --i-understand-this-emails-the-customer # customer_email only ``` Stored under `$CACHE/drafts/*.json`. --- ## Other subcommands (ops) | Command | Role | |---------|------| | `spam-check` / `close-if-spam` | Deterministic spam tooling (LAN for apply) | | `known-spam-scan` / `known-spam-close` | Profile-based spam | | `tag-maybe-customer` | Contact tagging from inquiry evidence | | `decompose` | Dry-run vault-oriented plan for one ticket | --- ## Modules | File | Role | |------|------| | `redmine_tickets.py` | CLI entry: sync, search, view, walk, spam, … | | `redmine_ticket_query.py` | Property expression compiler | | `redmine_ticket_view.py` | Markdown render + pager | | `redmine_contacts.py` | Shared Redmine client / contacts | Tests: `tests/test_redmine_ticket_query_and_view.py`, `tests/test_redmine_tickets_remote_filters.py`. --- ## What is **not** here yet - Queued notes / helpdesk replies / field mutations (P2 remaining) - Free-text semantic search (Qdrant / semantic_index — separate track) - SQLite projection layer (planned architecture, not shipped) **Shipped for queues/filters:** saved-filters + `materialize-queues` (see above). --- ## Quick “is the client alive?” check ```bash $RT search --max-tickets 3 $RT search --where 'assigned_to IS NULL' --max-tickets 3 --ids-only ID=$($RT search --max-tickets 1 --ids-only | head -1) $RT view "$ID" --print | head -30 ``` --- ## TUI (curses keyboard-driven batch processor) [NEW] Fast single-screen TUI for the exact workflow you described: queue a template of changes, scroll/cycle tickets, paint the template onto many tickets with one key, then one button to write everything. ```bash ./bin/rtl tui --where 'assigned_to IS NULL' --max-tickets 60 --open-only ./bin/rtl tui --control-tag triage-needed --max-tickets 30 ``` **Workflow in the TUI:** 1. **Set template once** (optional fields; menus or type+filter): - `c` : Category picker (from meta cache; type to filter) - `a` : Assignee picker (unassigned=0) - `s` : Status picker - `p` : Priority (type a number, e.g. 2; hotkeys 1/2/3 also work) - `P` : Project ID (for moving the ticket) - `t` : Local control tag (string) - `C` : Add contact tag (repeatable; creates if new) - `n` : Private note (applies to painted tickets) - `e` / `m` : Toggle entity-extraction / NMI-brief flags (become control tags + notes) 2. **Navigate the list**: - `j` / `k` / arrows : move - `/` : filter the visible list (live) - `r` : redraw 3. **Paint / apply**: - `SPACE` or `Enter` on a ticket: **apply the current template** to it (records per-ticket pending: fields, note, tags, toggles). Shows marker [FNTEM] etc. 4. **Commit**: - `A` : Queue drafts for all painted tickets (safe default). - (If you passed `--apply` and have `--allow-prod-writes` on LAN/prod, `S` for direct apply — use with care.) Other keys: `x` clear pending on current, `R` force meta refresh, `q` quit (prompts if pending), `?` help. **What gets written on commit:** - Field updates (status/assignee/priority/category/project) via KIND_FIELDS draft (or direct). - Private note (per ticket or from template). - Local control tags (via control-tag add). - Contact tags (via contact_fields drafts for the helpdesk contact). - Special toggles → control tags ("entity-extract") + placeholder NMI note if needed. All the safety rules (drafts vs apply, prod gate, impersonate rebot) still apply. The TUI just removes the per-ticket menu tax. **Tips:** - Run `meta-refresh --force` once for full picker lists. - Combine with saved filters: `./bin/rtl tui --where '@unassigned'` - The TUI loads from the same cache/search logic as browse/walk. ## TUI Commit behavior update (auto-apply on A) As of latest change: - Pressing **A** in the TUI now: 1. Shows a **single** "Are you SURE?" confirmation for the whole batch. 2. Queues the drafts for every painted ticket. 3. Immediately applies those drafts (using the normal apply_draft_to_redmine + mark_applied path). This removes the need to manually run `draft apply ` for each ticket. `S` remains available for the old direct-apply (bypass draft) path if you ever want it. All normal safety (LAN/prod gate, --allow-prod-writes, impersonate rebot, no customer emails from this path) remains in force. See the module docstring in redmine_ticket_tui.py or run the TUI and press `?` for current keys. ## NMI Enrichment Improvements (entities nmi) The `entities nmi` command (and the NMI path used by browse 'm' and TUI) now: - Automatically generates rich deep links when it sees item numbers, tracking, request ids, order ids, accounts: - Item Manager + Item Statement for item_no - Box History (boxFinder) for tracking numbers - Order summary and package/request history pages - User Info for account numbers (See also the reference note: [[INBOX/WIP/Linking to Entities Within NMI]] ) - When an item_no is resolved, it calls the new `getItemHistory` API endpoint via `nmi item history --item-no=... --snapshot`: - Returns merged timeline of product_history (moves, condition changes), service_fees (billings), and comments. - The private note / cs-brief output now includes a "=== NMI Deep Links ===" section and a "=== Item History (getItemHistory) ===" section (truncated for notes; full in --format json). Usage: ``` rtl entities nmi --issue-id 123456 --format note rtl entities nmi --issue-id 123456 --format json # full history payload rtl entities nmi --issue-id 123456 --no-history ``` The same enrichment logic feeds the TUI and browse NMI quick action. --- ## Post-apply cache refresh (anti-staleness) After any mutation that hits live Redmine (TUI `A`, `draft apply`, `set-fields --apply`, direct notes, etc.) the affected ticket's JSON is immediately re-fetched and overwritten in the local cache. This means: - Next `rtl browse`, `rtl tui`, or search sees the new status / assignee / journals / updated_on right away. - No need to wait for the next full sync for the tickets you just touched. In the TUI: - `r` → local redraw (fast) - `R` → force live refresh of the **currently highlighted** ticket (pulls fresh from Redmine + updates cache + view) This directly supports the rule: "update the existing cached file when the ticket has changed" and "agents update the ticket when their work is done." ### TUI "S" key (direct apply) `S` is the **direct/bypass** path (still requires write permission). - It skips the draft queue for field changes and does an immediate `update_issue`. - We now also call the same live cache refresh that `A` uses, so the TUI and later `browse` see the change. - Behavior difference from `A`: - No "are you sure?" batch confirmation. - No draft records created for the main fields. - Still gated by `--allow-prod-writes` (or `--apply`). Current recommended path for most batch work remains **A** (one confirm, goes through drafts for auditability, then auto-applies). ### TUI safety / UX improvements (2026-07) - If you start without `--allow-prod-writes`, a clear **READ-ONLY** banner appears near the top. - Bottom status/help line is now wrapped to avoid `_curses.error` on narrow/short terminals (common when ssh'ing from Windows Terminal, Tabby, or split panes). - After confirming `A`, the screen immediately shows "COMMIT + APPLY IN PROGRESS" feedback so it doesn't look hung while the writes happen. - `S` now shows "fields updated DIRECTLY" + cache sync messages when it actually performs a live write.