gitlab-tracker-redmine 0.3.3

Optional Redmine integration plugin for gitlab-tracker
# gitlab-tracker-redmine

Optional Redmine integration plugin for [gitlab-tracker](../README.md).

Implements the `TrackerProvider` trait from `gitlab-tracker-core` to detect Redmine ticket references in MR titles and descriptions, and enrich the TUI with ticket details and time tracking.

---

## Features

* **Linked ticket display** in the Inspector's MR Info view — the following fields are shown when available:

  | Field | Source (`GET /issues/{id}.json`) |
  | :--- | :--- |
  | Ticket ID + Subject | `id`, `subject` |
  | Type badge | `tracker.name` (e.g. "Bug", "Evolution") |
  | Priority badge | `priority.name` (e.g. "Normal", "High") |
  | Status | `status.name` |
  | Author / Assignee | `author.name`, `assigned_to.name` |
  | Target version | `fixed_version.name` |
  | Start date | `start_date` |
  | Progress bar | `done_ratio` (0–100 %) |
  | Estimate / Spent / Remaining | `estimated_hours`, `spent_hours`, `remaining_hours` |
  | Direct URL | built from `redmine_url` + ticket ID |

  Type and Priority are rendered as **coloured badges** — colours are fully configurable in `redmine.yaml` (see [Configuration]#configuration below).

* **Time Log view (`P`):** press `P` twice to reach the **Time Log** view:
  * A progress bar comparing time spent vs. the ticket's estimate.
  * The full list of time entries (date, user, activity, duration, comment) fetched live from Redmine.
  * Navigating between MRs with ``/`` while on this view automatically refreshes the entries for the newly selected ticket.
* **Log time (`L`):** open a popup to submit a new time entry directly to Redmine — select the activity category, enter a duration (e.g. `1h30`, `90m`, `1.5h`), optionally add a comment, and confirm with `Enter`.
* **Tracker column** in the main table (toggleable via `C`) — shows ticket ID, status, and spent/estimated time at a glance.

---

## Enabling at Build Time

The `redmine` feature flag must be explicitly passed when building or installing:

```bash
# Build from source
cargo build --release --features redmine

# Install from source
cargo install --path gitlab-tracker --features redmine

# Install from crates.io
cargo install gitlab-tracker --features redmine
```

> Without the flag, this crate is not compiled and there is zero runtime overhead.

---

## Configuration

On first launch with the feature enabled, the app interactively prompts for your Redmine URL:

```text
🌐 No Redmine URL found in config or environment.
   Leave empty to disable Redmine integration.
Redmine URL: https://redmine.my-company.com
```

Leaving the prompt **empty** silently disables the integration — no error, no impact on the rest of the dashboard.

The configuration is persisted as a YAML file:

* **Linux:** `~/.config/gitlab-tracker/gitlab-tracker/redmine.yaml`
* **macOS:** `~/Library/Application Support/gitlab-tracker/gitlab-tracker/redmine.yaml`
* **Windows:** `C:\Users\<User>\AppData\Roaming\gitlab-tracker\gitlab-tracker\redmine.yaml`

You can also pre-configure via environment variable to skip the prompt entirely:

```env
REDMINE_URL=https://redmine.my-company.com
```

### `redmine.yaml` — full reference

```yaml
# ── Connection ────────────────────────────────────────────────────────────────

# Base URL of your Redmine instance. No trailing slash.
# Can also be set via the REDMINE_URL environment variable.
redmine_url: "https://redmine.my-company.com"

# ── Ticket detection ──────────────────────────────────────────────────────────

# Regex patterns used to detect ticket IDs in MR titles and descriptions.
# Each pattern must expose the numeric ID in capture group 1.
# The first match wins; patterns are tried in order.
ticket_patterns:
  - "#(\\d+)"                                        # plain #1234
  - "(?i)(?:refs|fixes|closes|resolves)\\s+#(\\d+)" # refs #1234, fixes #1234, …
  - "/issues/(\\d+)"                                 # full Redmine URL in description

# ── Badge colours ─────────────────────────────────────────────────────────────
#
# Colour both the "Type" and "Priority" badges displayed in the Inspector panel.
# Keys are matched CASE-INSENSITIVELY against the value returned by the Redmine API.
# Use "*" as a catch-all fallback for any label not listed explicitly.
#
# Accepted colour values:
#   Named:  red, green, yellow, blue, magenta, cyan, white, black, dark_gray, light_gray
#   Hex:    #rrggbb  (e.g. #ff6600)

# tracker_type_colors maps the "tracker.name" field (ticket category in Redmine).
# Discover your instance's values with:
#   curl -s -H "X-Redmine-API-Key: YOUR_TOKEN" \
#     "https://your-redmine.com/issues/ANY_ID.json" | jq '.issue.tracker'
tracker_type_colors:
  "Bug":       { bg: "red",      fg: "white" }
  "Evolution": { bg: "cyan",     fg: "black" }
  "Support":   { bg: "yellow",   fg: "black" }
  "*":         { bg: "dark_gray", fg: "white" } # catch-all for unlisted types

# priority_colors maps the "priority.name" field.
# Discover your instance's values with:
#   curl -s -H "X-Redmine-API-Key: YOUR_TOKEN" \
#     "https://your-redmine.com/issues/ANY_ID.json" | jq '.issue.priority'
priority_colors:
  "Low":   { bg: "dark_gray", fg: "white" }
  "Normal": { bg: "dark_gray", fg: "white" }
  "High":   { bg: "yellow",    fg: "black" }
  "Urgent": { bg: "red",       fg: "white" }
  "*":       { bg: "dark_gray", fg: "white" } # catch-all for unlisted priorities
```

| Field | Required | Description |
| :--- | :--- | :--- |
| `redmine_url` || Base URL of your Redmine instance (no trailing slash) |
| `ticket_patterns` || Regex list to detect ticket IDs in MR titles/descriptions — capture group 1 must match the numeric ID |
| `tracker_type_colors` || Badge colour map for the `tracker.name` field. Keys are case-insensitive; `"*"` is a catch-all fallback. Omit the section entirely to use the default (dark_gray / white) |
| `priority_colors` || Badge colour map for the `priority.name` field. Same rules as above |

> **How to discover your Redmine's label values**
>
> Label names (tracker types, priorities) are instance-specific and may be in any language. Run these commands against any existing issue to see what your instance returns:
>
> ```bash
> # Tracker type and priority of issue #1234
> curl -s -H "X-Redmine-API-Key: YOUR_TOKEN" \
>   "https://your-redmine.com/issues/1234.json" \
>   | jq '.issue | {tracker: .tracker.name, priority: .priority.name}'
>
> # All priorities defined in your instance
> curl -s -H "X-Redmine-API-Key: YOUR_TOKEN" \
>   "https://your-redmine.com/enumerations/issue_priorities.json" \
>   | jq '[.issue_priorities[].name]'
> ```

---

## Estimate to Complete (ETC) — automatic update on time entry submission

When you log time via the `L` popup, the app automatically recomputes the **Estimate to Complete (ETC)** on the linked Redmine ticket and writes it back.

> **ETC** (Estimate to Complete) is the standard project management term for the remaining effort needed to finish a task. It is sometimes labelled _Remaining time_ or _Reste à faire_ (RAF) in French Redmine instances.

### How it works

The computation uses fields returned directly by the Redmine issue API (`GET /issues/{id}.json`) — no admin access required:

| Priority | Field used | Formula | Available on |
| :--- | :--- | :--- | :--- |
| 1 (preferred) | `remaining_hours` | `remaining_hours − new_hours` | Redmine instances with a budget/planning plugin |
| 2 (fallback) | `estimated_hours` + `spent_hours` | `estimated_hours − spent_hours − new_hours` | All standard Redmine instances |

Both strategies clamp the result to `0.0` — ETC cannot be negative.

The ETC and budget are submitted **directly on the time entry** (`POST /time_entries.json`), which is how the Redmine Budget plugin tracks them. The `remaining_hours` field on the issue is then automatically recalculated by the plugin — no separate write to the issue is needed. If your Redmine instance does not expose these fields, they are simply omitted from the payload — the time entry is still created successfully.

### No configuration needed

The ETC update is **fully automatic** and requires no changes to `redmine.yaml`. It activates whenever the issue returns usable time fields, and degrades gracefully otherwise.

**Verify your instance exposes the fields** by running:

> ```bash
> curl -s -H "X-Redmine-API-Key: YOUR_TOKEN" \
>   "https://your-redmine.com/issues/YOUR_ISSUE_ID.json" \
>   | jq '.issue | {estimated_hours, spent_hours, remaining_hours}'
> ```

If `remaining_hours` appears in the output, Strategy 1 is used. If only `estimated_hours` and `spent_hours` appear, Strategy 2 (fallback) is used.

---

## API Token

The Redmine personal API token follows the same secure lookup chain as the GitLab token — it is **never stored in plain text**:

```text
1. REDMINE_TOKEN environment variable (if set)
2. Native OS Keyring (GNOME Keyring / macOS Keychain / Windows Credential Manager)
3. Interactive CLI prompt → saved to OS Keyring
```

---

## Adding a New Tracker Plugin

To add a different tracker (Jira, Linear, …), create a new crate and implement the `TrackerProvider` trait from `gitlab-tracker-core`.

### Required methods

```rust
#[async_trait]
impl TrackerProvider for MyProvider {
    fn name(&self) -> &'static str { "My Tracker" }

    fn detect_ticket_id(&self, title: &str, description: &str) -> Option<String> { ... }

    async fn fetch_ticket(&self, ticket_id: &str) -> Option<LinkedTicket> { ... }

    fn ticket_url(&self, ticket_id: &str) -> String { ... }
}
```

### Optional overrides (all have default no-op implementations)

```rust
    // Badge colours for Type and Priority labels — read from your config file.
    // Return raw (bg, fg) string pairs; the orchestrator converts them to ratatui::Color.
    // Omit to use the hard-coded fallback (dark_gray / white).
    fn label_colors(&self) -> LabelColorMaps { ... }

    // Time-tracking support:
    async fn fetch_activities(&self) -> Vec<Activity> { ... }
    async fn fetch_time_entries(&self, ticket_id: &str) -> Vec<TimeEntry> { ... }
    async fn log_time(&self, ticket_id: &str, entry: TimeEntryRequest) -> Result<(), String> { ... }
```

### Wiring in `main.rs`

Add a `#[cfg(feature = "my-tracker")]` block in `gitlab-tracker/src/main.rs` — the `build_tracker_colors` helper is already generic over any `TrackerProvider`, so no further changes are needed in the orchestrator:

```rust
#[cfg(feature = "my-tracker")]
if let Some(ref provider) = my_tracker_provider {
    app.tracker_colors = build_tracker_colors(provider.as_ref());
    app.tracker = my_tracker_provider;
}
```

The `LabelColorMaps` flow:

```text
Your config file (YAML/JSON/…)
  └─ label_colors() in your provider   ← String pairs, no ratatui dependency
       └─ build_tracker_colors()        ← converts to ratatui::Color (orchestrator only)
            └─ TrackerLabelColors       ← passed to the Inspector renderer
```