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`
1628 lines
88 KiB
Markdown
1628 lines
88 KiB
Markdown
---
|
||
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.
|