22 KiB
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
- Pre-MVP search snapshot: 2026-07-14_localsearch_before_sanity.md
- Sync plan: ticket_sync_tool_plan.md
- Cron scripts:
cron-scripts/sync-live-customer-service.sh,cron-scripts/sync-dev-customer-service.sh
Setup
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:
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.
# 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):
#id updated_on status assignee contact category control_tags subject
Contact comes from helpdesk_ticket.contact.email (or name/company/from_address).
Classic filters
$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.
$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
$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):
saved-filters/unassigned.expr
saved-filters/helpdeskEmailMatchCompany.expr
saved-filters/triage-needed-5d.expr
Reference them from CLI:
$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
./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):
./bin/rtl --allow-prod-writes draft apply <draft_id>
Named queue folders (materialize-queues)
Config: queues.example.json
$RT materialize-queues --config queues.example.json
# writes: $CACHE/queues/<name>/*.json → symlinks into $CACHE/$PROJECT/<id>.json
Agents that only understand files can list $CACHE/queues/unassigned/.
Dry-run:
$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.
$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:
$CACHE/$PROJECT/markdown/<id>.md
$CACHE/$PROJECT/markdown/<id>.md.updated_on
Regenerates when ticket updated_on changes (stamp file).
Walk (interactive feed)
# 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)
$RT note --issue-id 41934 --body 'Staff-only follow-up' --private
# later:
$RT draft apply <draft-id> # still does NOT email
Apply internal note immediately (LAN/test preferred)
$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)
$RT customer-email --issue-id 41934 \
--body 'Thanks for writing — we received your request.' \
--i-understand-this-emails-the-customer
# later:
$RT draft apply <draft-id> --i-understand-this-emails-the-customer
Send customer email immediately
$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
$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).
# 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/<project>/
Contacts (local mirror + light mutations)
Separate from full redmine_contacts.py CRM CLI on purpose (different cache + surface), but workflows can hand off.
# 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 <id> # 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/<id>.json |
| Full CRM (batch/loops/custom fields) | redmine_contacts.py |
| Ticket workbench contacts | contacts-*, contact-set, ticket-contact |
Store: $CACHE/contacts/<id>.json + manifest.json.
Local control tags (not Redmine tags)
$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
$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/<issue_id>/.
Does not email the customer. Prod write needs --allow-prod-writes.
Entities (tracking / order patterns)
$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)
# 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).
# 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)
# 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
./bin/rtl categories list
./bin/rtl categories list --refresh
Entities + NMI
./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)
$RT draft list
$RT draft cancel <id>
$RT draft cancel --all-pending
$RT draft apply <id> # internal notes
$RT draft apply <id> --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
$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.
./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:
-
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 pickerp: 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)
-
Navigate the list:
j/k/ arrows : move/: filter the visible list (live)r: redraw
-
Paint / apply:
SPACEorEnteron a ticket: apply the current template to it (records per-ticket pending: fields, note, tags, toggles). Shows marker [FNTEM] etc.
-
Commit:
A: Queue drafts for all painted tickets (safe default).- (If you passed
--applyand have--allow-prod-writeson LAN/prod,Sfor 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 --forceonce 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:
- Shows a single "Are you SURE?" confirmation for the whole batch.
- Queues the drafts for every painted ticket.
- Immediately applies those drafts (using the normal apply_draft_to_redmine + mark_applied path).
This removes the need to manually run draft apply <id> 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
getItemHistoryAPI endpoint vianmi 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
Auses, so the TUI and laterbrowsesee 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.erroron 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. Snow shows "fields updated DIRECTLY" + cache sync messages when it actually performs a live write.