# Research: Linear Epic Issues - Empty child_issues Field

**Date**: 2025-11-24
**Issue**: epic_issues endpoint returns 0 issues for Linear project
**Context**: User reported Linear project URL `https://linear.app/1m-hyperdev/project/epstein-island-13ddc89e7271/issues` returning no issues when calling `epic_issues("13ddc89e7271")`

## Executive Summary

The `epic_issues` tool in `hierarchy_tools.py` is **correctly implemented** but relies on `adapter.read(epic_id)` to populate the `child_issues` field. The Linear adapter's `read()` method **does populate child_issues** (lines 1525-1532), but there are **two potential gaps**:

1. **Project ID Format**: User provided short ID `13ddc89e7271` extracted from URL, which may not resolve correctly
2. **GraphQL Query Efficiency**: The `_get_project_issues()` method uses filter-based querying instead of directly fetching project.issues field

## Problem Analysis

### Current Implementation Flow

```
epic_issues(epic_id) [hierarchy_tools.py:235-276]
    ↓
adapter.read(epic_id) [adapter.py:1476-1534]
    ↓
get_project(epic_id) [adapter.py:362-426]
    ↓
_get_project_issues(epic_id) [adapter.py:728-770]
    ↓
LIST_ISSUES_QUERY with filter: {"project": {"id": {"eq": project_id}}}
```

### Key Finding: adapter.read() DOES Populate child_issues

**File**: `src/mcp_ticketer/adapters/linear/adapter.py`
**Lines**: 1521-1534

```python
# If not found as issue, try reading as project
try:
    project_data = await self.get_project(ticket_id)
    if project_data:
        # Fetch project's issues to populate child_issues field
        issues = await self._get_project_issues(ticket_id)

        # Map to Epic
        epic = map_linear_project_to_epic(project_data)

        # Populate child_issues with issue IDs
        epic.child_issues = [issue.id for issue in issues]

        return epic
```

**Status**: ✅ This is working correctly. The adapter.read() method **does** fetch issues and populate child_issues.

## Root Cause Analysis

### Issue 1: Project ID Resolution

**Problem**: User provided `13ddc89e7271` (12 hex characters - Linear short ID format)

**How Linear adapter handles this**:
- `_resolve_project_id()` (lines 477-636) attempts direct query with `get_project()`
- `get_project()` (lines 362-426) uses Linear's `project(id:)` GraphQL query
- Linear API accepts UUID, slugId, or short ID formats

**Potential Gap**:
```python
# File: adapter.py, lines 518-541
if len(project_identifier) == 12 and all(
    c in "0123456789abcdefABCDEF" for c in project_identifier
):
    should_try_direct_query = True
```

The code **does** recognize 12-char hex IDs and tries direct query. However, if the direct query fails, it falls back to listing all projects and searching by name/slug.

**Verification Needed**:
- Does Linear's `project(id: "13ddc89e7271")` query work with short IDs?
- Or does it require full UUID or slug-id format like `epstein-island-13ddc89e7271`?

### Issue 2: GraphQL Query Pattern

**Current Implementation** (`_get_project_issues`, lines 728-770):
```python
# Build filter for issues belonging to this project
issue_filter = build_issue_filter(project_id=project_id)

variables = {
    "filter": issue_filter,
    "first": min(limit, 250),
}

result = await self.client.execute_query(LIST_ISSUES_QUERY, variables)
```

**GraphQL Query Used** (`LIST_ISSUES_QUERY`, queries.py:271-289):
```graphql
query ListIssues($filter: IssueFilter, $first: Int!) {
    issues(
        filter: $filter
        first: $first
        orderBy: updatedAt
    ) {
        nodes {
            ...IssueCompactFields
        }
    }
}
```

**Filter Built** (`build_issue_filter`, types.py:267-268):
```python
if project_id:
    issue_filter["project"] = {"id": {"eq": project_id}}
```

**Potential Issue**:
- This queries **global issues** filtered by project ID
- May not work if project_id format is invalid (UUID vs short ID vs slug-id)
- Linear GraphQL API might require specific project ID format for filters

## Alternative: Direct Project.issues Query

Linear's GraphQL API supports fetching issues **directly** from a project:

```graphql
query GetProject($id: String!) {
    project(id: $id) {
        id
        name
        issues {
            nodes {
                id
                identifier
                title
                # ... other fields
            }
        }
    }
}
```

**Advantages**:
1. Single query instead of two (get_project + list_issues)
2. Guaranteed to use same ID format that `get_project()` accepted
3. More efficient - no filter matching needed
4. Directly maps to project's issues collection

**Disadvantages**:
1. Requires modifying `get_project()` query to include issues
2. May fetch unnecessary data if not always needed
3. Need pagination support for projects with many issues

## Recommended Fix Strategy

### Option A: Enhance get_project() with Optional Issue Loading

**Modify**: `get_project()` method (lines 362-426)

**Change**:
```python
async def get_project(
    self,
    project_id: str,
    include_issues: bool = False,
    issues_limit: int = 100
) -> dict[str, Any] | None:
    """Get a Linear project by ID using direct query.

    Args:
        project_id: Project UUID, slugId, or short ID
        include_issues: Whether to include project issues (default False)
        issues_limit: Maximum issues to fetch if include_issues=True
    """
    query = """
        query GetProject($id: String!, $includeIssues: Boolean!, $issuesLimit: Int!) {
            project(id: $id) {
                id
                name
                description
                # ... existing fields ...
                issues(first: $issuesLimit) @include(if: $includeIssues) {
                    nodes {
                        id
                        identifier
                        title
                        # ... compact issue fields ...
                    }
                }
            }
        }
    """

    result = await self.client.execute_query(query, {
        "id": project_id,
        "includeIssues": include_issues,
        "issuesLimit": issues_limit
    })
```

**Impact**:
- ✅ Single query instead of two
- ✅ Uses same ID that worked for project lookup
- ✅ Backward compatible (default include_issues=False)
- ❌ Requires updating all callers that need issues

### Option B: Keep Current Approach, Fix ID Resolution

**Keep**: Current `_get_project_issues()` filter-based approach
**Fix**: Ensure project_id is properly resolved to UUID before filtering

**Modify**: `_get_project_issues()` (lines 728-770)

```python
async def _get_project_issues(
    self, project_id: str, limit: int = 100
) -> list[Task]:
    """Fetch all issues belonging to a Linear project.

    Args:
        project_id: Project UUID, slugId, or short ID
        limit: Maximum issues to return (default 100, max 250)
    """
    logger = logging.getLogger(__name__)

    # CRITICAL: Resolve project_id to full UUID first
    # This ensures the filter uses the correct ID format
    project_uuid = await self._resolve_project_id(project_id)
    if not project_uuid:
        logger.warning(f"Could not resolve project ID: {project_id}")
        return []

    # Build filter for issues belonging to this project
    issue_filter = build_issue_filter(project_id=project_uuid)

    # ... rest of implementation unchanged ...
```

**Impact**:
- ✅ Minimal code change
- ✅ Ensures correct ID format for filtering
- ✅ No API changes
- ⚠️ Still makes two queries (get_project + list_issues)

### Option C: Hybrid Approach (Recommended)

**Combine** both approaches:
1. Add optional issues loading to `get_project()`
2. Keep `_get_project_issues()` for standalone use
3. Update `read()` to use new `get_project(include_issues=True)`

**Benefits**:
- ✅ Efficient for most cases (single query)
- ✅ Flexible for edge cases (separate queries)
- ✅ Backward compatible
- ✅ Fixes ID resolution issue

## Testing Recommendations

### Test Case 1: Short ID Format
```python
# Input: 12-character hex short ID
epic_id = "13ddc89e7271"
epic = await adapter.read(epic_id)
assert epic is not None
assert len(epic.child_issues) > 0
```

### Test Case 2: Slug-ID Format
```python
# Input: slug-shortid format from URL
epic_id = "epstein-island-13ddc89e7271"
epic = await adapter.read(epic_id)
assert epic is not None
assert len(epic.child_issues) > 0
```

### Test Case 3: Full UUID Format
```python
# Input: Full Linear project UUID
epic_id = "c0e6db5a-03b6-479f-8796-5070b8fb7895"
epic = await adapter.read(epic_id)
assert epic is not None
assert len(epic.child_issues) > 0
```

### Test Case 4: Direct epic_issues Tool
```python
# Test hierarchy_tools.epic_issues endpoint
result = await epic_issues("13ddc89e7271")
assert result["status"] == "completed"
assert result["count"] > 0
```

## Linear GraphQL API Documentation Gaps

**Need to verify**:
1. What ID formats does `project(id:)` query accept?
   - UUID only?
   - slugId (e.g., "epstein-island-13ddc89e7271")?
   - Short ID (e.g., "13ddc89e7271")?

2. What ID format must be used in issue filters?
   ```graphql
   {
     issues(filter: {project: {id: {eq: "???"}}}) {
       nodes { id }
     }
   }
   ```

3. Does Linear's project.issues field support pagination?
   ```graphql
   {
     project(id: "...") {
       issues(first: 100, after: "cursor") {
         nodes { id }
         pageInfo { hasNextPage, endCursor }
       }
     }
   }
   ```

## Conclusion

### Gap Analysis

**Why child_issues is empty**:
- ❌ **NOT** because adapter.read() doesn't populate it (it does - lines 1525-1532)
- ❌ **NOT** because _get_project_issues() doesn't exist (it does - lines 728-770)
- ✅ **LIKELY** because project ID format mismatch between queries
- ✅ **POSSIBLE** that Linear filter `{project: {id: {eq: "..."}}}` requires UUID format

### Recommended Solution

**Priority 1**: Fix ID resolution in `_get_project_issues()`
```python
# Add this at start of _get_project_issues():
project_uuid = await self._resolve_project_id(project_id)
if not project_uuid:
    return []
issue_filter = build_issue_filter(project_id=project_uuid)
```

**Priority 2**: Add direct project.issues query as optimization
```python
# Enhance get_project() to optionally include issues
# Update read() to use get_project(include_issues=True)
```

### Files to Modify

1. **src/mcp_ticketer/adapters/linear/adapter.py**
   - Line 728-770: Add project ID resolution to `_get_project_issues()`
   - Line 362-426: Add optional issues loading to `get_project()`
   - Line 1521-1534: Update `read()` to use enhanced `get_project()`

2. **src/mcp_ticketer/adapters/linear/queries.py**
   - Line 387-413: Enhance GET_PROJECT_QUERY to support optional issues

### Testing Strategy

1. Create integration test with actual Linear API
2. Test all three project ID formats (UUID, slug-id, short ID)
3. Verify issue filter works with resolved UUID
4. Measure performance difference (single query vs two queries)

## Next Steps

1. **Verify** Linear API behavior with short IDs
2. **Implement** Priority 1 fix (ID resolution)
3. **Test** with user's project `13ddc89e7271`
4. **Consider** Priority 2 optimization if needed
5. **Document** Linear project ID format requirements
