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

1628 lines
88 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
plan: true
title: "Work Item Status via GraphQL Enrichment"
status: iterating
iteration: 7
target_iterations: 8
beads_revision: 1
related_plans: []
created: 2026-02-10
updated: 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.
```graphql
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.
```rust
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):**
```rust
pub mod client;
pub mod transformers;
pub mod types;
```
**Add after `pub mod types;`:**
```rust
pub mod graphql;
```
**Add to `pub use types::{...}` list:**
```rust
WorkItemStatus,
```
### File 3: `src/gitlab/types.rs` (MODIFY)
**Add at end of file (after `GitLabMergeRequest` struct, before any `#[cfg(test)]`):**
```rust
/// 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!`:**
```rust
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:**
```rust
("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`:**
```rust
#[serde(rename = "fetchWorkItemStatus", default = "default_true")]
pub fetch_work_item_status: bool,
```
**In `impl Default for SyncConfig`, add after `fetch_mr_file_changes: true`:**
```rust
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:
```rust
// ── 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:**
```rust
/// 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:**
```rust
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:**
```rust
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:**
```rust
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.
```sql
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)?`:**
```rust
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:**
```rust
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:**
```rust
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):**
```rust
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:**
```rust
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:**
```rust
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:**
```rust
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:**
```rust
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):**
```rust
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:**
```rust
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:**
```sql
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:
```rust
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:**
```rust
pub statuses: &'a [String],
```
**Modify `query_issues` WHERE clause builder — add after `has_due_date` block:**
```rust
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
```rust
Cell::new("Status").add_attribute(Attribute::Bold),
```
**Modify `print_list_issues` row — add status cell after state cell (before assignee cell):**
```rust
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`:**
```rust
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):**
```rust
/// 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:**
```rust
let filters = ListFilters {
// ... existing fields ...
statuses: &args.status,
};
```
**In legacy `List` command handler (~line 2421), also add `statuses: &[]` to `ListFilters`:**
```rust
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:**
```rust
(
"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:**
```rust
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):**
```rust
/// 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)
```sql
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_true`** — `SyncConfig::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 `GraphqlQueryResult``FetchStatusResult``IngestProjectResult` → 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 `FetchStatusResult`
> → `IngestProjectResult` → 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.