342 lines
10 KiB
Python
342 lines
10 KiB
Python
"""
|
|
Linear Integration Configuration
|
|
================================
|
|
|
|
Constants, status mappings, and configuration helpers for Linear integration.
|
|
Mirrors the approach from Linear-Coding-Agent-Harness.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
|
|
# Linear Status Constants (map to Linear workflow states)
|
|
STATUS_TODO = "Todo"
|
|
STATUS_IN_PROGRESS = "In Progress"
|
|
STATUS_DONE = "Done"
|
|
STATUS_BLOCKED = "Blocked" # For stuck chunks
|
|
STATUS_CANCELED = "Canceled"
|
|
|
|
# Linear Priority Constants (1=Urgent, 4=Low, 0=No priority)
|
|
PRIORITY_URGENT = 1 # Core infrastructure, blockers
|
|
PRIORITY_HIGH = 2 # Primary features, dependencies
|
|
PRIORITY_MEDIUM = 3 # Secondary features
|
|
PRIORITY_LOW = 4 # Polish, nice-to-haves
|
|
PRIORITY_NONE = 0 # No priority set
|
|
|
|
# Chunk status to Linear status mapping
|
|
CHUNK_TO_LINEAR_STATUS = {
|
|
"pending": STATUS_TODO,
|
|
"in_progress": STATUS_IN_PROGRESS,
|
|
"completed": STATUS_DONE,
|
|
"blocked": STATUS_BLOCKED,
|
|
"failed": STATUS_BLOCKED, # Map failures to Blocked for visibility
|
|
"stuck": STATUS_BLOCKED,
|
|
}
|
|
|
|
# Linear labels for categorization
|
|
LABELS = {
|
|
"phase": "phase", # Phase label prefix (e.g., "phase-1")
|
|
"service": "service", # Service label prefix (e.g., "service-backend")
|
|
"stuck": "stuck", # Mark stuck chunks
|
|
"auto_build": "auto-build", # All auto-build issues
|
|
"needs_review": "needs-review",
|
|
}
|
|
|
|
# Linear project marker file (stores team/project IDs)
|
|
LINEAR_PROJECT_MARKER = ".linear_project.json"
|
|
|
|
# Meta issue for session tracking
|
|
META_ISSUE_TITLE = "[META] Build Progress Tracker"
|
|
|
|
|
|
@dataclass
|
|
class LinearConfig:
|
|
"""Configuration for Linear integration."""
|
|
api_key: str
|
|
team_id: Optional[str] = None
|
|
project_id: Optional[str] = None
|
|
project_name: Optional[str] = None
|
|
meta_issue_id: Optional[str] = None
|
|
enabled: bool = True
|
|
|
|
@classmethod
|
|
def from_env(cls) -> "LinearConfig":
|
|
"""Create config from environment variables."""
|
|
api_key = os.environ.get("LINEAR_API_KEY", "")
|
|
|
|
return cls(
|
|
api_key=api_key,
|
|
team_id=os.environ.get("LINEAR_TEAM_ID"),
|
|
project_id=os.environ.get("LINEAR_PROJECT_ID"),
|
|
enabled=bool(api_key),
|
|
)
|
|
|
|
def is_valid(self) -> bool:
|
|
"""Check if config has minimum required values."""
|
|
return bool(self.api_key)
|
|
|
|
|
|
@dataclass
|
|
class LinearProjectState:
|
|
"""State of a Linear project for an auto-build spec."""
|
|
initialized: bool = False
|
|
team_id: Optional[str] = None
|
|
project_id: Optional[str] = None
|
|
project_name: Optional[str] = None
|
|
meta_issue_id: Optional[str] = None
|
|
total_issues: int = 0
|
|
created_at: Optional[str] = None
|
|
issue_mapping: dict = None # chunk_id -> issue_id mapping
|
|
|
|
def __post_init__(self):
|
|
if self.issue_mapping is None:
|
|
self.issue_mapping = {}
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"initialized": self.initialized,
|
|
"team_id": self.team_id,
|
|
"project_id": self.project_id,
|
|
"project_name": self.project_name,
|
|
"meta_issue_id": self.meta_issue_id,
|
|
"total_issues": self.total_issues,
|
|
"created_at": self.created_at,
|
|
"issue_mapping": self.issue_mapping,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> "LinearProjectState":
|
|
return cls(
|
|
initialized=data.get("initialized", False),
|
|
team_id=data.get("team_id"),
|
|
project_id=data.get("project_id"),
|
|
project_name=data.get("project_name"),
|
|
meta_issue_id=data.get("meta_issue_id"),
|
|
total_issues=data.get("total_issues", 0),
|
|
created_at=data.get("created_at"),
|
|
issue_mapping=data.get("issue_mapping", {}),
|
|
)
|
|
|
|
def save(self, spec_dir: Path) -> None:
|
|
"""Save state to the spec directory."""
|
|
marker_file = spec_dir / LINEAR_PROJECT_MARKER
|
|
with open(marker_file, "w") as f:
|
|
json.dump(self.to_dict(), f, indent=2)
|
|
|
|
@classmethod
|
|
def load(cls, spec_dir: Path) -> Optional["LinearProjectState"]:
|
|
"""Load state from the spec directory."""
|
|
marker_file = spec_dir / LINEAR_PROJECT_MARKER
|
|
if not marker_file.exists():
|
|
return None
|
|
|
|
try:
|
|
with open(marker_file, "r") as f:
|
|
return cls.from_dict(json.load(f))
|
|
except (json.JSONDecodeError, IOError):
|
|
return None
|
|
|
|
|
|
def get_linear_status(chunk_status: str) -> str:
|
|
"""
|
|
Map chunk status to Linear status.
|
|
|
|
Args:
|
|
chunk_status: Status from implementation_plan.json
|
|
|
|
Returns:
|
|
Corresponding Linear status string
|
|
"""
|
|
return CHUNK_TO_LINEAR_STATUS.get(chunk_status, STATUS_TODO)
|
|
|
|
|
|
def get_priority_for_phase(phase_num: int, total_phases: int) -> int:
|
|
"""
|
|
Determine Linear priority based on phase number.
|
|
|
|
Early phases are higher priority (they're dependencies).
|
|
|
|
Args:
|
|
phase_num: Phase number (1-indexed)
|
|
total_phases: Total number of phases
|
|
|
|
Returns:
|
|
Linear priority value (1-4)
|
|
"""
|
|
if total_phases <= 1:
|
|
return PRIORITY_HIGH
|
|
|
|
# First quarter of phases = Urgent
|
|
# Second quarter = High
|
|
# Third quarter = Medium
|
|
# Fourth quarter = Low
|
|
position = phase_num / total_phases
|
|
|
|
if position <= 0.25:
|
|
return PRIORITY_URGENT
|
|
elif position <= 0.5:
|
|
return PRIORITY_HIGH
|
|
elif position <= 0.75:
|
|
return PRIORITY_MEDIUM
|
|
else:
|
|
return PRIORITY_LOW
|
|
|
|
|
|
def format_chunk_description(chunk: dict, phase: dict = None) -> str:
|
|
"""
|
|
Format a chunk as a Linear issue description.
|
|
|
|
Args:
|
|
chunk: Chunk dict from implementation_plan.json
|
|
phase: Optional phase dict for context
|
|
|
|
Returns:
|
|
Markdown-formatted description
|
|
"""
|
|
lines = []
|
|
|
|
# Description
|
|
if chunk.get("description"):
|
|
lines.append(f"## Description\n{chunk['description']}\n")
|
|
|
|
# Service
|
|
if chunk.get("service"):
|
|
lines.append(f"**Service:** {chunk['service']}")
|
|
elif chunk.get("all_services"):
|
|
lines.append("**Scope:** All services (integration)")
|
|
|
|
# Phase info
|
|
if phase:
|
|
lines.append(f"**Phase:** {phase.get('name', phase.get('id', 'Unknown'))}")
|
|
|
|
# Files to modify
|
|
if chunk.get("files_to_modify"):
|
|
lines.append("\n## Files to Modify")
|
|
for f in chunk["files_to_modify"]:
|
|
lines.append(f"- `{f}`")
|
|
|
|
# Files to create
|
|
if chunk.get("files_to_create"):
|
|
lines.append("\n## Files to Create")
|
|
for f in chunk["files_to_create"]:
|
|
lines.append(f"- `{f}`")
|
|
|
|
# Patterns to follow
|
|
if chunk.get("patterns_from"):
|
|
lines.append("\n## Reference Patterns")
|
|
for f in chunk["patterns_from"]:
|
|
lines.append(f"- `{f}`")
|
|
|
|
# Verification
|
|
if chunk.get("verification"):
|
|
v = chunk["verification"]
|
|
lines.append("\n## Verification")
|
|
lines.append(f"**Type:** {v.get('type', 'none')}")
|
|
if v.get("run"):
|
|
lines.append(f"**Command:** `{v['run']}`")
|
|
if v.get("url"):
|
|
lines.append(f"**URL:** {v['url']}")
|
|
if v.get("scenario"):
|
|
lines.append(f"**Scenario:** {v['scenario']}")
|
|
|
|
# Auto-build metadata
|
|
lines.append("\n---")
|
|
lines.append("*This issue was created by the Auto-Build Framework*")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def format_session_comment(
|
|
session_num: int,
|
|
chunk_id: str,
|
|
success: bool,
|
|
approach: str = "",
|
|
error: str = "",
|
|
git_commit: str = "",
|
|
) -> str:
|
|
"""
|
|
Format a session result as a Linear comment.
|
|
|
|
Args:
|
|
session_num: Session number
|
|
chunk_id: Chunk being worked on
|
|
success: Whether the session succeeded
|
|
approach: What was attempted
|
|
error: Error message if failed
|
|
git_commit: Git commit hash if any
|
|
|
|
Returns:
|
|
Markdown-formatted comment
|
|
"""
|
|
status_emoji = "✅" if success else "❌"
|
|
lines = [
|
|
f"## Session #{session_num} {status_emoji}",
|
|
f"**Chunk:** `{chunk_id}`",
|
|
f"**Status:** {'Completed' if success else 'In Progress'}",
|
|
f"**Time:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
]
|
|
|
|
if approach:
|
|
lines.append(f"\n**Approach:** {approach}")
|
|
|
|
if git_commit:
|
|
lines.append(f"\n**Commit:** `{git_commit[:8]}`")
|
|
|
|
if error:
|
|
lines.append(f"\n**Error:**\n```\n{error[:500]}\n```")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def format_stuck_chunk_comment(
|
|
chunk_id: str,
|
|
attempt_count: int,
|
|
attempts: list[dict],
|
|
reason: str = "",
|
|
) -> str:
|
|
"""
|
|
Format a detailed comment for stuck chunks.
|
|
|
|
Args:
|
|
chunk_id: Stuck chunk ID
|
|
attempt_count: Number of attempts
|
|
attempts: List of attempt records
|
|
reason: Why it's stuck
|
|
|
|
Returns:
|
|
Markdown-formatted comment for escalation
|
|
"""
|
|
lines = [
|
|
"## ⚠️ Chunk Marked as STUCK",
|
|
f"**Chunk:** `{chunk_id}`",
|
|
f"**Attempts:** {attempt_count}",
|
|
f"**Time:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
]
|
|
|
|
if reason:
|
|
lines.append(f"\n**Reason:** {reason}")
|
|
|
|
# Add attempt history
|
|
if attempts:
|
|
lines.append("\n### Attempt History")
|
|
for i, attempt in enumerate(attempts[-5:], 1): # Last 5 attempts
|
|
status = "✅" if attempt.get("success") else "❌"
|
|
lines.append(f"\n**Attempt {i}:** {status}")
|
|
if attempt.get("approach"):
|
|
lines.append(f"- Approach: {attempt['approach'][:200]}")
|
|
if attempt.get("error"):
|
|
lines.append(f"- Error: {attempt['error'][:200]}")
|
|
|
|
lines.append("\n### Recommended Actions")
|
|
lines.append("1. Review the approach and error patterns above")
|
|
lines.append("2. Check for missing dependencies or configuration")
|
|
lines.append("3. Consider manual intervention or different approach")
|
|
lines.append("4. Update HUMAN_INPUT.md with guidance for the agent")
|
|
|
|
return "\n".join(lines)
|