Files
gitlore/plans/work-item-status-graphql.md
teernisse 16cc58b17f docs: remove references to deprecated show command
Update planning docs and audit tables to reflect the removal of
`lore show`:

- CLI_AUDIT.md: remove show row, renumber remaining entries
- plan-expose-discussion-ids.md: replace `show` with
  `issues <IID>`/`mrs <IID>`
- plan-expose-discussion-ids.feedback-3.md: replace `show` with
  "detail views"
- work-item-status-graphql.md: update example commands from
  `lore show issue 123` to `lore issues 123`
2026-03-10 14:21:03 -04:00

88 KiB
Raw Permalink Blame History

plan, title, status, iteration, target_iterations, beads_revision, related_plans, created, updated
plan title status iteration target_iterations beads_revision related_plans created updated
true Work Item Status via GraphQL Enrichment iterating 7 8 1
2026-02-10 2026-02-11

Work Item Status via GraphQL Enrichment

Bead: bd-2y79 | Priority: P1 | Status: Planning Created: 2026-02-10

Problem

GitLab issues have native work item status (To do, In progress, Done, Won't do, Duplicate) but this is only available via GraphQL — not the REST API we use for ingestion. Without this data, lore cannot report or filter by workflow status, making it invisible to agents and humans.


Acceptance Criteria

Each criterion is independently testable. Implementation is complete when ALL pass.

AC-1: GraphQL Client (Unit)

  • GraphqlClient::query() POSTs to {base_url}/api/graphql with Content-Type: application/json
  • Request uses Authorization: Bearer {token} header (NOT PRIVATE-TOKEN)
  • Request body is {"query": "...", "variables": {...}}
  • Successful response: parses data field from JSON envelope
  • Error response: if top-level errors array is non-empty AND data field is absent/null, returns LoreError with first error message
  • Partial-data response: if errors array is non-empty BUT data field is present and non-null, returns GraphqlQueryResult { data, had_partial_errors: true, first_partial_error: Some("...") } (does NOT fail the query — GraphQL spec permits data + errors coexistence for partial results)
  • GraphqlQueryResult struct: data: serde_json::Value, had_partial_errors: bool, first_partial_error: Option<String> — enables callers to propagate partial-error metadata to observability surfaces
  • Successful response (no errors): returns GraphqlQueryResult { data, had_partial_errors: false, first_partial_error: None }
  • HTTP 401 → LoreError::GitLabAuthFailed
  • HTTP 403 → LoreError::GitLabAuthFailed (forbidden treated as auth failure — no separate variant needed)
  • HTTP 404 → LoreError::GitLabNotFound
  • HTTP 429 → LoreError::GitLabRateLimited (respects Retry-After header — supports both delta-seconds and HTTP-date formats, falls back to 60s if unparseable)
  • Network error → LoreError::Other

AC-2: Status Types (Unit)

  • WorkItemStatus struct has name: String, category: Option<String>, color: Option<String>, icon_name: Option<String>
  • category stored as raw string from GraphQL (e.g., "IN_PROGRESS", "TO_DO") — no enum, no normalization
  • WorkItemStatus deserializes from GraphQL JSON shape with name, category, color, iconName
  • All fields except name are Option — absent fields deserialize to None
  • Custom statuses (18.5+) with non-standard category values deserialize without error

AC-3: Status Fetcher (Integration)

  • fetch_issue_statuses() returns FetchStatusResult containing:
    • statuses: HashMap<i64, WorkItemStatus> keyed by issue IID (parsed from GraphQL's String to i64)
    • all_fetched_iids: HashSet<i64> — all IIDs seen in GraphQL response (for staleness clearing)
    • unsupported_reason: Option<UnsupportedReason> — set when enrichment was skipped due to 404/403 (enables orchestrator to distinguish "no statuses" from "feature unavailable")
    • partial_error_count: usize — count of pages that returned partial-data responses (data + errors)
    • first_partial_error: Option<String> — first partial-error message for diagnostic surfacing
  • Paginates: follows pageInfo.endCursor + hasNextPage until all pages consumed
  • Pagination guard: if hasNextPage=true but endCursor is None or unchanged, aborts pagination loop with a warning log and returns the partial result collected so far (prevents infinite loops from GraphQL cursor bugs)
  • Adaptive page size: starts with first=100; on GraphQL complexity or timeout errors (detected via error message substring matching for "complexity" or "timeout"), retries the same cursor with halved page size (100→50→25→10); if page size 10 still fails, returns the error
  • Query uses $first: Int! variable (not hardcoded first: 100) to support adaptive page sizing
  • Query includes __typename in widgets selection; parser matches __typename == "WorkItemWidgetStatus" for deterministic widget identification (no heuristic try-deserialize)
  • Non-status widgets are ignored deterministically via __typename check
  • Issues with no status widget in widgets array → in all_fetched_iids but not in statuses map (no error)
  • Issues with status widget but status: null → in all_fetched_iids but not in statuses map
  • GraphQL 404 → returns Ok(FetchStatusResult) with empty collections, unsupported_reason == Some(UnsupportedReason::GraphqlEndpointMissing), + warning log
  • GraphQL 403 (GitLabAuthFailed) → returns Ok(FetchStatusResult) with empty collections, unsupported_reason == Some(UnsupportedReason::AuthForbidden), + warning log

AC-4: Migration 021 (Unit)

  • Migration adds 5 nullable columns to issues: status_name TEXT, status_category TEXT, status_color TEXT, status_icon_name TEXT, status_synced_at INTEGER (ms epoch UTC — when enrichment last wrote/cleared this row's status)
  • Adds compound index idx_issues_project_status_name(project_id, status_name) for --status filter performance
  • LATEST_SCHEMA_VERSION becomes 21
  • Existing issues retain all data (ALTER TABLE ADD COLUMN is non-destructive)
  • In-memory DB test: after migration, SELECT status_name, status_category, status_color, status_icon_name, status_synced_at FROM issues succeeds
  • NULL default: existing rows have NULL for all 5 new columns

AC-5: Config Toggle (Unit)

  • SyncConfig has fetch_work_item_status: bool field
  • Default value: true (uses existing default_true() helper)
  • JSON key: "fetchWorkItemStatus" (camelCase, matching convention)
  • Existing config files without this key → defaults to true (no breakage)

AC-6: Enrichment in Orchestrator (Integration)

  • Enrichment runs after ingest_issues() completes, before discussion sync
  • Runs on every sync (not gated by --full)
  • Gated by config.sync.fetch_work_item_status — if false, skipped entirely
  • Creates GraphqlClient via client.graphql_client() factory (token stays encapsulated)
  • For each project: calls fetch_issue_statuses(), then UPDATEs matching issues rows
  • Enrichment DB writes are transactional per project (all-or-nothing)
  • Before applying updates, NULL out status fields for issues that were fetched but have no status widget (prevents stale status from lingering when status is removed)
  • If enrichment fails mid-project, prior persisted statuses are unchanged (transaction rollback)
  • UPDATE SQL: SET status_name=?, status_category=?, status_color=?, status_icon_name=?, status_synced_at=? WHERE project_id=? AND iid=? (synced_at = current epoch ms)
  • Clear SQL also sets status_synced_at to current epoch ms (records when we confirmed absence of status)
  • Logs summary: "Enriched {n} issues with work item status for {project}" — includes seen, enriched, cleared, without_widget counts
  • On any GraphQL error: logs warning, continues to next project (never fails the sync)
  • If project path lookup fails (DB error or missing row), status enrichment is skipped for that project with warning log and status_enrichment_error: "project_path_missing" — does NOT fail the overall project pipeline
  • IngestProjectResult gains statuses_enriched: usize, statuses_cleared: usize, statuses_seen: usize, statuses_without_widget: usize, partial_error_count: usize, first_partial_error: Option<String>, and status_enrichment_error: Option<String> (captures error message when enrichment fails for this project)
  • Progress events: StatusEnrichmentComplete { enriched, cleared } and StatusEnrichmentSkipped (when config toggle is false)
  • Enrichment log line includes seen, enriched, cleared, and without_widget counts for full observability

AC-7: Show Issue Display (E2E)

Human (lore issues 123):

  • New line after "State": Status: In progress (colored by status_color hex → nearest terminal color)
  • Status line only shown when status_name IS NOT NULL
  • Category shown in parens when available, lowercased: Status: In progress (in_progress)

Robot (lore --robot issues 123):

  • JSON includes status_name, status_category, status_color, status_icon_name, status_synced_at fields
  • Fields are null (not absent) when status not available
  • status_synced_at is integer (ms epoch UTC) or null — enables freshness checks by consumers

AC-8: List Issues Display (E2E)

Human (lore list issues):

  • New "Status" column in table after "State" column
  • Status name colored by status_color hex → nearest terminal color
  • NULL status → empty cell (no placeholder text)

Robot (lore --robot list issues):

  • JSON includes status_name, status_category fields on each issue
  • --fields supports: status_name, status_category, status_color, status_icon_name, status_synced_at
  • --fields minimal preset does NOT include status fields (keeps token count low)

AC-9: List Issues Filter (E2E)

  • lore list issues --status "In progress" → only issues where status_name = 'In progress'
  • Filter uses case-insensitive matching (COLLATE NOCASE) for UX — "in progress" matches "In progress"
  • --status is repeatable: --status "In progress" --status "To do" → issues matching ANY of the given statuses (OR semantics within --status, AND with other filters)
  • Single --status produces WHERE status_name = ? COLLATE NOCASE; multiple produce WHERE status_name IN (?, ?) COLLATE NOCASE
  • --status combined with other filters (e.g., --state opened --status "To do") → AND logic
  • --status with no matching issues → "No issues found."

AC-10: Robot Sync Envelope (E2E)

  • lore --robot sync JSON response includes per-project status_enrichment object: { "mode": "fetched|unsupported|skipped", "reason": null | "graphql_endpoint_missing" | "auth_forbidden", "seen": N, "enriched": N, "cleared": N, "without_widget": N, "partial_errors": N, "first_partial_error": null | "message", "error": null | "message" }
  • mode: "fetched" — enrichment ran normally (even if 0 statuses found)
  • mode: "unsupported" — GraphQL returned 404 or 403; reason explains why; all counters are 0
  • mode: "skipped" — config toggle is off; all other fields are 0/null
  • When enrichment fails for a project: error contains the error message string, counters are 0, mode is "fetched" (it attempted)
  • partial_errors > 0 indicates GraphQL returned partial-data responses (data + errors) — agents can use this to flag potentially incomplete status data
  • seen counter enables agents to distinguish "project has 0 issues" from "project has 500 issues but 0 with status"
  • Aggregate status_enrichment_errors: N in top-level sync summary for quick agent health checks

AC-11: Compiler & Quality Gates

  • cargo check --all-targets passes with zero errors
  • cargo clippy --all-targets -- -D warnings passes (pedantic + nursery enabled)
  • cargo fmt --check passes
  • cargo test passes — all new + existing tests green

GitLab API Constraints

Tier Requirement

Premium or Ultimate only. The status widget (WorkItemWidgetStatus) lives entirely in GitLab EE. On Free tier, the widget simply won't appear in the widgets array — no error, just absent.

Version Requirements

GitLab Version Status Support
< 17.11 No status widget at all
17.11 - 18.1 Status widget (Experiment), category field missing
18.2 - 18.3 Status widget with feature flag (enabled by default)
18.4+ Generally available, workItemAllowedStatuses query added
18.5+ Custom statuses via Lifecycles

Authentication

GraphQL endpoint (/api/graphql) does NOT accept PRIVATE-TOKEN header.

Must use: Authorization: Bearer <token>

Same personal access token works — just a different header format. Requires api or read_api scope.

Query Path

Must use workItems resolver, NOT project.issues. The legacy issues field returns the old Issue type which does not include status widgets.

query($projectPath: ID!, $after: String, $first: Int!) {
  project(fullPath: $projectPath) {
    workItems(types: [ISSUE], first: $first, after: $after) {
      nodes {
        iid
        state
        widgets {
          __typename
          ... on WorkItemWidgetStatus {
            status {
              name
              category
              color
              iconName
            }
          }
        }
      }
      pageInfo {
        endCursor
        hasNextPage
      }
    }
  }
}

GraphQL Limits

Limit Value
Max complexity 250 (authenticated)
Max page size 100 nodes
Max query size 10,000 chars
Request timeout 30 seconds

Status Values

System-defined statuses (default lifecycle):

ID Name Color Category Maps to State
1 To do #737278 (gray) to_do open
2 In progress #1f75cb (blue) in_progress open
3 Done #108548 (green) done closed
4 Won't do #DD2B0E (red) canceled closed
5 Duplicate #DD2B0E (red) canceled closed

Known category values: TRIAGE, TO_DO, IN_PROGRESS, DONE, CANCELED

Note: Organizations with Premium/Ultimate on 18.5+ can define up to 70 custom statuses per namespace. Custom status names, IDs, and potentially category values will vary by instance. We store category as a raw string (Option<String>) rather than an enum to avoid breaking on unknown values from custom lifecycles or future GitLab releases.

Status-State Synchronization

Setting a status in done/canceled category automatically closes the issue. Setting a status in triage/to_do/in_progress automatically reopens it. This is bidirectional — closing via REST sets status to default_closed_status.


Implementation Detail

File 1: src/gitlab/graphql.rs (NEW)

Purpose: Minimal GraphQL client and status-specific fetcher.

use std::collections::{HashMap, HashSet};

use reqwest::Client;
use serde::Deserialize;
use tracing::{debug, warn};

use crate::core::error::{LoreError, Result};

use super::types::WorkItemStatus;

// ─── GraphQL Client ──────────────────────────────────────────────────────────

pub struct GraphqlClient {
    http: Client,
    base_url: String,  // e.g. "https://gitlab.example.com"
    token: String,
}

/// Result of a GraphQL query — includes both data and partial-error metadata.
/// Partial errors occur when GraphQL returns both `data` and `errors` (per spec,
/// this means some fields resolved successfully while others failed).
pub struct GraphqlQueryResult {
    pub data: serde_json::Value,
    pub had_partial_errors: bool,
    pub first_partial_error: Option<String>,
}

impl GraphqlClient {
    pub fn new(base_url: &str, token: &str) -> Self {
        let base_url = base_url.trim_end_matches('/').to_string();
        let http = Client::builder()
            .timeout(std::time::Duration::from_secs(30))
            .build()
            .expect("Failed to build HTTP client");
        Self {
            http,
            base_url,
            token: token.to_string(),
        }
    }

    /// POST a GraphQL query and return a `GraphqlQueryResult`.
    /// Returns Err if HTTP fails, JSON parse fails, or response has `errors` with no `data`.
    /// If response has both `errors` and `data`, returns `GraphqlQueryResult` with
    /// `had_partial_errors=true` and `first_partial_error` populated
    /// (GraphQL spec permits partial results with errors).
    pub async fn query(
        &self,
        query: &str,
        variables: serde_json::Value,
    ) -> Result<GraphqlQueryResult> {
        let url = format!("{}/api/graphql", self.base_url);
        let body = serde_json::json!({
            "query": query,
            "variables": variables,
        });

        let response = self
            .http
            .post(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .header("Content-Type", "application/json")
            .json(&body)
            .send()
            .await
            .map_err(|e| LoreError::Other(format!("GraphQL request failed: {e}")))?;

        let status = response.status();

        if status == reqwest::StatusCode::UNAUTHORIZED
            || status == reqwest::StatusCode::FORBIDDEN
        {
            return Err(LoreError::GitLabAuthFailed);
        }
        if status == reqwest::StatusCode::NOT_FOUND {
            return Err(LoreError::GitLabNotFound {
                resource: "GraphQL endpoint".to_string(),
            });
        }
        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
            let retry_after = response
                .headers()
                .get("retry-after")
                .and_then(|v| v.to_str().ok())
                .and_then(|v| {
                    // Try delta-seconds first (e.g., "60"), then HTTP-date
                    v.parse::<u64>().ok().or_else(|| {
                        httpdate::parse_http_date(v)
                            .ok()
                            .and_then(|dt| dt.duration_since(std::time::SystemTime::now()).ok())
                            .map(|d| d.as_secs())
                    })
                })
                .unwrap_or(60);  // LoreError::GitLabRateLimited uses u64, not Option<u64>
            return Err(LoreError::GitLabRateLimited { retry_after });
        }
        if !status.is_success() {
            let body = response
                .text()
                .await
                .unwrap_or_else(|_| "unknown".to_string());
            return Err(LoreError::Other(format!(
                "GraphQL HTTP {}: {}",
                status, body
            )));
        }

        let json: serde_json::Value = response
            .json()
            .await
            .map_err(|e| LoreError::Other(format!("GraphQL response parse failed: {e}")))?;

        // Check for GraphQL-level errors
        let errors = json
            .get("errors")
            .and_then(|e| e.as_array())
            .filter(|arr| !arr.is_empty());

        let data = json.get("data").filter(|d| !d.is_null()).cloned();

        if let Some(err_array) = errors {
            let first_msg = err_array[0]
                .get("message")
                .and_then(|m| m.as_str())
                .unwrap_or("Unknown GraphQL error")
                .to_string();

            if let Some(data) = data {
                // Partial data with errors — return data with error metadata
                warn!(error = %first_msg, "GraphQL returned partial data with errors");
                return Ok(GraphqlQueryResult {
                    data,
                    had_partial_errors: true,
                    first_partial_error: Some(first_msg),
                });
            }
            // Errors only, no data
            return Err(LoreError::Other(format!("GraphQL error: {first_msg}")));
        }

        data.map(|d| GraphqlQueryResult {
            data: d,
            had_partial_errors: false,
            first_partial_error: None,
        })
        .ok_or_else(|| LoreError::Other("GraphQL response missing 'data' field".to_string()))
    }
}

// ─── Status Fetcher ──────────────────────────────────────────────────────────

const ISSUE_STATUS_QUERY: &str = r#"
query($projectPath: ID!, $after: String, $first: Int!) {
  project(fullPath: $projectPath) {
    workItems(types: [ISSUE], first: $first, after: $after) {
      nodes {
        iid
        widgets {
          __typename
          ... on WorkItemWidgetStatus {
            status {
              name
              category
              color
              iconName
            }
          }
        }
      }
      pageInfo {
        endCursor
        hasNextPage
      }
    }
  }
}
"#;

/// Page sizes to try, in order, when adaptive fallback is triggered.
const PAGE_SIZES: &[u32] = &[100, 50, 25, 10];

/// Deserialization types for the GraphQL response shape.
/// These are private — only `WorkItemStatus` escapes via the return type.

#[derive(Deserialize)]
struct WorkItemsResponse {
    project: Option<ProjectNode>,
}

#[derive(Deserialize)]
struct ProjectNode {
    #[serde(rename = "workItems")]
    work_items: Option<WorkItemConnection>,
}

#[derive(Deserialize)]
struct WorkItemConnection {
    nodes: Vec<WorkItemNode>,
    #[serde(rename = "pageInfo")]
    page_info: PageInfo,
}

#[derive(Deserialize)]
struct WorkItemNode {
    iid: String,  // GraphQL returns iid as String
    widgets: Vec<serde_json::Value>,
}

#[derive(Deserialize)]
struct PageInfo {
    #[serde(rename = "endCursor")]
    end_cursor: Option<String>,
    #[serde(rename = "hasNextPage")]
    has_next_page: bool,
}

#[derive(Deserialize)]
struct StatusWidget {
    status: Option<WorkItemStatus>,
}

/// Reason why status enrichment was not available for a project.
/// Used in `FetchStatusResult` to distinguish
/// "no statuses found" from "feature unavailable."
#[derive(Debug, Clone)]
pub enum UnsupportedReason {
    /// GraphQL endpoint returned 404 (old GitLab, self-hosted with GraphQL disabled)
    GraphqlEndpointMissing,
    /// GraphQL endpoint returned 403 (insufficient permissions)
    AuthForbidden,
}

/// Result of fetching issue statuses — includes both the status map and the
/// set of all IIDs that were seen in the GraphQL response (with or without status).
/// `all_fetched_iids` is a `HashSet` for O(1) staleness lookups in the orchestrator
/// when NULLing out status fields for issues that no longer have a status widget.
///
/// `unsupported_reason` is `Some(...)` when enrichment was skipped due to 404/403 —
/// enables the orchestrator to distinguish "project has no statuses" from "feature
/// unavailable" for observability and robot sync output.
///
/// `partial_error_count` and `first_partial_error` track pages where GraphQL returned
/// both `data` and `errors` — enables agents to detect potentially incomplete data.
pub struct FetchStatusResult {
    pub statuses: HashMap<i64, WorkItemStatus>,
    pub all_fetched_iids: HashSet<i64>,
    pub unsupported_reason: Option<UnsupportedReason>,
    pub partial_error_count: usize,
    pub first_partial_error: Option<String>,
}

/// Returns true if a GraphQL error message suggests the query is too complex
/// or timed out — conditions where reducing page size may help.
fn is_complexity_or_timeout_error(msg: &str) -> bool {
    let lower = msg.to_ascii_lowercase();
    lower.contains("complexity") || lower.contains("timeout")
}

/// Fetch work item statuses for all issues in a project.
/// Returns a `FetchStatusResult` containing:
/// - `statuses`: map of IID (i64) → WorkItemStatus for issues that have a status widget
/// - `all_fetched_iids`: all IIDs seen in the GraphQL response as `HashSet<i64>` (for O(1) staleness clearing)
/// - `unsupported_reason`: `Some(...)` when enrichment was skipped due to 404/403
/// - `partial_error_count` / `first_partial_error`: partial-error tracking across pages
///
/// Paginates through all results. Uses adaptive page sizing: starts with 100,
/// falls back to 50→25→10 on complexity/timeout errors.
/// Includes a pagination guard: aborts if `hasNextPage=true` but cursor is
/// `None` or unchanged from the previous page (prevents infinite loops from GraphQL bugs).
///
/// On 404/403: returns Ok(FetchStatusResult) with empty maps + unsupported_reason + warning log.
/// On other errors: returns Err.
pub async fn fetch_issue_statuses(
    client: &GraphqlClient,
    project_path: &str,
) -> Result<FetchStatusResult> {
    let mut statuses = HashMap::new();
    let mut all_fetched_iids = HashSet::new();
    let mut cursor: Option<String> = None;
    let mut page_size_idx = 0;  // index into PAGE_SIZES
    let mut partial_error_count = 0usize;
    let mut first_partial_error: Option<String> = None;

    loop {
        let current_page_size = PAGE_SIZES[page_size_idx];
        let variables = serde_json::json!({
            "projectPath": project_path,
            "after": cursor,
            "first": current_page_size,
        });

        let query_result = match client.query(ISSUE_STATUS_QUERY, variables).await {
            Ok(result) => result,
            Err(LoreError::GitLabNotFound { .. }) => {
                warn!(
                    project = project_path,
                    "GraphQL endpoint not found — skipping status enrichment"
                );
                return Ok(FetchStatusResult {
                    statuses,
                    all_fetched_iids,
                    unsupported_reason: Some(UnsupportedReason::GraphqlEndpointMissing),
                    partial_error_count,
                    first_partial_error,
                });
            }
            Err(LoreError::GitLabAuthFailed) => {
                warn!(
                    project = project_path,
                    "GraphQL auth failed — skipping status enrichment"
                );
                return Ok(FetchStatusResult {
                    statuses,
                    all_fetched_iids,
                    unsupported_reason: Some(UnsupportedReason::AuthForbidden),
                    partial_error_count,
                    first_partial_error,
                });
            }
            Err(LoreError::Other(ref msg)) if is_complexity_or_timeout_error(msg) => {
                // Adaptive page size: try smaller page
                if page_size_idx + 1 < PAGE_SIZES.len() {
                    let old_size = PAGE_SIZES[page_size_idx];
                    page_size_idx += 1;
                    let new_size = PAGE_SIZES[page_size_idx];
                    warn!(
                        project = project_path,
                        old_size,
                        new_size,
                        "GraphQL complexity/timeout error — reducing page size and retrying"
                    );
                    continue;  // retry same cursor with smaller page
                }
                // Smallest page size still failed
                return Err(LoreError::Other(msg.clone()));
            }
            Err(e) => return Err(e),
        };

        // Track partial-error metadata
        if query_result.had_partial_errors {
            partial_error_count += 1;
            if first_partial_error.is_none() {
                first_partial_error = query_result.first_partial_error.clone();
            }
        }

        let response: WorkItemsResponse = serde_json::from_value(query_result.data)
            .map_err(|e| LoreError::Other(format!("Failed to parse GraphQL response: {e}")))?;

        let connection = match response
            .project
            .and_then(|p| p.work_items)
        {
            Some(c) => c,
            None => {
                debug!(
                    project = project_path,
                    "No workItems in GraphQL response"
                );
                break;
            }
        };

        for node in &connection.nodes {
            if let Ok(iid) = node.iid.parse::<i64>() {
                all_fetched_iids.insert(iid);

                // Find the status widget via __typename for deterministic matching
                for widget_value in &node.widgets {
                    let is_status_widget = widget_value
                        .get("__typename")
                        .and_then(|t| t.as_str())
                        == Some("WorkItemWidgetStatus");

                    if is_status_widget {
                        if let Ok(sw) = serde_json::from_value::<StatusWidget>(widget_value.clone())
                            && let Some(status) = sw.status
                        {
                            statuses.insert(iid, status);
                        }
                    }
                }
            }
        }

        // After a successful page, reset page size back to max for next page
        // (the complexity issue may be cursor-position-specific)
        page_size_idx = 0;

        if connection.page_info.has_next_page {
            let new_cursor = connection.page_info.end_cursor;
            // Pagination guard: abort if cursor is None or unchanged (prevents infinite loops)
            if new_cursor.is_none() || new_cursor == cursor {
                warn!(
                    project = project_path,
                    pages_fetched = all_fetched_iids.len(),
                    "Pagination cursor stalled (hasNextPage=true but cursor unchanged) — returning partial result"
                );
                break;
            }
            cursor = new_cursor;
        } else {
            break;
        }
    }

    debug!(
        project = project_path,
        count = statuses.len(),
        total_fetched = all_fetched_iids.len(),
        partial_error_count,
        "Fetched issue statuses via GraphQL"
    );

    Ok(FetchStatusResult {
        statuses,
        all_fetched_iids,
        unsupported_reason: None,
        partial_error_count,
        first_partial_error,
    })
}

/// Map RGB to nearest ANSI 256-color index using the 6x6x6 color cube (indices 16-231).
/// NOTE: clippy::items_after_test_module — this MUST be placed BEFORE the test module.
pub fn ansi256_from_rgb(r: u8, g: u8, b: u8) -> u8 {
    let ri = ((u16::from(r) * 5 + 127) / 255) as u8;
    let gi = ((u16::from(g) * 5 + 127) / 255) as u8;
    let bi = ((u16::from(b) * 5 + 127) / 255) as u8;
    16 + 36 * ri + 6 * gi + bi
}

#[cfg(test)]
mod tests {
    use super::*;
    // Tests use wiremock or similar mock server
    // See TDD Plan section for test specifications
}

File 2: src/gitlab/mod.rs (MODIFY)

Existing code (14 lines):

pub mod client;
pub mod transformers;
pub mod types;

Add after pub mod types;:

pub mod graphql;

Add to pub use types::{...} list:

WorkItemStatus,

File 3: src/gitlab/types.rs (MODIFY)

Add at end of file (after GitLabMergeRequest struct, before any #[cfg(test)]):

/// Work item status from GitLab GraphQL API.
/// Stored in the `issues` table columns: status_name, status_category, status_color, status_icon_name.
///
/// `category` is stored as a raw string from GraphQL (e.g., "IN_PROGRESS", "TO_DO", "DONE").
/// No enum — custom statuses on GitLab 18.5+ can have arbitrary category values, and even
/// system-defined categories may change across GitLab versions. Storing the raw string avoids
/// serde deserialization failures on unknown values.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkItemStatus {
    pub name: String,
    pub category: Option<String>,
    pub color: Option<String>,
    #[serde(rename = "iconName")]
    pub icon_name: Option<String>,
}

File 4: src/core/db.rs (MODIFY)

Existing pattern — each migration is a tuple of (&str, &str) loaded via include_str!:

const MIGRATIONS: &[(&str, &str)] = &[
    ("001", include_str!("../../migrations/001_initial.sql")),
    // ... 002 through 020 ...
    ("020", include_str!("../../migrations/020_mr_diffs_watermark.sql")),
];

Add as the 21st entry at the end of the MIGRATIONS array:

    ("021", include_str!("../../migrations/021_work_item_status.sql")),

LATEST_SCHEMA_VERSION is computed as MIGRATIONS.len() as i32 — automatically becomes 21.

File 5: src/core/config.rs (MODIFY)

In SyncConfig struct, add after fetch_mr_file_changes:

#[serde(rename = "fetchWorkItemStatus", default = "default_true")]
pub fetch_work_item_status: bool,

In impl Default for SyncConfig, add after fetch_mr_file_changes: true:

fetch_work_item_status: true,

File 6: src/ingestion/orchestrator.rs (MODIFY)

Existing ingest_project_issues_with_progress flow (simplified):

Phase 1: ingest_issues()
Phase 2: sync_discussions()     ← discussions for changed issues
Phase 3: drain_resource_events() ← if config.sync.fetch_resource_events
Phase 4: extract_refs_from_state_events()

Insert new Phase 1.5 between issue ingestion and discussion sync.

The orchestrator function receives a &GitLabClient. Use the new client.graphql_client() factory (File 13) to get a ready-to-use GraphQL client without exposing the raw token. The function has project_id: i64 but needs path_with_namespace for GraphQL — look it up from DB. The path lookup uses .optional()? to make failure non-fatal — if the project path is missing or the query fails, enrichment is skipped with a structured error rather than failing the entire project pipeline:

// ── Phase 1.5: GraphQL Status Enrichment ────────────────────────────────

if config.sync.fetch_work_item_status && !signal.is_cancelled() {
    // Get project path for GraphQL query (orchestrator only has project_id).
    // Non-fatal: if path is missing, skip enrichment with structured error.
    let project_path: Option<String> = conn
        .query_row(
            "SELECT path_with_namespace FROM projects WHERE id = ?1",
            [project_id],
            |r| r.get(0),
        )
        .optional()?;

    let Some(project_path) = project_path else {
        warn!(project_id, "Project path not found — skipping status enrichment");
        result.status_enrichment_error = Some("project_path_missing".to_string());
        result.status_enrichment_mode = "fetched".to_string();  // attempted but unavailable
        emit(ProgressEvent::StatusEnrichmentComplete { enriched: 0, cleared: 0 });
        // Fall through to discussion sync — enrichment failure is non-fatal
    };

    if let Some(ref project_path) = project_path {
        let graphql_client = client.graphql_client();  // factory keeps token encapsulated

        match crate::gitlab::graphql::fetch_issue_statuses(
            &graphql_client,
            project_path,
        )
        .await
        {
            Ok(fetch_result) => {
                // Record unsupported reason for robot sync output
                result.status_enrichment_mode = match &fetch_result.unsupported_reason {
                    Some(crate::gitlab::graphql::UnsupportedReason::GraphqlEndpointMissing) => {
                        result.status_unsupported_reason = Some("graphql_endpoint_missing".to_string());
                        "unsupported".to_string()
                    }
                    Some(crate::gitlab::graphql::UnsupportedReason::AuthForbidden) => {
                        result.status_unsupported_reason = Some("auth_forbidden".to_string());
                        "unsupported".to_string()
                    }
                    None => "fetched".to_string(),
                };

                // Coverage telemetry
                result.statuses_seen = fetch_result.all_fetched_iids.len();
                result.partial_error_count = fetch_result.partial_error_count;
                result.first_partial_error = fetch_result.first_partial_error.clone();

                let now_ms = std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .unwrap_or_default()
                    .as_millis() as i64;
                let (enriched, cleared) = enrich_issue_statuses_txn(
                    conn, project_id, &fetch_result.statuses, &fetch_result.all_fetched_iids, now_ms,
                )?;
                result.statuses_enriched = enriched;
                result.statuses_cleared = cleared;
                result.statuses_without_widget = result.statuses_seen
                    .saturating_sub(enriched);
                info!(
                    project = %project_path,
                    seen = result.statuses_seen,
                    enriched,
                    cleared,
                    without_widget = result.statuses_without_widget,
                    partial_errors = result.partial_error_count,
                    "Issue status enrichment complete"
                );
            }
            Err(e) => {
                let msg = format!("{e}");
                warn!(
                    project = %project_path,
                    error = %msg,
                    "GraphQL status enrichment failed — continuing without status data"
                );
                result.status_enrichment_error = Some(msg);
                result.status_enrichment_mode = "fetched".to_string();  // it attempted
            }
        }

        emit(ProgressEvent::StatusEnrichmentComplete {
            enriched: result.statuses_enriched,
            cleared: result.statuses_cleared,
        });
    }
} else {
    result.status_enrichment_mode = "skipped".to_string();
    emit(ProgressEvent::StatusEnrichmentSkipped);
}

New helper function in orchestrator.rs — transactional with staleness clearing:

/// Apply status enrichment within a transaction. Two phases:
/// 1. NULL out status fields for issues that were fetched but have no status widget
///    (prevents stale status from lingering when a status is removed in GitLab)
/// 2. Apply new status values for issues that do have a status widget
///
/// Both phases write `status_synced_at` to record when enrichment last touched each row.
/// If anything fails, the entire transaction rolls back — no partial updates.
fn enrich_issue_statuses_txn(
    conn: &Connection,
    project_id: i64,
    statuses: &HashMap<i64, crate::gitlab::types::WorkItemStatus>,
    all_fetched_iids: &HashSet<i64>,
    now_ms: i64,
) -> Result<(usize, usize)> {
    let tx = conn.unchecked_transaction()?;

    // Phase 1: Clear stale statuses for fetched issues that no longer have a status widget
    let mut clear_stmt = tx.prepare_cached(
        "UPDATE issues
         SET status_name = NULL, status_category = NULL, status_color = NULL,
             status_icon_name = NULL, status_synced_at = ?3
         WHERE project_id = ?1 AND iid = ?2
           AND status_name IS NOT NULL",
    )?;
    let mut cleared = 0usize;
    for iid in all_fetched_iids {
        if !statuses.contains_key(iid) {
            let rows = clear_stmt.execute(rusqlite::params![project_id, iid, now_ms])?;
            if rows > 0 {
                cleared += 1;
            }
        }
    }

    // Phase 2: Apply new status values
    let mut update_stmt = tx.prepare_cached(
        "UPDATE issues
         SET status_name = ?1, status_category = ?2, status_color = ?3,
             status_icon_name = ?4, status_synced_at = ?5
         WHERE project_id = ?6 AND iid = ?7",
    )?;

    let mut enriched = 0;
    for (iid, status) in statuses {
        let rows = update_stmt.execute(rusqlite::params![
            &status.name,
            &status.category,
            &status.color,
            &status.icon_name,
            now_ms,
            project_id,
            iid,
        ])?;
        if rows > 0 {
            enriched += 1;
        }
    }

    tx.commit()?;
    Ok((enriched, cleared))
}

Modify IngestProjectResult — add fields:

pub statuses_enriched: usize,
pub statuses_cleared: usize,
pub statuses_seen: usize,                     // total IIDs in GraphQL response
pub statuses_without_widget: usize,           // seen - enriched (coverage metric)
pub partial_error_count: usize,               // pages with partial-data responses
pub first_partial_error: Option<String>,       // first partial-error message
pub status_enrichment_error: Option<String>,
pub status_enrichment_mode: String,            // "fetched" | "unsupported" | "skipped"
pub status_unsupported_reason: Option<String>, // "graphql_endpoint_missing" | "auth_forbidden"

Modify ProgressEvent enum — add variants:

StatusEnrichmentComplete { enriched: usize, cleared: usize },
StatusEnrichmentSkipped,  // emitted when config.sync.fetch_work_item_status is false

File 7: src/cli/commands/show.rs (MODIFY)

Modify IssueRow (private struct in show.rs) — add 5 fields:

status_name: Option<String>,
status_category: Option<String>,
status_color: Option<String>,
status_icon_name: Option<String>,
status_synced_at: Option<i64>,

Modify BOTH find_issue SQL queries (with and without project filter) — add 5 columns to SELECT:

Note: find_issue() has two separate SQL strings — one for when project_filter is Some and one for None. Both must be updated identically.

SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
       i.created_at, i.updated_at, i.web_url, p.path_with_namespace,
       i.due_date, i.milestone_title,
       i.status_name, i.status_category, i.status_color, i.status_icon_name, i.status_synced_at

Column indices: status_name=12, status_category=13, status_color=14, status_icon_name=15, status_synced_at=16

Modify find_issue row mapping — add after milestone_title: row.get(11)?:

status_name: row.get(12)?,
status_category: row.get(13)?,
status_color: row.get(14)?,
status_icon_name: row.get(15)?,
status_synced_at: row.get(16)?,

Modify IssueDetail (public struct) — add 5 fields:

pub status_name: Option<String>,
pub status_category: Option<String>,
pub status_color: Option<String>,
pub status_icon_name: Option<String>,
pub status_synced_at: Option<i64>,

Modify run_show_issue return — add fields from issue row:

status_name: issue.status_name,
status_category: issue.status_category,
status_color: issue.status_color,
status_icon_name: issue.status_icon_name,
status_synced_at: issue.status_synced_at,

Modify print_show_issue — add after the "State:" line (currently ~line 604):

if let Some(status) = &issue.status_name {
    let status_display = if let Some(cat) = &issue.status_category {
        format!("{status} ({})", cat.to_ascii_lowercase())
    } else {
        status.clone()
    };
    println!(
        "Status:   {}",
        style_with_hex(&status_display, issue.status_color.as_deref())
    );
}

New helper function for hex color → terminal color:

fn style_with_hex<'a>(text: &'a str, hex: Option<&str>) -> console::StyledObject<&'a str> {
    let Some(hex) = hex else {
        return style(text);
    };
    let hex = hex.trim_start_matches('#');
    // NOTE: clippy::collapsible_if — must combine conditions
    if hex.len() == 6
        && let (Ok(r), Ok(g), Ok(b)) = (
            u8::from_str_radix(&hex[0..2], 16),
            u8::from_str_radix(&hex[2..4], 16),
            u8::from_str_radix(&hex[4..6], 16),
        )
    {
        return style(text).color256(crate::gitlab::graphql::ansi256_from_rgb(r, g, b));
    }
    style(text)
}

Modify IssueDetailJson — add 5 fields:

pub status_name: Option<String>,
pub status_category: Option<String>,
pub status_color: Option<String>,
pub status_icon_name: Option<String>,
pub status_synced_at: Option<i64>,

Modify From<&IssueDetail> for IssueDetailJson — add after existing field mappings:

status_name: d.status_name.clone(),
status_category: d.status_category.clone(),
status_color: d.status_color.clone(),
status_icon_name: d.status_icon_name.clone(),
status_synced_at: d.status_synced_at,

File 8: src/cli/commands/list.rs (MODIFY)

Modify IssueListRow — add 5 fields:

pub status_name: Option<String>,
pub status_category: Option<String>,
pub status_color: Option<String>,
pub status_icon_name: Option<String>,
pub status_synced_at: Option<i64>,

Modify IssueListRowJson — add 5 fields (all for robot mode):

pub status_name: Option<String>,
pub status_category: Option<String>,
pub status_color: Option<String>,
pub status_icon_name: Option<String>,
pub status_synced_at: Option<i64>,

Modify From<&IssueListRow> for IssueListRowJson — add after existing field mappings:

status_name: r.status_name.clone(),
status_category: r.status_category.clone(),
status_color: r.status_color.clone(),
status_icon_name: r.status_icon_name.clone(),
status_synced_at: r.status_synced_at,

Modify query_issues SELECT — add 5 columns after unresolved_count subquery:

i.status_name,
i.status_category,
i.status_color,
i.status_icon_name,
i.status_synced_at

Modify query_issues row mapping — add after unresolved_count: row.get(11)?:

The existing query_issues SELECT has 12 columns (indices 0-11). The 5 new status columns append as indices 12-16:

status_name: row.get(12)?,
status_category: row.get(13)?,
status_color: row.get(14)?,
status_icon_name: row.get(15)?,
status_synced_at: row.get(16)?,

Modify ListFilters — add status filter:

pub statuses: &'a [String],

Modify query_issues WHERE clause builder — add after has_due_date block:

if !filters.statuses.is_empty() {
    if filters.statuses.len() == 1 {
        where_clauses.push("i.status_name = ? COLLATE NOCASE");
        params.push(Box::new(filters.statuses[0].clone()));
    } else {
        // Build IN clause: "i.status_name IN (?, ?, ?) COLLATE NOCASE"
        let placeholders = vec!["?"; filters.statuses.len()].join(", ");
        where_clauses.push(format!("i.status_name IN ({placeholders}) COLLATE NOCASE"));
        for s in filters.statuses {
            params.push(Box::new(s.clone()));
        }
    }
}

Modify print_list_issues table — add "Status" column header between "State" and "Assignee":

Current column order: IID, Title, State, Assignee, Labels, Disc, Updated New column order: IID, Title, State, Status, Assignee, Labels, Disc, Updated

Cell::new("Status").add_attribute(Attribute::Bold),

Modify print_list_issues row — add status cell after state cell (before assignee cell):

let status_cell = match &issue.status_name {
    Some(status) => colored_cell_hex(status, issue.status_color.as_deref()),
    None => Cell::new(""),
};

New helper function for hex → comfy_table::Color:

fn colored_cell_hex(content: impl std::fmt::Display, hex: Option<&str>) -> Cell {
    let Some(hex) = hex else {
        return Cell::new(content);
    };
    if !console::colors_enabled() {
        return Cell::new(content);
    }
    let hex = hex.trim_start_matches('#');
    // NOTE: clippy::collapsible_if — must combine conditions
    if hex.len() == 6
        && let (Ok(r), Ok(g), Ok(b)) = (
            u8::from_str_radix(&hex[0..2], 16),
            u8::from_str_radix(&hex[2..4], 16),
            u8::from_str_radix(&hex[4..6], 16),
        )
    {
        return Cell::new(content).fg(Color::Rgb { r, g, b });
    }
    Cell::new(content)
}

File 9: src/cli/mod.rs (MODIFY)

In IssuesArgs struct, add --status flag (after --milestone or similar filter flags):

/// Filter by work item status name (e.g., "In progress"). Repeatable for OR semantics.
#[arg(long, help_heading = "Filters")]
pub status: Vec<String>,

File 10: src/main.rs (MODIFY)

In handle_issues function (~line 695), add statuses to ListFilters construction:

let filters = ListFilters {
    // ... existing fields ...
    statuses: &args.status,
};

In legacy List command handler (~line 2421), also add statuses: &[] to ListFilters:

let filters = ListFilters {
    // ... existing fields ...
    statuses: &[],  // legacy command has no --status flag
};

File 11: src/cli/autocorrect.rs (MODIFY)

In COMMAND_FLAGS array (~line 52), add "--status" to the "issues" entry:

(
    "issues",
    &[
        // ... existing flags ...
        "--has-due",
        "--no-has-due",
        "--status",       // <-- ADD THIS
        "--sort",
        // ...
    ],
),

The registry_covers_command_flags test validates all clap flags are registered here — it will fail if --status is missing.

File 12: src/cli/commands/ingest.rs (MODIFY)

In the ProgressEvent match within the progress callback, add new arms:

ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => {
    // handle progress display
}
ProgressEvent::StatusEnrichmentSkipped => {
    // handle skipped display (config toggle off)
}

The existing match is exhaustive — adding new variants to the enum without adding these arms will cause a compile error.

File 13: src/gitlab/client.rs (MODIFY)

Add GraphqlClient factory — keeps token encapsulated (no raw accessor):

/// Create a GraphQL client using the same base URL and token as this REST client.
/// The token is not exposed — only a ready-to-use GraphQL client is returned.
pub fn graphql_client(&self) -> crate::gitlab::graphql::GraphqlClient {
    crate::gitlab::graphql::GraphqlClient::new(&self.base_url, &self.token)
}

File 14: migrations/021_work_item_status.sql (NEW)

ALTER TABLE issues ADD COLUMN status_name TEXT;
ALTER TABLE issues ADD COLUMN status_category TEXT;
ALTER TABLE issues ADD COLUMN status_color TEXT;
ALTER TABLE issues ADD COLUMN status_icon_name TEXT;
ALTER TABLE issues ADD COLUMN status_synced_at INTEGER;

CREATE INDEX IF NOT EXISTS idx_issues_project_status_name
  ON issues(project_id, status_name);

Migration Numbering

Current state: migrations 001-020 exist on disk and in MIGRATIONS array. This feature uses migration 021.

Files Changed (Summary)

File Change Lines (est)
migrations/021_work_item_status.sql NEW — 5 ALTER TABLE ADD COLUMN + 1 index 8
src/gitlab/graphql.rs NEW — GraphQL client (with GraphqlQueryResult struct, partial-error metadata, HTTP-date Retry-After) + status fetcher + FetchStatusResult (HashSet, partial-error counters) + UnsupportedReason enum + adaptive page sizing + pagination guard + ansi256_from_rgb + __typename matching ~380
src/gitlab/mod.rs Add pub mod graphql; + re-exports +3
src/gitlab/types.rs Add WorkItemStatus struct (no enum — category is Option<String>) +15
src/gitlab/client.rs Add pub fn graphql_client() factory +5
src/core/db.rs Add migration 021 to MIGRATIONS array +5
src/core/config.rs Add fetch_work_item_status to SyncConfig + Default +4
src/ingestion/orchestrator.rs Enrichment step (non-fatal path lookup) + enrich_issue_statuses_txn() (with now_ms param, returns enriched+cleared) + coverage telemetry (seen, without_widget, partial_error_count, first_partial_error) + status_enrichment_error + status_enrichment_mode + status_unsupported_reason on result + progress fields +125
src/cli/commands/show.rs 5 fields on IssueRow/IssueDetail/IssueDetailJson, display, hex color helper +45
src/cli/commands/list.rs 5 fields on row types, SQL, column, repeatable --status filter with IN clause, hex color helper +50
src/cli/commands/ingest.rs Add StatusEnrichmentComplete + StatusEnrichmentSkipped match arms to progress callback +6
src/cli/mod.rs --status flag on IssuesArgs (Vec<String> for repeatable) +3
src/cli/autocorrect.rs Add "--status" to COMMAND_FLAGS issues entry +1
src/main.rs Wire statuses into both ListFilters constructions +2

Total: ~650 lines new/modified across 14 files (2 new, 12 modified)

TDD Plan

RED Phase — Tests to Write First

  1. test_graphql_query_success — mock HTTP server returns {"data":{"foo":"bar"}}Ok(GraphqlQueryResult { data: json!({"foo":"bar"}), had_partial_errors: false, first_partial_error: None })
  2. test_graphql_query_with_errors_no_data — mock returns {"errors":[{"message":"bad"}]} (no data field) → Err(LoreError::Other("GraphQL error: bad"))
  3. test_graphql_auth_uses_bearer — mock asserts request has Authorization: Bearer tok123 header
  4. test_graphql_401_maps_to_auth_failed — mock returns 401 → Err(LoreError::GitLabAuthFailed)
  5. test_graphql_403_maps_to_auth_failed — mock returns 403 → Err(LoreError::GitLabAuthFailed)
  6. test_graphql_404_maps_to_not_found — mock returns 404 → Err(LoreError::GitLabNotFound)
  7. test_work_item_status_deserialize — parse {"name":"In progress","category":"IN_PROGRESS","color":"#1f75cb","iconName":"status-in-progress"} → category is Some("IN_PROGRESS")
  8. test_work_item_status_optional_fields — parse {"name":"To do"} → category/color/icon_name are None
  9. test_work_item_status_unknown_category — parse {"name":"Custom","category":"SOME_FUTURE_VALUE"} → category is Some("SOME_FUTURE_VALUE") (no deserialization error)
  10. test_work_item_status_null_category — parse {"name":"In progress","category":null} → category is None
  11. test_fetch_statuses_pagination — mock returns 2 pages → all statuses in map, all_fetched_iids includes all IIDs
  12. test_fetch_statuses_no_status_widget — mock returns widgets without StatusWidget → empty statuses map, all_fetched_iids still populated
  13. test_fetch_statuses_404_graceful — mock returns 404 → Ok(FetchStatusResult) with empty maps, unsupported_reason == Some(GraphqlEndpointMissing)
  14. test_fetch_statuses_403_graceful — mock returns 403 → Ok(FetchStatusResult) with empty maps, unsupported_reason == Some(AuthForbidden)
  15. test_migration_021_adds_columns — in-memory DB: PRAGMA table_info(issues) includes 5 new columns (including status_synced_at)
  16. test_migration_021_adds_index — in-memory DB: PRAGMA index_list(issues) includes idx_issues_project_status_name
  17. test_enrich_issue_statuses_txn — insert issue, call enrich_issue_statuses_txn(), verify 4 status columns populated + status_synced_at is set to now_ms
  18. test_enrich_skips_unknown_iids — status map has IID not in DB → no error, returns 0
  19. test_enrich_clears_removed_status — issue previously had status, now in all_fetched_iids but not in statuses → status fields NULLed out, status_synced_at updated to now_ms (not NULLed — confirms we checked this row)
  20. test_enrich_transaction_rolls_back_on_failure — simulate failure mid-enrichment → no partial updates, prior status values intact
  21. test_list_filter_by_status — insert 2 issues with different statuses, filter returns correct one
  22. test_list_filter_by_status_case_insensitive--status "in progress" matches "In progress" via COLLATE NOCASE
  23. test_config_fetch_work_item_status_default_trueSyncConfig::default().fetch_work_item_status == true
  24. test_config_deserialize_without_key — JSON without fetchWorkItemStatus → defaults to true
  25. test_ansi256_from_rgb — known conversions: (0,0,0)→16, (255,255,255)→231, (31,117,203)→~68
  26. test_enrich_idempotent_across_two_runs — run enrich_issue_statuses_txn() twice with same data → columns unchanged, enriched count same both times
  27. test_typename_matching_ignores_non_status_widgets — widgets array with __typename: "WorkItemWidgetDescription" → not parsed as status, no error
  28. test_retry_after_http_date_format — mock returns 429 with Retry-After: Wed, 11 Feb 2026 01:00:00 GMT → parses to delta seconds from now
  29. test_retry_after_invalid_falls_back_to_60 — mock returns 429 with Retry-After: garbage → falls back to 60
  30. test_enrich_sets_synced_at_on_clear — issue with status cleared → status_synced_at is now_ms (not NULL)
  31. test_enrichment_error_captured_in_result — simulate GraphQL error → IngestProjectResult.status_enrichment_error contains error message string
  32. test_robot_sync_includes_status_enrichment — robot sync output JSON includes per-project status_enrichment object with mode, reason, seen, enriched, cleared, without_widget, partial_errors, first_partial_error, error fields
  33. test_graphql_partial_data_with_errors_returns_data — mock returns {"data":{"foo":"bar"},"errors":[{"message":"partial failure"}]}Ok(GraphqlQueryResult { data: json!({"foo":"bar"}), had_partial_errors: true, first_partial_error: Some("partial failure") })
  34. test_fetch_statuses_cursor_stall_aborts — mock returns hasNextPage: true with same endCursor on consecutive pages → pagination aborts with warning, returns partial result collected so far
  35. test_fetch_statuses_unsupported_reason_none_on_success — successful fetch → unsupported_reason is None
  36. test_fetch_statuses_complexity_error_reduces_page_size — mock returns complexity error on first page with first=100 → retries with first=50, succeeds → result contains all statuses from the successful page
  37. test_fetch_statuses_timeout_error_reduces_page_size — mock returns timeout error on first page with first=100 → retries with smaller page size, succeeds
  38. test_fetch_statuses_smallest_page_still_fails — mock returns complexity error at all page sizes (100→50→25→10) → returns Err
  39. test_fetch_statuses_page_size_resets_after_success — first page succeeds at 100, second page fails at 100 → falls back to 50 for page 2, page 3 retries at 100 (reset after success)
  40. test_list_filter_by_multiple_statuses--status "In progress" --status "To do" → returns issues matching either status
  41. test_project_path_missing_skips_enrichment — project with no path_with_namespace in DB → enrichment skipped, status_enrichment_error == "project_path_missing", sync continues
  42. test_fetch_statuses_partial_errors_tracked — mock returns partial-data response on one page → partial_error_count == 1, first_partial_error populated

GREEN Phase — Build Order

Batch 1: Types + Migration (Files 3, 4, 14 → Tests 7-10, 15-16, 23-24)

  • Create migrations/021_work_item_status.sql (5 columns + index)
  • Add WorkItemStatus struct to types.rs (no enum — category is Option<String>)
  • Register migration 021 in db.rs
  • Add fetch_work_item_status to config.rs
  • Run: cargo test test_work_item_status test_migration_021 test_config

Batch 2: GraphQL Client (Files 1, 2, 13 → Tests 1-6, 11-14, 25, 27-29, 33-39, 42)

  • Create src/gitlab/graphql.rs with full client (GraphqlQueryResult struct, partial-error metadata, HTTP-date Retry-After) + fetcher + FetchStatusResult (with partial_error_count/first_partial_error) + UnsupportedReason + adaptive page sizing + pagination guard + ansi256_from_rgb
  • Add pub mod graphql; to gitlab/mod.rs
  • Add pub fn graphql_client() factory to client.rs
  • Add httpdate crate to Cargo.toml dependencies (for Retry-After HTTP-date parsing)
  • Run: cargo test graphql

Batch 3: Orchestrator (Files 6, 12 → Tests 17-20, 26, 30-32, 41)

  • Add enrichment phase (with non-fatal path lookup) + enrich_issue_statuses_txn() (with now_ms param) to orchestrator
  • Add coverage telemetry fields (statuses_seen, statuses_without_widget, partial_error_count, first_partial_error) to IngestProjectResult
  • Add status_enrichment_error: Option<String>, status_enrichment_mode: String, status_unsupported_reason: Option<String> to IngestProjectResult
  • Add StatusEnrichmentComplete + StatusEnrichmentSkipped to ProgressEvent enum
  • Add match arms in ingest.rs progress callback
  • Run: cargo test orchestrator

Batch 4: CLI Display + Filter (Files 7-11 → Tests 21-22, 40)

  • Add status fields (including status_synced_at) to show.rs structs, SQL, display
  • Add status fields to list.rs structs, SQL, column, repeatable --status filter (with IN clause + COLLATE NOCASE)
  • Add --status flag to cli/mod.rs as Vec<String> (repeatable)
  • Add "--status" to autocorrect registry
  • Wire statuses in both ListFilters constructions in main.rs
  • Wire status_enrichment (with mode/reason/seen/without_widget/partial_errors/first_partial_error fields) into robot sync output envelope
  • Run: cargo test (full suite)

Batch 5: Quality Gates (AC-11)

  • cargo check --all-targets
  • cargo clippy --all-targets -- -D warnings
  • cargo fmt --check
  • cargo test (all green)

Key gotcha per batch:

  • Batch 1: Migration has 5 columns now (including status_synced_at INTEGER), not 4 — test assertion must check for all 5
  • Batch 2: Use r##"..."## in tests containing "#1f75cb" hex colors; FetchStatusResult is not Clone — tests must check fields individually; __typename test mock data must include the field in widget JSON objects; httpdate crate needed for Retry-After HTTP-date parsing — add to Cargo.toml; pagination guard test needs mock that returns same endCursor twice; partial-data test needs mock that returns both data and errors fields; adaptive page size tests need mock that inspects $first variable in request body to return different responses per page size; GraphqlQueryResult (not raw Value) is now the return type — test assertions must destructure it
  • Batch 3: Progress callback in ingest.rs must be updated in same batch as enum change (2 new arms: StatusEnrichmentComplete + StatusEnrichmentSkipped); unchecked_transaction() needed because conn is &Connection not &mut Connection; enrich_issue_statuses_txn takes 5 params now (added now_ms: i64) and returns (usize, usize) tuple — destructure at call site; status_enrichment_error must be populated on the Err branch; status_enrichment_mode and status_unsupported_reason must be set in all code paths; project path lookup uses .optional()? — requires use rusqlite::OptionalExtension; import
  • Batch 4: Autocorrect registry must be updated in same batch as clap flag addition; COLLATE NOCASE applies to the comparison, not the column definition; status_synced_at is Option<i64> in Rust structs (maps to nullable INTEGER in SQLite); --status is Vec<String> not Option<String>ListFilters.statuses is &[String]; multi-value filter uses dynamic IN clause with placeholder generation

Edge Cases

  • GitLab Free tier: Status widget absent → columns stay NULL, no error
  • GitLab < 17.11: No status widget at all → same as Free tier
  • GitLab 17.11-18.0: Status widget present but category field missing → store name only
  • Custom statuses (18.5+): Names and categories won't match system defaults → stored as raw strings, no deserialization failures
  • Token with read_api scope: Should work for GraphQL queries (read-only)
  • Self-hosted with GraphQL disabled: 404 on endpoint → graceful skip, unsupported_reason: GraphqlEndpointMissing
  • HTTP 403 (Forbidden): Treated as auth failure, graceful skip with warning log, unsupported_reason: AuthForbidden
  • Rate limiting: Respect Retry-After header if present
  • Large projects (10k+ issues): Pagination handles this — adaptive page sizing starts at 100, falls back to 50→25→10 on complexity/timeout errors
  • Self-hosted with strict complexity limits: Adaptive page sizing automatically reduces first parameter to avoid complexity budget exhaustion
  • Status-state sync: Closed via REST → status might be "Done" — expected and correct
  • Concurrent syncs: Status enrichment is idempotent — UPDATE is safe to run multiple times
  • Status removed in GitLab: Issue was fetched but has no status widget → status fields NULLed out (staleness clearing)
  • Enrichment failure mid-project: Transaction rolls back — no partial updates, prior status values intact. Error message captured in IngestProjectResult.status_enrichment_error and surfaced in robot sync output.
  • Project path missing from DB: Enrichment skipped with structured error (project_path_missing), does NOT fail the overall project pipeline — sync continues to discussion phase
  • Retry-After with HTTP-date format: Parsed to delta-seconds from now via httpdate crate. Invalid format falls back to 60s.
  • NULL hex color: style_with_hex and colored_cell_hex fall back to unstyled text
  • Invalid hex color: Malformed color string → fall back to unstyled text
  • Empty project: fetch_issue_statuses returns empty result → no UPDATEs, no error
  • Case-insensitive filter: --status "in progress" matches "In progress" via COLLATE NOCASE
  • Multiple status filter: --status "To do" --status "In progress" → OR semantics within status, AND with other filters
  • GraphQL partial-data response: Response has both data and errors → data is used, partial_error_count incremented, first_partial_error captured — agents can detect incomplete data via robot sync output
  • GraphQL cursor stall: hasNextPage=true but endCursor is None or unchanged → pagination aborted with warning, partial result returned (prevents infinite loops from GraphQL cursor bugs)

Decisions

  1. Store color + icon_name — YES. Used for colored CLI output in human view.
  2. Run on every sync — always enrich, not just --full. This is vital data.
  3. Include --status filter — YES, in v1. lore list issues --status "In progress"
  4. Factory over raw token — YES. client.graphql_client() keeps token encapsulated.
  5. Transactional enrichment — YES. All-or-nothing per project prevents partial/stale state.
  6. Case-insensitive --status filter — YES. Better UX, especially for custom status names. ASCII COLLATE NOCASE is sufficient — all system statuses are ASCII, and custom names are overwhelmingly ASCII too.
  7. Flat fields over nested JSON object — YES. Consistent with existing labels, milestone_title pattern. Works with --fields selection. Nesting would break --fields syntax and require special dot-path resolution.
  8. No retry/backoff in v1 — DEFER. REST client doesn't have retry either. This is a cross-cutting concern that should be built once as a shared transport layer (transport.rs) for both REST and GraphQL, not bolted onto GraphQL alone. Adding retry only for GraphQL creates split behavior and maintenance burden. Note: adaptive page sizing (Decision 18) handles the specific case of complexity/timeout errors without needing general retry.
  9. No capability probe/cache in v1 — DEFER. Graceful degradation (empty map on 404/403) is sufficient. The warning is once per project per sync — acceptable noise. Cache table adds migration complexity and a new DB schema concept for marginal benefit.
  10. status_synced_at column — YES. Lightweight enrichment freshness timestamp (ms epoch UTC). Enables consumers to detect stale data and supports future delta-driven enrichment. Written on both status-set and status-clear operations to distinguish "never enriched" (NULL) from "enriched but no status" (timestamp set, status NULL).
  11. Enrichment error in robot output — YES. status_enrichment_error: Option<String> on IngestProjectResult + per-project status_enrichment object in robot sync JSON. Agents need machine-readable signal when enrichment fails — silent warnings in logs are invisible to automation.
  12. No status_name_fold shadow column — REJECT. COLLATE NOCASE handles ASCII case-folding which covers all system statuses. A fold column doubles write cost for negligible benefit.
  13. No --status-category / --no-status in v1 — DEFER. Keep v1 focused on core --status filter. These are easy additions once usage patterns emerge.
  14. No strict mode — DEFER. A status_enrichment_strict config toggle adds config bloat for an edge case. The status_enrichment_error field gives agents the signal they need to implement their own strict behavior.
  15. Explicit outcome mode in robot output — YES. status_enrichment.mode distinguishes "fetched" / "unsupported" / "skipped" with optional reason field. Resolves the ambiguity where agents couldn't tell "project has no statuses" from "feature unavailable" — both previously looked like an empty success. Cheap to implement since FetchStatusResult already has unsupported_reason.
  16. GraphQL partial-data tolerance with end-to-end metadata — YES. When GraphQL returns both data and errors, use the data and propagate error metadata (had_partial_errors/first_partial_error) through GraphqlQueryResultFetchStatusResultIngestProjectResult → robot sync output. Agents get machine-readable signal that status data may be incomplete, rather than silent log-only warnings.
  17. Pagination guard against cursor stall — YES. If hasNextPage=true but endCursor is None or unchanged, abort the loop and return partial results. This is a zero-cost safety valve against infinite loops from GraphQL cursor bugs. The alternative (trusting the server unconditionally) risks hanging the sync indefinitely.
  18. Adaptive page sizing — YES. Start with first=100, fall back to 50→25→10 on GraphQL complexity/timeout errors. This is NOT general retry/backoff (Decision 8) — it specifically handles self-hosted GitLab instances with stricter complexity/time limits by reducing the page size that caused the problem. After a successful page, resets to 100 (the complexity issue may be cursor-position-specific). Zero operational cost — adds ~15 lines and 4 tests.
  19. Repeatable --status filter — YES. --status "To do" --status "In progress" with OR semantics. Practical for "show me active work" queries. Clap supports Vec<String> natively, and dynamic IN clause generation is straightforward. Single-value case uses = for simplicity.
  20. Non-fatal project path lookup — YES. Uses .optional()? instead of ? for the path_with_namespace DB query. If the project path is missing, enrichment is skipped with status_enrichment_error: "project_path_missing" and the sync continues to discussion phase. Enrichment is optional — it should never take down the entire project pipeline.
  21. Coverage telemetry counters — YES. seen, enriched, cleared, without_widget in IngestProjectResult and robot sync output. enriched/cleared alone cannot distinguish "project has 0 issues" from "project has 500 issues with 0 statuses." Coverage counters cost one len() call and one subtraction — negligible.

Future Enhancements (Not in Scope)

These ideas surfaced during planning (iterations 3-6, cross-model reviews) but are out of scope for this implementation. File as separate beads if/when needed.

Filters & Querying

  • --status-category filter: Filter by category (in_progress, done, etc.) for automation — more stable than name-based filtering for custom lifecycles. Consider adding a COLLATE NOCASE index on (project_id, status_category) when this lands.
  • --no-status filter: Return only issues where status_name IS NULL — useful for migration visibility and data quality audits.
  • --stale-status-days N filter: Filter issues where status hasn't changed in N days — requires status_changed_at column (see below).

Enrichment Optimization

  • Delta-driven enrichment: Skip GraphQL fetch when issue ingestion reports zero changes for a project (optimization for large repos). Use status_synced_at (already in v1 schema) to determine last enrichment time. Add status_full_reconcile_hours config (default 24) to force periodic full sweep as safety net.
  • --refresh-status override flag: Force enrichment even with zero issue deltas.
  • Capability probe/cache: Detect status-widget support per project, cache with TTL in a project_capabilities table to avoid repeated pointless GraphQL calls on Free tier. Re-probe on TTL expiry (default 24h). The status_synced_at column provides a foundation — if a project's issues all have NULL status_synced_at, it's likely unsupported.

Schema Extensions

  • status_changed_at column: Track when status value last changed (ms epoch UTC) — enables "how long has this been in progress?" queries, --stale-status-days N filter, and status_age_days field in robot output. Requires change-detection logic during enrichment (compare old vs new before UPDATE). Note: status_synced_at (in v1) records when enrichment last ran on the row, not when the status value changed — these serve different purposes.
  • status_id column: Store GitLab's global node ID (e.g., gid://gitlab/WorkItems::Statuses::Status/1) for rename-resistant identity. Useful if automations need to track a specific status across renames. Deferred because the global node ID is opaque, instance-specific, and not useful for cross-instance queries or human consumption — status_name is the primary user-facing identifier.

Infrastructure

  • Shared transport with retry/backoff: Retry 429/502/503/504/network errors with exponential backoff + jitter (max 3 attempts) — should be a cross-cutting concern via shared transport.rs layer for both REST and GraphQL clients. Must respect cancellation signal between retry sleeps.
  • Set-based bulk enrichment: For very large projects (10k+ issues), replace row-by-row UPDATE loop with temp-table + set-based SQL for faster write-lock release. Profile first to determine if this is actually needed — SQLite prepare_cached statements may be sufficient.

Operational

  • Strict mode: status_enrichment_strict: bool config toggle that makes enrichment failure fail the sync. Agents can already implement equivalent behavior by checking status_enrichment_error in the robot sync output (added in v1).
  • partial_failures array in robot sync envelope: Aggregate all enrichment errors into a top-level array for easy agent consumption. Currently per-project status_enrichment.error provides the data — this would be a convenience aggregation.
  • Status/state consistency checks: Detect status-state mismatches (e.g., DONE category with state=open, or IN_PROGRESS category with state=closed) and log warnings. Useful for data integrity diagnostics, but status-state sync is GitLab's responsibility and temporary mismatches during sync windows are expected — not appropriate for v1 enrichment logic.

Cancellation

  • Pass cancellation signal into fetcher: Thread the existing CancellationSignal into fetch_issue_statuses() to check between page requests. Currently the orchestrator checks cancellation before enrichment starts, and individual page fetches complete in <1s, so the worst-case delay before honoring cancellation is ~1s per page (negligible). Worth adding if enrichment grows to include multiple fetch operations per project.

Battle-Test Results (Iteration 2, pre-revision)

This plan was fully implemented in a trial run. All 435+ tests passed, clippy clean, fmt clean. The code was then reverted so the plan could be reviewed before real implementation. The corrections below are already reflected in the code snippets above.

Note (Iteration 3): The plan was revised based on external review feedback. Key changes: transactional enrichment with staleness clearing, graphql_client() factory pattern, 403 handling, compound index, case-insensitive filter, 5 additional tests, and dropped WorkItemStatusCategory enum in favor of Option<String> (custom statuses on 18.5+ can have arbitrary category values). The trial implementation predates these revisions — the next trial should validate the new behavior.

Note (Iteration 4): Revised based on cross-model review (ChatGPT). Key changes: __typename-based deterministic widget matching (replaces heuristic try-deserialize), all_fetched_iids changed from Vec<i64> to HashSet<i64> for O(1) staleness lookups, statuses_cleared counter added for enrichment observability, StatusEnrichmentSkipped progress event for config-off case, idempotency test added, expanded future enhancements with deferred ideas (status_changed_at, capability cache, shared transport, set-based bulk). Rejected scope-expanding suggestions: shared transport layer, capability cache table, delta-driven enrichment, NOCASE index changes, --status-category/--no-status filters, status_changed_at column — all deferred to future enhancements with improved descriptions.

Note (Iteration 5): Revised based on second cross-model review (ChatGPT feedback-5). Accepted: (1) status_synced_at INTEGER column — lightweight freshness timestamp for enrichment observability and future delta-driven optimization, written on both set and clear operations; (2) status_enrichment_error: Option<String> on IngestProjectResult — captures error message for machine-readable failure reporting; (3) AC-10 for robot sync envelope with per-project status_enrichment object; (4) Retry-After HTTP-date format support via httpdate crate; (5) 5 new tests (HTTP-date, clear synced_at, error capture, sync envelope). Rejected with rationale: retry/backoff (cross-cutting, not GraphQL-only), capability cache (migration complexity for marginal benefit), delta-driven enrichment (profile first), set-based SQL (premature optimization), strict mode (agents can use error field), Unicode case-folding column (ASCII COLLATE NOCASE sufficient), nested JSON status object (breaks --fields syntax), --status-category/--no-status filters (keep v1 focused). Expanded Decisions section from 9 to 14 with explicit rationale for each rejection. Reorganized Future Enhancements into categorized subsections.

Note (Iteration 6): Revised based on third cross-model review (ChatGPT feedback-6). Accepted: (1) GraphQL partial-data tolerance — when response has both data and errors, return data and log warning instead of hard-failing (per GraphQL spec, partial results are valid); (2) Pagination guard — abort loop if hasNextPage=true but endCursor is None or unchanged (prevents infinite loops from GraphQL cursor bugs); (3) UnsupportedReason enum + unsupported_reason field on FetchStatusResult — distinguishes "no statuses found" from "feature unavailable" for robot sync output clarity; (4) Enhanced AC-10 with mode/reason fields for explicit enrichment outcome reporting. Added 3 new tests (partial-data, cursor stall, unsupported reason on success). Updated Decisions 15-17. Added status_id to Future Enhancements. Rejected with rationale: see Rejected Recommendations section below.

Note (Iteration 7): Revised based on fourth cross-model review (ChatGPT feedback-7). Accepted: (1) Partial-error metadata end-to-end — GraphqlQueryResult struct replaces raw Value return, propagating had_partial_errors/first_partial_error through FetchStatusResultIngestProjectResult → robot sync output (agents get machine-readable incomplete-data signal); (2) Adaptive page sizing — first=100→50→25→10 fallback on complexity/timeout errors, handles self-hosted instances with stricter limits without needing general retry/backoff; (3) Non-fatal project path lookup — .optional()? prevents enrichment from crashing the project pipeline; (4) Repeatable --status filter — Vec<String> with OR semantics via IN clause; (5) Coverage telemetry — seen, without_widget, partial_error_count, first_partial_error counters on IngestProjectResult for full observability. Added 7 new tests (adaptive page size ×4, multi-status, path missing, partial errors tracked). Updated Decisions 16, 18-21. Rejected with rationale: centralized color parsing module (see Rejected Recommendations).

Corrections Made During Trial

Issue Root Cause Fix Applied
LoreError::GitLabRateLimited { retry_after } uses u64, not Option<u64> Plan assumed Option<u64> for retry_after Added .unwrap_or(60) after parsing Retry-After header
Raw string r#"..."# breaks on "#1f75cb" in test JSON The "# sequence matches the raw string terminator Use r##"..."## double-hash delimiters in test code
Clippy: collapsible if statements (5 instances) if x { if y { ... } }if x && y { ... } Used if ... && let ... chain syntax (Rust 2024 edition)
Clippy: items after test module ansi256_from_rgb() was placed after #[cfg(test)] Moved function before the test module
Clippy: manual range contains assert!(blue >= 16 && blue <= 231) Changed to assert!((16..=231).contains(&blue))
Missing token accessor on GitLabClient Orchestrator needs token to create GraphqlClient Added pub fn token(&self) -> &str to client.rs
Missing path_with_namespace in orchestrator Only had project_id, needed project path for GraphQL Added DB query to look up path from projects table
Exhaustive match on ProgressEvent Adding enum variant breaks ingest.rs match Added StatusEnrichmentComplete arm to progress callback
Legacy List command missing status field Two ListFilters constructions in main.rs Added status: None to legacy command's ListFilters
Autocorrect registry test failure All clap flags must be registered in COMMAND_FLAGS Added "--status" to the "issues" entry in autocorrect.rs

Files Not in Original Plan (Discovered During Trial)

  • migrations/021_work_item_status.sql — separate file, not inline in db.rs (uses include_str!)
  • src/gitlab/client.rs — needs graphql_client() factory for GraphQL client creation (revised from token() accessor)
  • src/cli/commands/ingest.rs — progress callback match must be updated
  • src/cli/autocorrect.rs — flag registry test requires all new flags registered
  • src/main.rs — legacy List command path also constructs ListFilters

Test Results from Trial (all green, pre-revision)

  • 435 lib tests + 136 integration/benchmark tests = 571 total
  • 11 new GraphQL tests (client, status fetcher, auth, pagination, graceful degradation)
  • 5 new orchestrator tests (migration, enrich, skip unknown IIDs, progress event, result default)
  • 3 new config tests (default true, deserialize false, omitted defaults true)
  • All existing tests remained green

Post-revision additions (5 new tests, iteration 3): test_graphql_403_maps_to_auth_failed, test_migration_021_adds_index, test_enrich_clears_removed_status, test_enrich_transaction_rolls_back_on_failure, test_list_filter_by_status_case_insensitive

Post-revision additions (2 new tests, iteration 4): test_enrich_idempotent_across_two_runs, test_typename_matching_ignores_non_status_widgets

Post-revision additions (5 new tests, iteration 5): test_retry_after_http_date_format, test_retry_after_invalid_falls_back_to_60, test_enrich_sets_synced_at_on_clear, test_enrichment_error_captured_in_result, test_robot_sync_includes_status_enrichment

Post-revision additions (3 new tests, iteration 6): test_graphql_partial_data_with_errors_returns_data, test_fetch_statuses_cursor_stall_aborts, test_fetch_statuses_unsupported_reason_none_on_success

Post-revision additions (7 new tests, iteration 7): test_fetch_statuses_complexity_error_reduces_page_size, test_fetch_statuses_timeout_error_reduces_page_size, test_fetch_statuses_smallest_page_still_fails, test_fetch_statuses_page_size_resets_after_success, test_list_filter_by_multiple_statuses, test_project_path_missing_skips_enrichment, test_fetch_statuses_partial_errors_tracked


Rejected Recommendations

Cumulative log of recommendations considered and rejected across iterations. Prevents future reviewers from re-proposing the same changes.

Iteration 4 (ChatGPT feedback-4)

  • Shared transport layer with retry/backoff — rejected because this is a cross-cutting concern that should be built once for both REST and GraphQL, not bolted onto GraphQL alone. Adding retry only for GraphQL creates split behavior. Deferred to Future Enhancements.
  • Capability cache table — rejected because migration complexity and new DB schema concept for marginal benefit. Graceful degradation (empty map on 404/403) is sufficient.
  • Delta-driven enrichment — rejected because needs profiling first to determine if full re-fetch is actually a bottleneck. Premature optimization.
  • NOCASE index changes — rejected because COLLATE NOCASE on the comparison is sufficient; adding it to the index definition provides no benefit for SQLite's query planner in this case.
  • --status-category / --no-status filters in v1 — rejected because keeping v1 focused. Easy additions once usage patterns emerge.
  • status_changed_at column in v1 — rejected because requires change-detection logic during enrichment (compare old vs new) which adds complexity. status_synced_at serves different purpose and is sufficient for v1.

Iteration 5 (ChatGPT feedback-5)

  • Retry/backoff (again) — rejected for same reason as iteration 4. Cross-cutting concern.
  • Capability cache (again) — rejected for same reason as iteration 4.
  • Delta-driven enrichment (again) — rejected for same reason as iteration 4.
  • Set-based bulk SQL — rejected because premature optimization. SQLite prepare_cached statements may be sufficient. Profile first.
  • Strict mode config toggle — rejected because adds config bloat for edge case. Agents can implement equivalent behavior by checking status_enrichment_error.
  • Unicode case-folding shadow column (status_name_fold) — rejected because COLLATE NOCASE handles ASCII case-folding which covers all system statuses. Doubles write cost for negligible benefit.
  • Nested JSON status object — rejected because breaks --fields syntax and requires special dot-path resolution. Flat fields are consistent with existing patterns.
  • --status-category / --no-status filters (again) — rejected for same reason as iteration 4.

Iteration 6 (ChatGPT feedback-6)

  • FetchStatusOutcome enum with CancelledPartial variant — rejected because over-engineered for v1. The simpler approach of adding unsupported_reason: Option<UnsupportedReason> to FetchStatusResult provides the same observability signal (distinguishing "no statuses" from "feature unavailable") without introducing a 3-variant enum that forces match arms everywhere. The partial-from-cancellation case is not needed since the orchestrator checks cancellation before starting enrichment and individual page fetches complete in <1s.
  • Pass cancellation signal into fetch_issue_statuses() — rejected because the orchestrator already checks cancellation before enrichment starts, and individual page fetches are <1s. Threading the signal through adds a parameter and complexity for negligible benefit. Deferred to Future Enhancements (Cancellation section) in case enrichment grows to include multiple fetch operations.
  • Persist status_id column (GitLab global node ID) — rejected because GitLab's GraphQL id is a global node ID (e.g., gid://gitlab/WorkItems::Statuses::Status/1) that is opaque, instance-specific, and not useful for cross-instance queries or human consumption — the name is what users see and filter by. Added to Future Enhancements (Schema Extensions) as a deferred option for rename-resistant identity if needed.
  • Extract status enrichment into src/ingestion/enrichment/status.rs module — rejected because the enrichment logic is ~60 lines (one orchestrator block + one helper function). Creating a new module, directory, and mod.rs for this is premature abstraction. The orchestrator is the right home until enrichment grows to justify extraction.
  • Status/state consistency checks (mismatch detection) — rejected because status-state sync is GitLab's responsibility and temporary mismatches during sync windows are expected behavior. Adding mismatch detection to enrichment adds complexity for a diagnostic signal that would generate false positives. Deferred to Future Enhancements (Operational).
  • Performance envelope acceptance criterion (AC-12) — rejected because "10k-issue fixture on CI baseline machine" is unmeasurable without CI infrastructure and test fixtures we don't have. Pagination handles large projects by design (100/page). Testing memory bounds requires test harness complexity far beyond the value for v1.

Iteration 7 (ChatGPT feedback-7)

  • Centralize color parsing into src/cli/commands/color.rs module — rejected because the two color helpers (style_with_hex in show.rs, colored_cell_hex in list.rs) return different types (console::StyledObject vs comfy_table::Cell) for different rendering contexts. The shared hex-parsing logic is 4 lines. Creating a new module + file for this is premature abstraction per project rules ("only abstract when you have 3+ similar uses"). If a third color context emerges, extract then.