refactor(tooling): merge redmine-cli into tooling-cli/redmine
Consolidate the redmine-cli source into tooling-cli/redmine/
(CLI source, Dockerfile, README, AGENTS.md) with all documentation
rewritten to invoke the container via raw docker run and credentials
from the centralized ~/.creds/redmine.env store. No bin/ wrapper, no
system-dependent paths in the docs.
Removes the old redmine-cli/ subdirectory, updates the
KNELCredsManager consumer table, and updates STATUS.md + AGENTS.md
to reference the new location and registry image.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@@ -26,7 +26,7 @@ All credential consumers source from `~/.creds/`:
|
||||
|
||||
| Service | Wrapper / consumer | Mechanism |
|
||||
|---|---|---|
|
||||
| Redmine | `~/daytoday/redmine/bin/redmine` | `--env-file ~/.creds/redmine.env` |
|
||||
| Redmine | `redmine-cli` container (`tooling-cli/redmine/`) | `docker run --env-file ~/.creds/redmine.env` |
|
||||
| Redmine MCP | `mcp-redmine-wrapper.sh` | `set -a; . ~/.creds/redmine.env; set +a` |
|
||||
| Discourse | `discourse-cli` container (`tooling-cli/discourse/`) | `docker run --env-file ~/.creds/discourse.env` |
|
||||
| Discourse MCP | `mcp-discourse-wrapper.sh` | `set -a; . ~/.creds/discourse.env; set +a` |
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Redmine connection details
|
||||
# Copy this file to .env and fill in real values. The .env file is gitignored.
|
||||
REDMINE_URL=https://redmine.example.com
|
||||
REDMINE_API_KEY=your-api-key-here
|
||||
@@ -0,0 +1,8 @@
|
||||
# Secrets - never commit
|
||||
.env
|
||||
|
||||
# OS / editor cruft
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -0,0 +1,240 @@
|
||||
# AGENTS.md — Redmine CLI tooling
|
||||
|
||||
This directory holds the `redmine-cli` Docker container — a thin Python CLI
|
||||
over `python-redmine` for Redmine issue tracking. Every Crush session that
|
||||
works on or with this tool should read this file first: how to invoke it,
|
||||
what commands exist, what gotchas to avoid, and the patterns to follow for
|
||||
common ticket operations.
|
||||
|
||||
## Connection & invocation
|
||||
|
||||
Invoke the real container with `docker run`. Credentials come from the
|
||||
centralized credential store (see `tooling-cli/KNELCredsManager`):
|
||||
|
||||
```bash
|
||||
docker run --rm --env-file ~/.creds/redmine.env \
|
||||
git.knownelement.com/reachableceo/redmine-cli:latest <command>
|
||||
```
|
||||
|
||||
There is intentionally **no `bin/` wrapper** — invoke the real container, as
|
||||
the project-wide rules require. For readability in an interactive session you
|
||||
may define a throwaway shell alias (do not commit one):
|
||||
|
||||
```bash
|
||||
alias redmine='docker run --rm --env-file ~/.creds/redmine.env \
|
||||
git.knownelement.com/reachableceo/redmine-cli:latest'
|
||||
```
|
||||
|
||||
The examples below write `redmine <cmd>` for brevity; expand it to the full
|
||||
`docker run` line (or set the alias) before running.
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Instance | `https://projects.knownelement.com` |
|
||||
| API user | Charles N (id 5, login `reachableceo`) |
|
||||
| Credentials | `~/.creds/redmine.env` (`REDMINE_URL`, `REDMINE_API_KEY`) |
|
||||
| CLI image | `git.knownelement.com/reachableceo/redmine-cli:latest` |
|
||||
| Source | `src/redmine_cli.py` in this directory |
|
||||
|
||||
To use a locally built image instead of the registry image, either set
|
||||
`REDMINE_CLI_IMAGE` or swap the tag for `kneldevstack-aimiddleware-redmine-cli:latest`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Connection sanity check (run this first in any session):
|
||||
redmine whoami
|
||||
|
||||
# Look at a ticket:
|
||||
redmine show 314
|
||||
|
||||
# Your queue:
|
||||
redmine list --assigned-to-me
|
||||
```
|
||||
|
||||
## Command reference
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `whoami` | Authenticated user + connection test. |
|
||||
| `projects` | List projects (id, identifier, name). |
|
||||
| `statuses` | List issue statuses + which are "closed". |
|
||||
| `list` (`ls`) | List issues. Filters below. |
|
||||
| `show <id>` | Full issue detail incl. note history. |
|
||||
| `create` | Create an issue. |
|
||||
| `update <id>` | Update an issue (status, notes, assignee, done, ...). |
|
||||
| `close <id>` | Close (first closed status, done ratio 100%). |
|
||||
|
||||
### `list` filters
|
||||
- `-m, --assigned-to-me` — only issues assigned to you
|
||||
- `-a, --assigned-to <id>` — filter by assignee user id
|
||||
- `-p, --project <id|slug>` — filter by project
|
||||
- `-s, --status <name|id|open|closed>` — filter by status
|
||||
- `-l, --limit <n>` — max results (default 50)
|
||||
- `--sort <spec>` — Redmine sort spec (default `priority:desc,updated_on:desc`)
|
||||
|
||||
### `create` options
|
||||
- `-p, --project <id|slug>` **required**
|
||||
- `-s, --subject` **required**
|
||||
- `-d, --description`
|
||||
- `-a, --assigned-to <id>`
|
||||
- `-t, --tracker <id>`
|
||||
- `--priority <id>`
|
||||
- `--status <name|id>`
|
||||
|
||||
### `update` options
|
||||
- `-s, --status <name|id>`
|
||||
- `-n, --notes <text>`
|
||||
- `-a, --assigned-to <id>`
|
||||
- `--done-ratio <0-100>`
|
||||
- `--subject`
|
||||
- `--priority <id>`
|
||||
|
||||
## Key project / tracker / status IDs
|
||||
|
||||
### Most-used projects
|
||||
| ID | Identifier | Name |
|
||||
|----|------------|------|
|
||||
| 55 | `technicaloperations` | Known Element Enterprises - Technology & Facility Services |
|
||||
| 62 | `business-operations` | Known Element Enterprises - Business Services |
|
||||
| 77 | `tsys-group` | TSYS Group |
|
||||
|
||||
Run `redmine projects` for the full list.
|
||||
|
||||
### Statuses
|
||||
| ID | Name | Closed? |
|
||||
|----|------|---------|
|
||||
| 1 | New | no |
|
||||
| 2 | In Progress | no |
|
||||
| 3 | Resolved | yes |
|
||||
| 4 | Feedback | no |
|
||||
| 5 | Closed | yes |
|
||||
| 6 | Rejected | no |
|
||||
|
||||
### Trackers
|
||||
Tracker 3 = **Support** (the most common one in project 55). Inspect a
|
||||
parent issue to inherit its exact tracker.
|
||||
|
||||
## Gotcha: creating subtasks
|
||||
|
||||
`redmine create` has **no `--parent` flag**. To create a subtask of an
|
||||
existing issue you must use `python-redmine` directly inside the container
|
||||
(via `--entrypoint python`).
|
||||
|
||||
Pattern (mount a script and run it in the same image):
|
||||
|
||||
```bash
|
||||
cat > /tmp/script.py <<'PY'
|
||||
import os
|
||||
from redminelib import Redmine
|
||||
|
||||
rm = Redmine(os.environ["REDMINE_URL"].rstrip("/"), key=os.environ["REDMINE_API_KEY"])
|
||||
|
||||
issue = rm.issue.create(
|
||||
project_id=55, # inherit from parent
|
||||
tracker_id=3, # inherit from parent
|
||||
priority_id=2, # inherit from parent
|
||||
status_id=4, # Feedback = freshly created, awaiting work
|
||||
assigned_to_id=5, # inherit from parent
|
||||
parent_issue_id=314, # THE KEY FIELD
|
||||
subject="Your subject",
|
||||
description="Your description",
|
||||
)
|
||||
print(f"Created #{issue.id}")
|
||||
PY
|
||||
|
||||
docker run --rm --env-file ~/.creds/redmine.env \
|
||||
--entrypoint python \
|
||||
-v /tmp/script.py:/tmp/script.py \
|
||||
git.knownelement.com/reachableceo/redmine-cli:latest \
|
||||
/tmp/script.py
|
||||
```
|
||||
|
||||
**Before creating subtasks**, inspect the parent to inherit its attributes:
|
||||
|
||||
```bash
|
||||
docker run --rm --env-file ~/.creds/redmine.env \
|
||||
--entrypoint python \
|
||||
git.knownelement.com/reachableceo/redmine-cli:latest -c "
|
||||
import os
|
||||
from redminelib import Redmine
|
||||
rm = Redmine(os.environ['REDMINE_URL'].rstrip('/'), key=os.environ['REDMINE_API_KEY'])
|
||||
i = rm.issue.get(PARENT_ID)
|
||||
print('project_id:', i.project.id)
|
||||
print('tracker_id:', i.tracker.id, i.tracker.name)
|
||||
print('priority_id:', i.priority.id, i.priority.name)
|
||||
print('assigned_to_id:', getattr(i.assigned_to,'id',None))
|
||||
print('status_id:', i.status.id, i.status.name)
|
||||
"
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Pattern: review & split a ticket
|
||||
1. `redmine show <id>` — read the full ticket (description + history).
|
||||
2. Identify the **distinct bodies of work**. Each should be independently
|
||||
trackable and assignable.
|
||||
3. Map out **dependencies** between the pieces (what blocks what).
|
||||
4. Propose a split to the user as a table: proposed subject, scope, and
|
||||
dependencies. Wait for approval before creating anything.
|
||||
5. On approval: create subtasks (see "Gotcha: creating subtasks" above),
|
||||
inheriting project/tracker/priority/assignee from the parent.
|
||||
6. Update the parent's **description** to an index table of children +
|
||||
recommended execution order. Add a note explaining the split.
|
||||
7. Verify with `redmine show <parent_id>`.
|
||||
|
||||
### Pattern: ticket conventions
|
||||
- **Subject prefix:** OAM-pool tickets use `OAM: <topic>`. Match the parent's
|
||||
naming convention if one exists.
|
||||
- **Description header:** always start with `Parent/umbrella: #<id> (<subject>).`
|
||||
- **Scope section:** bullet list of concrete deliverables. Use `- [ ]` for
|
||||
checklist items within a subtask.
|
||||
- **Dependencies section:** list what the ticket depends on and what it blocks,
|
||||
referencing ticket numbers once they exist.
|
||||
- **Newly created children** go to status **Feedback (4)** so they're visible
|
||||
but not yet "in progress".
|
||||
- **Tables** in descriptions render in Redmine's Markdown pipeline (`| a | b |`).
|
||||
|
||||
### Pattern: bulk operations
|
||||
For anything beyond a single `create`/`update`/`close`, write a Python script
|
||||
and run it inside the container as shown in the subtask gotcha above. This
|
||||
applies to: batch status changes, bulk ticket creation, inspecting parent
|
||||
attributes, relationship wiring, etc.
|
||||
|
||||
### Pattern: before you act
|
||||
- Always `redmine show <id>` before updating — confirm current status,
|
||||
assignee, and existing notes so you don't clobber context.
|
||||
- Never delete or close a ticket unless the user explicitly asks.
|
||||
- When in doubt about project/tracker/priority, inherit from the parent or
|
||||
ask the user.
|
||||
|
||||
## Git workflow
|
||||
|
||||
### Atomic commits
|
||||
Each commit is **one logical change** — one feature, one fix, one doc update.
|
||||
If you're tempted to write "and also..." in a commit message, that's a sign
|
||||
to split it into two commits. Stage precisely (`git add <file>`, not
|
||||
`git add -A`) so unrelated changes don't get bundled.
|
||||
|
||||
### Conventional commit messages
|
||||
Use the [Conventional Commits](https://www.conventionalcommits.org/) format:
|
||||
|
||||
```
|
||||
<type>(<optional scope>): <imperative subject>
|
||||
|
||||
<optional body — why, not what>
|
||||
```
|
||||
|
||||
Types used in this repo: `feat`, `fix`, `docs`, `refactor`, `chore`, `style`.
|
||||
|
||||
Rules:
|
||||
- Subject line **under 72 chars**, lowercase, imperative mood.
|
||||
- No period at end of subject.
|
||||
- Body wrapped at 72 chars, explains **why** the change exists.
|
||||
|
||||
### Commit cadence
|
||||
- **Commit early and often.** Don't accumulate a pile of unrelated changes.
|
||||
- Commit **without asking** — if you made a coherent change, commit it.
|
||||
- Every task (ticket review, split, script addition, doc update) ends with
|
||||
the relevant files committed.
|
||||
- Run `git status` before committing to stage only what belongs together.
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY src/redmine_cli.py /usr/local/bin/redmine-cli
|
||||
RUN chmod +x /usr/local/bin/redmine-cli
|
||||
|
||||
ENTRYPOINT ["redmine-cli"]
|
||||
CMD ["--help"]
|
||||
@@ -0,0 +1,102 @@
|
||||
# redmine-cli
|
||||
|
||||
A Docker container that lets an AI agent (or a human) access, edit, and close
|
||||
Redmine issues through the Redmine REST API. Built on the
|
||||
[`python-redmine`](https://python-redmine.com) library with a small command-line
|
||||
wrapper.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker on the host.
|
||||
- A Redmine instance with REST web services enabled
|
||||
(Administration → Settings → API → Enable REST API).
|
||||
- A Redmine API key for the user the agent will act as
|
||||
(My account → API access key → Show / Reset).
|
||||
|
||||
## Configuration
|
||||
|
||||
Credentials live in the **centralized credential store** at
|
||||
`~/.creds/redmine.env` (see `tooling-cli/KNELCredsManager`). It holds:
|
||||
|
||||
```
|
||||
REDMINE_URL=https://your-redmine.example.com
|
||||
REDMINE_API_KEY=abc123...
|
||||
```
|
||||
|
||||
Use `.env.example` in this directory as a template if you need to create one.
|
||||
Permissions: directory `700`, the env file `600` (owner read/write only).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
docker build -t kneldevstack-aimiddleware-redmine-cli:latest .
|
||||
```
|
||||
|
||||
A prebuilt image is also available in the Gitea registry:
|
||||
|
||||
```
|
||||
git.knownelement.com/reachableceo/redmine-cli:latest
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Invoke the container directly with `docker run`. Pass credentials from the
|
||||
centralized store via `--env-file`:
|
||||
|
||||
```bash
|
||||
docker run --rm --env-file ~/.creds/redmine.env \
|
||||
git.knownelement.com/reachableceo/redmine-cli:latest whoami
|
||||
|
||||
docker run --rm --env-file ~/.creds/redmine.env \
|
||||
git.knownelement.com/reachableceo/redmine-cli:latest list --assigned-to-me
|
||||
|
||||
docker run --rm --env-file ~/.creds/redmine.env \
|
||||
git.knownelement.com/reachableceo/redmine-cli:latest show 123
|
||||
|
||||
docker run --rm --env-file ~/.creds/redmine.env \
|
||||
git.knownelement.com/reachableceo/redmine-cli:latest close 123 --notes "work complete"
|
||||
```
|
||||
|
||||
To use a locally built image instead of the registry image, either set
|
||||
`REDMINE_CLI_IMAGE` or swap the image tag for `kneldevstack-aimiddleware-redmine-cli:latest`.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| -------------------------------- | ------------------------------------------------------ |
|
||||
| `whoami` | Show the authenticated user (also a connection test). |
|
||||
| `projects` | List projects (id, identifier, name). |
|
||||
| `statuses` | List issue statuses and which are "closed". |
|
||||
| `list` (`ls`) | List issues. Filters below. |
|
||||
| `show <id>` | Show full issue detail incl. note history. |
|
||||
| `create` | Create an issue (`--project`, `--subject`, ...). |
|
||||
| `update <id>` | Update an issue (status, notes, assignee, done, ...). |
|
||||
| `close <id>` | Close an issue (first closed status, done ratio 100%). |
|
||||
|
||||
### `list` filters
|
||||
|
||||
- `-m, --assigned-to-me` — only issues assigned to the current user
|
||||
- `-a, --assigned-to <id>` — filter by assignee user id
|
||||
- `-p, --project <id|identifier>` — filter by project
|
||||
- `-s, --status <name|id|open|closed>` — filter by status
|
||||
- `-l, --limit <n>` — max results (default 50)
|
||||
- `--sort <spec>` — Redmine sort spec
|
||||
(default `priority:desc,updated_on:desc`)
|
||||
|
||||
### `update` / `create` options
|
||||
|
||||
- `--status <name|id>` — status name (case-insensitive) or numeric id
|
||||
- `-n, --notes <text>` — add a journal note
|
||||
- `-a, --assigned-to <id>` — assignee user id
|
||||
- `--done-ratio <0-100>` — percent complete
|
||||
- `--subject <text>` — change the subject (`update`/`create`)
|
||||
- `--priority <id>` — priority id
|
||||
- `-d, --description <text>` — description (`create` only)
|
||||
- `-t, --tracker <id>` — tracker id (`create` only)
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ----------------- | -------- | ------------------------------------ |
|
||||
| `REDMINE_URL` | yes | Base URL of the Redmine instance. |
|
||||
| `REDMINE_API_KEY` | yes | API key of the acting user. |
|
||||
@@ -0,0 +1 @@
|
||||
python-redmine>=2.5.0
|
||||
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Redmine CLI - a thin wrapper around the Redmine REST API.
|
||||
|
||||
Connection details come from the environment:
|
||||
REDMINE_URL base URL of the Redmine instance (e.g. https://redmine.example.com)
|
||||
REDMINE_API_KEY API key of an authenticated user
|
||||
|
||||
Designed to be run inside the redmine-cli Docker container, but works anywhere
|
||||
these environment variables are set.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from redminelib import Redmine
|
||||
from redminelib.exceptions import (
|
||||
AuthError,
|
||||
ResourceNotFoundError,
|
||||
ServerError,
|
||||
ValidationError,
|
||||
ConflictError,
|
||||
NoFileError,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _client():
|
||||
"""Build and return an authenticated Redmine client, or exit with help."""
|
||||
url = os.environ.get("REDMINE_URL", "").strip().rstrip("/")
|
||||
key = os.environ.get("REDMINE_API_KEY", "").strip()
|
||||
missing = [n for n, v in (("REDMINE_URL", url), ("REDMINE_API_KEY", key)) if not v]
|
||||
if missing:
|
||||
sys.stderr.write(
|
||||
"ERROR: missing required environment variable(s): "
|
||||
+ ", ".join(missing)
|
||||
+ "\n"
|
||||
)
|
||||
sys.exit(2)
|
||||
return Redmine(url, key=key)
|
||||
|
||||
|
||||
def _current_user(rm):
|
||||
"""Return the Redmine user behind the current API key."""
|
||||
return rm.user.get("current")
|
||||
|
||||
|
||||
def _err(msg, code=1):
|
||||
sys.stderr.write(f"ERROR: {msg}\n")
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def _kv(label, value):
|
||||
"""Format a label/value line, omitting falsy values gracefully."""
|
||||
if value in (None, "", []):
|
||||
return None
|
||||
return f"{label:>14}: {value}"
|
||||
|
||||
|
||||
def _name(obj):
|
||||
"""Best-effort human name for a user object."""
|
||||
if obj is None:
|
||||
return "(unassigned)"
|
||||
# python-redmine user objects expose these attributes
|
||||
fname = getattr(obj, "firstname", "") or ""
|
||||
lname = getattr(obj, "lastname", "") or ""
|
||||
full = f"{fname} {lname}".strip()
|
||||
login = getattr(obj, "login", "") or ""
|
||||
mail = getattr(obj, "mail", "") or ""
|
||||
if full:
|
||||
return f"{full} ({login})" if login else full
|
||||
return login or mail or str(obj)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Commands
|
||||
# --------------------------------------------------------------------------- #
|
||||
def cmd_whoami(args):
|
||||
rm = _client()
|
||||
try:
|
||||
u = _current_user(rm)
|
||||
except AuthError:
|
||||
_err("authentication failed - check REDMINE_API_KEY", 2)
|
||||
print(f"id: {u.id}")
|
||||
print(f"login: {getattr(u, 'login', '?')}")
|
||||
print(f"name: {_name(u)}")
|
||||
print(f"mail: {getattr(u, 'mail', '?')}")
|
||||
print(f"admin: {getattr(u, 'admin', False)}")
|
||||
print(f"redmine: {os.environ['REDMINE_URL']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_projects(args):
|
||||
rm = _client()
|
||||
projects = rm.project.all()
|
||||
print(f"{'ID':<6} {'IDENTIFIER':<24} {'NAME'}")
|
||||
print("-" * 60)
|
||||
for p in projects:
|
||||
print(f"{p.id:<6} {str(p.identifier):<24} {p.name}")
|
||||
print(f"\n{len(projects)} project(s)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_statuses(args):
|
||||
rm = _client()
|
||||
statuses = rm.issue_status.all()
|
||||
print(f"{'ID':<6} {'NAME':<24} IS_CLOSED")
|
||||
print("-" * 40)
|
||||
for s in statuses:
|
||||
print(f"{s.id:<6} {str(s.name):<24} {bool(s.is_closed)}")
|
||||
print(f"\n{len(statuses)} status(es)")
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_status_id(rm, status_ref):
|
||||
"""Resolve a status name (case-insensitive) or id to a numeric id."""
|
||||
if status_ref is None:
|
||||
return None
|
||||
if status_ref.isdigit():
|
||||
return int(status_ref)
|
||||
needle = status_ref.strip().lower()
|
||||
for s in rm.issue_status.all():
|
||||
if str(s.name).lower() == needle:
|
||||
return s.id
|
||||
_err(f"unknown status '{status_ref}'. Run 'statuses' to list valid names.")
|
||||
|
||||
|
||||
def _resolve_project_id(rm, project_ref):
|
||||
"""Resolve a project id or identifier to a numeric id."""
|
||||
if project_ref is None:
|
||||
return None
|
||||
if project_ref.isdigit():
|
||||
return int(project_ref)
|
||||
needle = project_ref.strip().lower()
|
||||
for p in rm.project.all():
|
||||
if str(p.identifier).lower() == needle:
|
||||
return p.id
|
||||
_err(f"unknown project '{project_ref}'. Run 'projects' to list identifiers.")
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
rm = _client()
|
||||
filters = {}
|
||||
if args.assigned_to_me:
|
||||
me = _current_user(rm)
|
||||
filters["assigned_to_id"] = me.id
|
||||
elif args.assigned_to is not None:
|
||||
filters["assigned_to_id"] = args.assigned_to
|
||||
if args.project is not None:
|
||||
filters["project_id"] = _resolve_project_id(rm, args.project)
|
||||
if args.status is not None:
|
||||
# status can be a name or id
|
||||
if args.status.lower() in ("open", "open*"):
|
||||
filters["status_id"] = "open"
|
||||
elif args.status.lower() in ("closed", "closed*"):
|
||||
filters["status_id"] = "closed"
|
||||
else:
|
||||
filters["status_id"] = _resolve_status_id(rm, args.status)
|
||||
filters["limit"] = args.limit
|
||||
filters["sort"] = args.sort
|
||||
|
||||
issues = rm.issue.filter(**filters)
|
||||
print(f"{'ID':<8} {'STATUS':<14} {'PRJ':<14} {'DONE':>5} SUBJECT")
|
||||
print("-" * 90)
|
||||
for i in issues:
|
||||
proj = getattr(i, "project", None)
|
||||
proj_id = str(getattr(proj, "identifier", getattr(proj, "id", ""))) if proj else ""
|
||||
status = getattr(i, "status", None)
|
||||
status_name = str(getattr(status, "name", "")) if status else ""
|
||||
done = getattr(i, "done_ratio", 0) or 0
|
||||
print(f"{i.id:<8} {status_name:<14} {proj_id:<14} {done:>4}% {i.subject}")
|
||||
print(f"\n{len(issues)} issue(s)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show(args):
|
||||
rm = _client()
|
||||
try:
|
||||
i = rm.issue.get(args.issue_id)
|
||||
except ResourceNotFoundError:
|
||||
_err(f"issue #{args.issue_id} not found")
|
||||
lines = []
|
||||
lines.append(f"#{i.id}: {i.subject}")
|
||||
lines.append("=" * 90)
|
||||
proj = getattr(i, "project", None)
|
||||
status = getattr(i, "status", None)
|
||||
tracker = getattr(i, "tracker", None)
|
||||
priority = getattr(i, "priority", None)
|
||||
lines.append(_kv("project", str(getattr(proj, "name", "")) if proj else None))
|
||||
lines.append(_kv("tracker", str(getattr(tracker, "name", "")) if tracker else None))
|
||||
lines.append(_kv("status", str(getattr(status, "name", "")) if status else None))
|
||||
lines.append(_kv("priority", str(getattr(priority, "name", "")) if priority else None))
|
||||
lines.append(_kv("author", _name(getattr(i, "author", None))))
|
||||
lines.append(_kv("assigned", _name(getattr(i, "assigned_to", None))))
|
||||
lines.append(_kv("done", f"{getattr(i, 'done_ratio', 0) or 0}%"))
|
||||
lines.append(_kv("created", getattr(i, "created_on", None)))
|
||||
lines.append(_kv("updated", getattr(i, "updated_on", None)))
|
||||
lines.append(_kv("start", getattr(i, "start_date", None)))
|
||||
lines.append(_kv("due", getattr(i, "due_date", None)))
|
||||
lines.append(_kv("estimated", getattr(i, "estimated_hours", None)))
|
||||
lines.append(_kv("spent", getattr(i, "spent_hours", None)))
|
||||
for ln in (l for l in lines if l):
|
||||
print(ln)
|
||||
|
||||
desc = getattr(i, "description", "") or ""
|
||||
if desc.strip():
|
||||
print("\n--- description ---")
|
||||
print(desc.strip())
|
||||
|
||||
if not args.no_journals:
|
||||
journals = getattr(i, "journals", []) or []
|
||||
notes = [j for j in journals if getattr(j, "notes", "") and str(j.notes).strip()]
|
||||
if notes:
|
||||
print("\n--- history (notes) ---")
|
||||
for j in notes:
|
||||
who = _name(getattr(j, "user", None))
|
||||
when = getattr(j, "created_on", "?")
|
||||
print(f"\n[{when}] {who}:")
|
||||
print(str(j.notes).strip())
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_create(args):
|
||||
rm = _client()
|
||||
if not args.project:
|
||||
_err("--project is required to create an issue")
|
||||
if not args.subject:
|
||||
_err("--subject is required to create an issue")
|
||||
fields = {
|
||||
"project_id": _resolve_project_id(rm, args.project),
|
||||
"subject": args.subject,
|
||||
}
|
||||
if args.description:
|
||||
fields["description"] = args.description
|
||||
if args.assigned_to is not None:
|
||||
fields["assigned_to_id"] = args.assigned_to
|
||||
if args.tracker is not None:
|
||||
fields["tracker_id"] = args.tracker
|
||||
if args.priority is not None:
|
||||
fields["priority_id"] = args.priority
|
||||
if args.status is not None:
|
||||
sid = _resolve_status_id(rm, args.status)
|
||||
if sid:
|
||||
fields["status_id"] = sid
|
||||
try:
|
||||
issue = rm.issue.create(**fields)
|
||||
except ValidationError as e:
|
||||
_err(f"validation failed: {e}")
|
||||
print(f"Created issue #{issue.id}: {issue.subject}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_update(args):
|
||||
rm = _client()
|
||||
fields = {}
|
||||
if args.status is not None:
|
||||
sid = _resolve_status_id(rm, args.status)
|
||||
if sid:
|
||||
fields["status_id"] = sid
|
||||
if args.notes:
|
||||
fields["notes"] = args.notes
|
||||
if args.assigned_to is not None:
|
||||
fields["assigned_to_id"] = args.assigned_to
|
||||
if args.done_ratio is not None:
|
||||
fields["done_ratio"] = args.done_ratio
|
||||
if args.subject is not None:
|
||||
fields["subject"] = args.subject
|
||||
if args.priority is not None:
|
||||
fields["priority_id"] = args.priority
|
||||
if not fields:
|
||||
_err("no fields to update; pass at least one of --status/--notes/--assigned-to/--done-ratio/--subject/--priority")
|
||||
try:
|
||||
rm.issue.update(args.issue_id, **fields)
|
||||
except ResourceNotFoundError:
|
||||
_err(f"issue #{args.issue_id} not found")
|
||||
except ValidationError as e:
|
||||
_err(f"validation failed: {e}")
|
||||
print(f"Updated issue #{args.issue_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_close(args):
|
||||
rm = _client()
|
||||
close_id = None
|
||||
for s in rm.issue_status.all():
|
||||
if getattr(s, "is_closed", False):
|
||||
close_id = s.id
|
||||
break
|
||||
if close_id is None:
|
||||
_err("no closed status found on this Redmine instance")
|
||||
fields = {"status_id": close_id}
|
||||
if args.notes:
|
||||
fields["notes"] = args.notes
|
||||
if args.done_ratio is not None:
|
||||
fields["done_ratio"] = args.done_ratio
|
||||
else:
|
||||
fields["done_ratio"] = 100
|
||||
try:
|
||||
rm.issue.update(args.issue_id, **fields)
|
||||
except ResourceNotFoundError:
|
||||
_err(f"issue #{args.issue_id} not found")
|
||||
except ValidationError as e:
|
||||
_err(f"validation failed: {e}")
|
||||
print(f"Closed issue #{args.issue_id} (status_id={close_id}, done=100%)")
|
||||
return 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Argument parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(
|
||||
prog="redmine-cli",
|
||||
description="Access, edit, and close Redmine issues via the REST API.",
|
||||
)
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("whoami", help="show the authenticated user (connection test)").set_defaults(func=cmd_whoami)
|
||||
|
||||
sp = sub.add_parser("projects", help="list projects")
|
||||
sp.set_defaults(func=cmd_projects)
|
||||
|
||||
sp = sub.add_parser("statuses", help="list issue statuses")
|
||||
sp.set_defaults(func=cmd_statuses)
|
||||
|
||||
sp = sub.add_parser("list", aliases=["ls"], help="list issues")
|
||||
sp.add_argument("-m", "--assigned-to-me", action="store_true", help="only issues assigned to the current user")
|
||||
sp.add_argument("-a", "--assigned-to", metavar="USER_ID", help="filter by assignee user id")
|
||||
sp.add_argument("-p", "--project", metavar="ID_OR_IDENTIFIER", help="filter by project")
|
||||
sp.add_argument("-s", "--status", metavar="NAME_OR_ID", help="filter by status (name/id, or 'open'/'closed')")
|
||||
sp.add_argument("-l", "--limit", type=int, default=50, help="max issues to return (default 50)")
|
||||
sp.add_argument("--sort", default="priority:desc,updated_on:desc", help="sort order")
|
||||
sp.set_defaults(func=cmd_list)
|
||||
|
||||
sp = sub.add_parser("show", help="show details of an issue")
|
||||
sp.add_argument("issue_id", type=int)
|
||||
sp.add_argument("--no-journals", action="store_true", help="omit note history")
|
||||
sp.set_defaults(func=cmd_show)
|
||||
|
||||
sp = sub.add_parser("create", help="create a new issue")
|
||||
sp.add_argument("-p", "--project", required=True, metavar="ID_OR_IDENTIFIER")
|
||||
sp.add_argument("-s", "--subject", required=True)
|
||||
sp.add_argument("-d", "--description")
|
||||
sp.add_argument("-a", "--assigned-to", metavar="USER_ID")
|
||||
sp.add_argument("-t", "--tracker", metavar="TRACKER_ID")
|
||||
sp.add_argument("--priority", metavar="PRIORITY_ID")
|
||||
sp.add_argument("--status", metavar="NAME_OR_ID")
|
||||
sp.set_defaults(func=cmd_create)
|
||||
|
||||
sp = sub.add_parser("update", help="update an issue")
|
||||
sp.add_argument("issue_id", type=int)
|
||||
sp.add_argument("-s", "--status", metavar="NAME_OR_ID", help="new status")
|
||||
sp.add_argument("-n", "--notes", help="add a note/journal comment")
|
||||
sp.add_argument("-a", "--assigned-to", metavar="USER_ID")
|
||||
sp.add_argument("--done-ratio", type=int, metavar="0-100")
|
||||
sp.add_argument("--subject")
|
||||
sp.add_argument("--priority", metavar="PRIORITY_ID")
|
||||
sp.set_defaults(func=cmd_update)
|
||||
|
||||
sp = sub.add_parser("close", help="close an issue (set to first closed status, done ratio 100%%)")
|
||||
sp.add_argument("issue_id", type=int)
|
||||
sp.add_argument("-n", "--notes", help="add a note/journal comment")
|
||||
sp.add_argument("--done-ratio", type=int, metavar="0-100", help="override done ratio (default 100)")
|
||||
sp.set_defaults(func=cmd_close)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except AuthError:
|
||||
_err("authentication failed - check REDMINE_API_KEY", 2)
|
||||
except ServerError as e:
|
||||
_err(f"server error: {e}")
|
||||
except ConflictError as e:
|
||||
_err(f"conflict: {e}")
|
||||
except (NoFileError, ResourceNotFoundError) as e:
|
||||
_err(str(e))
|
||||
except Exception as e: # noqa: BLE001 - top-level safety net for the CLI
|
||||
_err(f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user