issue-core/issue_core/core/mapping.py
tegwick f88e9a28fe
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 1m22s
feat: implement ISSUE-WP-0005 connector mapping and scope alignment
Add MappingService (work_record_uuid ↔ external id), project/map CLI with
outward push-status, optional TaskSpec work_record fields, boundary-sync
policy, and packaging/capability framing cleanup. Mark WP-0005 finished.
2026-07-22 00:25:06 +02:00

381 lines
13 KiB
Python

"""
Work-record UUID ↔ external issue id mapping.
Fleet bookkeeping for the connector role (INTENT / work-record canon).
Storage is a dedicated SQLite file so mapping works regardless of whether
the active CRUD backend is local or Gitea.
"""
from __future__ import annotations
import sqlite3
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
ACTIVE = "active"
DETACHED = "detached"
SUPERSEDED = "superseded"
@dataclass
class WorkRecordMapping:
"""One projection link between a work record and a backend issue."""
id: str
work_record_uuid: str
work_record_id: Optional[str]
work_record_kind: Optional[str]
backend: str
external_id: str
external_url: Optional[str]
target_repo: Optional[str]
direction: str # outward | linked
status: str # active | detached | superseded
created_at: str
updated_at: str
last_pushed_at: Optional[str] = None
last_pulled_at: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"work_record_uuid": self.work_record_uuid,
"work_record_id": self.work_record_id,
"work_record_kind": self.work_record_kind,
"backend": self.backend,
"external_id": self.external_id,
"external_url": self.external_url,
"target_repo": self.target_repo,
"direction": self.direction,
"status": self.status,
"created_at": self.created_at,
"updated_at": self.updated_at,
"last_pushed_at": self.last_pushed_at,
"last_pulled_at": self.last_pulled_at,
}
# Work-record task status → tracker IssueState (outward v1).
# See docs/boundary-sync-and-status-mapping.md
TASK_STATUS_TO_ISSUE_STATE = {
"wait": "open",
"todo": "open",
"progress": "in_progress",
"done": "closed",
"cancel": "closed",
# Already tracker-ish values (pass-through)
"open": "open",
"closed": "closed",
"in_progress": "in_progress",
"blocked": "blocked",
}
def map_work_record_status_to_issue_state(status: str) -> str:
"""Map fleet work-record status (or tracker state) to IssueState value."""
key = (status or "").strip().lower().replace("-", "_")
if key not in TASK_STATUS_TO_ISSUE_STATE:
raise ValueError(
f"Unknown status {status!r}; expected one of "
f"{sorted(set(TASK_STATUS_TO_ISSUE_STATE))}"
)
return TASK_STATUS_TO_ISSUE_STATE[key]
class MappingService:
"""CRUD for work_record_issue_map rows."""
def __init__(self, db_path: Optional[str] = None):
self.db_path = db_path or "mappings.db"
self.connection: Optional[sqlite3.Connection] = None
def connect(self, db_path: Optional[str] = None) -> None:
if db_path:
self.db_path = db_path
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
self.connection = sqlite3.connect(self.db_path)
self.connection.row_factory = sqlite3.Row
self.connection.execute("PRAGMA foreign_keys = ON")
self._initialize_schema()
def disconnect(self) -> None:
if self.connection:
self.connection.close()
self.connection = None
def _initialize_schema(self) -> None:
assert self.connection is not None
self.connection.executescript(
"""
CREATE TABLE IF NOT EXISTS work_record_issue_map (
id TEXT PRIMARY KEY,
work_record_uuid TEXT NOT NULL,
work_record_id TEXT,
work_record_kind TEXT,
backend TEXT NOT NULL,
external_id TEXT NOT NULL,
external_url TEXT,
target_repo TEXT,
direction TEXT NOT NULL DEFAULT 'outward',
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'detached', 'superseded')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_pushed_at TEXT,
last_pulled_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_map_active_uuid_backend
ON work_record_issue_map (work_record_uuid, backend)
WHERE status = 'active';
CREATE UNIQUE INDEX IF NOT EXISTS idx_map_active_backend_external
ON work_record_issue_map (backend, external_id)
WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_map_work_record_id
ON work_record_issue_map (work_record_id);
"""
)
self.connection.commit()
def _require_conn(self) -> sqlite3.Connection:
if not self.connection:
raise RuntimeError("MappingService is not connected")
return self.connection
def _row_to_mapping(self, row: sqlite3.Row) -> WorkRecordMapping:
return WorkRecordMapping(
id=row["id"],
work_record_uuid=row["work_record_uuid"],
work_record_id=row["work_record_id"],
work_record_kind=row["work_record_kind"],
backend=row["backend"],
external_id=row["external_id"],
external_url=row["external_url"],
target_repo=row["target_repo"],
direction=row["direction"],
status=row["status"],
created_at=row["created_at"],
updated_at=row["updated_at"],
last_pushed_at=row["last_pushed_at"],
last_pulled_at=row["last_pulled_at"],
)
def get_active_by_uuid(
self, work_record_uuid: str, backend: Optional[str] = None
) -> Optional[WorkRecordMapping]:
conn = self._require_conn()
if backend:
cur = conn.execute(
"""
SELECT * FROM work_record_issue_map
WHERE work_record_uuid = ? AND backend = ? AND status = 'active'
""",
(work_record_uuid, backend),
)
else:
cur = conn.execute(
"""
SELECT * FROM work_record_issue_map
WHERE work_record_uuid = ? AND status = 'active'
ORDER BY updated_at DESC
""",
(work_record_uuid,),
)
row = cur.fetchone()
return self._row_to_mapping(row) if row else None
def get_active_by_canonical_id(
self, work_record_id: str, backend: Optional[str] = None
) -> Optional[WorkRecordMapping]:
conn = self._require_conn()
if backend:
cur = conn.execute(
"""
SELECT * FROM work_record_issue_map
WHERE work_record_id = ? AND backend = ? AND status = 'active'
""",
(work_record_id, backend),
)
else:
cur = conn.execute(
"""
SELECT * FROM work_record_issue_map
WHERE work_record_id = ? AND status = 'active'
ORDER BY updated_at DESC
""",
(work_record_id,),
)
row = cur.fetchone()
return self._row_to_mapping(row) if row else None
def get_active_by_external(
self, backend: str, external_id: str
) -> Optional[WorkRecordMapping]:
conn = self._require_conn()
cur = conn.execute(
"""
SELECT * FROM work_record_issue_map
WHERE backend = ? AND external_id = ? AND status = 'active'
""",
(backend, str(external_id)),
)
row = cur.fetchone()
return self._row_to_mapping(row) if row else None
def resolve(
self,
*,
work_record_uuid: Optional[str] = None,
work_record_id: Optional[str] = None,
backend: Optional[str] = None,
external_id: Optional[str] = None,
) -> Optional[WorkRecordMapping]:
if work_record_uuid:
return self.get_active_by_uuid(work_record_uuid, backend=backend)
if work_record_id:
return self.get_active_by_canonical_id(work_record_id, backend=backend)
if backend and external_id is not None:
return self.get_active_by_external(backend, external_id)
raise ValueError("Provide work_record_uuid, work_record_id, or backend+external_id")
def upsert(
self,
*,
work_record_uuid: str,
backend: str,
external_id: str,
work_record_id: Optional[str] = None,
work_record_kind: Optional[str] = None,
external_url: Optional[str] = None,
target_repo: Optional[str] = None,
direction: str = "outward",
) -> WorkRecordMapping:
"""Idempotent active mapping for (uuid, backend)."""
conn = self._require_conn()
now = datetime.now(timezone.utc).isoformat()
existing = self.get_active_by_uuid(work_record_uuid, backend=backend)
if existing:
if existing.external_id != str(external_id):
raise ValueError(
f"Active mapping for {work_record_uuid} on {backend} already "
f"points at external_id={existing.external_id!r}, not {external_id!r}"
)
conn.execute(
"""
UPDATE work_record_issue_map SET
work_record_id = COALESCE(?, work_record_id),
work_record_kind = COALESCE(?, work_record_kind),
external_url = COALESCE(?, external_url),
target_repo = COALESCE(?, target_repo),
updated_at = ?
WHERE id = ?
""",
(
work_record_id,
work_record_kind,
external_url,
target_repo,
now,
existing.id,
),
)
conn.commit()
refreshed = self.get_active_by_uuid(work_record_uuid, backend=backend)
assert refreshed is not None
return refreshed
# External id already mapped to a different UUID?
conflict = self.get_active_by_external(backend, external_id)
if conflict and conflict.work_record_uuid != work_record_uuid:
raise ValueError(
f"external_id={external_id!r} on {backend} already mapped to "
f"{conflict.work_record_uuid}"
)
mapping_id = str(uuid.uuid4())
conn.execute(
"""
INSERT INTO work_record_issue_map (
id, work_record_uuid, work_record_id, work_record_kind,
backend, external_id, external_url, target_repo,
direction, status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
""",
(
mapping_id,
work_record_uuid,
work_record_id,
work_record_kind,
backend,
str(external_id),
external_url,
target_repo,
direction,
now,
now,
),
)
conn.commit()
created = self.get_active_by_uuid(work_record_uuid, backend=backend)
assert created is not None
return created
def detach(
self,
*,
work_record_uuid: Optional[str] = None,
work_record_id: Optional[str] = None,
backend: Optional[str] = None,
) -> Optional[WorkRecordMapping]:
conn = self._require_conn()
mapping = self.resolve(
work_record_uuid=work_record_uuid,
work_record_id=work_record_id,
backend=backend,
)
if not mapping:
return None
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"""
UPDATE work_record_issue_map
SET status = 'detached', updated_at = ?
WHERE id = ?
""",
(now, mapping.id),
)
conn.commit()
cur = conn.execute(
"SELECT * FROM work_record_issue_map WHERE id = ?", (mapping.id,)
)
row = cur.fetchone()
return self._row_to_mapping(row) if row else None
def mark_pushed(self, mapping_id: str) -> None:
conn = self._require_conn()
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"""
UPDATE work_record_issue_map
SET last_pushed_at = ?, updated_at = ?
WHERE id = ?
""",
(now, now, mapping_id),
)
conn.commit()
def list_active(self, limit: int = 100) -> List[WorkRecordMapping]:
conn = self._require_conn()
cur = conn.execute(
"""
SELECT * FROM work_record_issue_map
WHERE status = 'active'
ORDER BY updated_at DESC
LIMIT ?
""",
(limit,),
)
return [self._row_to_mapping(r) for r in cur.fetchall()]