""" CLI for work-record ↔ external issue mapping (connector role). """ from __future__ import annotations import json from datetime import datetime, timezone from typing import Optional from uuid import UUID import click from ..core.mapping import MappingService, map_work_record_status_to_issue_state from ..core.models import Issue, IssueState, Label from .utils import echo_error, echo_info, echo_success, get_backend, get_config_dir def _default_mapping_db() -> str: return str(get_config_dir() / "mappings.db") def _open_mapping(db_path: Optional[str] = None) -> MappingService: svc = MappingService() svc.connect(db_path or _default_mapping_db()) return svc def _looks_like_uuid(value: str) -> bool: try: UUID(value) return True except (ValueError, TypeError, AttributeError): return False def _split_ref(ref: str) -> tuple: """Return (work_record_uuid|None, work_record_id|None).""" if _looks_like_uuid(ref): return ref, None return None, ref def _backend_label(backend) -> str: """Map backend_type to mapping.backend name.""" bt = getattr(backend, "backend_type", None) or "local" return "sqlite" if bt == "local" else bt def _issue_external_id(issue: Issue) -> str: return issue.id or str(issue.number) def _issue_url(issue: Issue) -> Optional[str]: if issue.sync_metadata: return issue.sync_metadata.get("url") or issue.sync_metadata.get("html_url") return None def _print_mapping(mapping, as_json: bool = False) -> None: if as_json: click.echo(json.dumps(mapping.to_dict(), indent=2)) else: click.echo( f"{mapping.work_record_uuid} " f"{mapping.work_record_id or '-'} " f"{mapping.backend}:{mapping.external_id} " f"[{mapping.status}/{mapping.direction}]" ) if mapping.external_url: click.echo(f" url: {mapping.external_url}") @click.command("project") @click.argument("work_record_ref") @click.option("--title", "-t", help="Issue title (required if creating new)") @click.option("--description", "-d", default="", help="Issue description") @click.option("--kind", "work_record_kind", default="task", help="Work-record kind") @click.option( "--canonical-id", help="Canonical work-record id (if ref is a UUID)", ) @click.option("--label", "-l", multiple=True, help="Labels") @click.option("--target-repo", help="Optional target_repo denorm") @click.option("--mapping-db", type=click.Path(), help="Override mappings.db path") @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text") @click.pass_context def project_command( ctx, work_record_ref, title, description, work_record_kind, canonical_id, label, target_repo, mapping_db, output_format, ): """Project a work record to the default tracker backend (idempotent).""" wr_uuid, wr_id = _split_ref(work_record_ref) if wr_uuid and canonical_id: wr_id = canonical_id if not wr_uuid and not wr_id: raise click.ClickException("work_record_ref is required") # For mapping we need a UUID key. If only canonical id given, derive a # stable namespace UUID so bookkeeping still works until hub UUID is known. if not wr_uuid: # Deterministic UUIDv5 from canonical id — not a hub UUIDv7; operators # should prefer real hub UUIDs when available. from uuid import uuid5, NAMESPACE_URL wr_uuid = str(uuid5(NAMESPACE_URL, f"work-record:{wr_id}")) echo_info( f"No UUID given; using deterministic key {wr_uuid} for canonical id {wr_id}" ) backend = get_backend(ctx) backend_name = _backend_label(backend) svc = _open_mapping(mapping_db) try: existing = svc.get_active_by_uuid(wr_uuid, backend=backend_name) if existing: if output_format == "json": click.echo(json.dumps({"mapping": existing.to_dict(), "created": False}, indent=2)) else: echo_info("Active mapping already exists (idempotent).") _print_mapping(existing) return if not title: title = f"[projection] {wr_id or wr_uuid}" now = datetime.now(timezone.utc) labels = [Label(name=n) for n in label] labels.append(Label(name=f"work_record:{wr_id or wr_uuid}")) draft = Issue( id="", number=0, title=title, description=description or "", state=IssueState.OPEN, created_at=now, updated_at=now, labels=labels, backend_type=getattr(backend, "backend_type", backend_name), sync_metadata={ "mapping": { "work_record_uuid": wr_uuid, "work_record_id": wr_id, "work_record_kind": work_record_kind, } }, ) created = backend.create_issue(draft) ext_id = _issue_external_id(created) mapping = svc.upsert( work_record_uuid=wr_uuid, work_record_id=wr_id, work_record_kind=work_record_kind, backend=backend_name, external_id=ext_id, external_url=_issue_url(created), target_repo=target_repo, direction="outward", ) if output_format == "json": click.echo( json.dumps( { "mapping": mapping.to_dict(), "created": True, "issue_id": ext_id, "issue_number": created.number, }, indent=2, ) ) else: echo_success(f"Projected → {backend_name}:{ext_id} (#{created.number})") _print_mapping(mapping) finally: svc.disconnect() try: backend.disconnect() except Exception: pass @click.group("map") def map_group(): """Work-record ↔ external issue mapping.""" @map_group.command("link") @click.argument("work_record_ref") @click.argument("external_id") @click.option("--kind", "work_record_kind", default="task") @click.option("--canonical-id", help="Canonical id when ref is UUID") @click.option("--url", "external_url", help="External issue URL") @click.option("--target-repo") @click.option("--mapping-db", type=click.Path()) @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text") @click.pass_context def map_link( ctx, work_record_ref, external_id, work_record_kind, canonical_id, external_url, target_repo, mapping_db, output_format, ): """Link an existing external issue to a work record (no create).""" wr_uuid, wr_id = _split_ref(work_record_ref) if wr_uuid and canonical_id: wr_id = canonical_id if not wr_uuid and wr_id: from uuid import uuid5, NAMESPACE_URL wr_uuid = str(uuid5(NAMESPACE_URL, f"work-record:{wr_id}")) backend = get_backend(ctx) backend_name = _backend_label(backend) # Verify issue exists when possible issue = backend.get_issue(str(external_id)) if issue is None: try: issue = backend.get_issue_by_number(int(external_id)) except (TypeError, ValueError): issue = None if issue is None: echo_info("Issue not found on backend; linking by id only.") ext_id = str(external_id) url = external_url else: ext_id = _issue_external_id(issue) url = external_url or _issue_url(issue) svc = _open_mapping(mapping_db) try: mapping = svc.upsert( work_record_uuid=wr_uuid, work_record_id=wr_id, work_record_kind=work_record_kind, backend=backend_name, external_id=ext_id, external_url=url, target_repo=target_repo, direction="linked", ) if output_format == "json": click.echo(json.dumps(mapping.to_dict(), indent=2)) else: echo_success("Linked.") _print_mapping(mapping) finally: svc.disconnect() try: backend.disconnect() except Exception: pass @map_group.command("show") @click.option("--uuid", "work_record_uuid", help="Work-record UUIDv7") @click.option("--id", "work_record_id", help="Canonical work-record id") @click.option("--external", "external_id", help="External issue id") @click.option("--backend", "backend_name", help="Backend name for external lookup") @click.option("--mapping-db", type=click.Path()) @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text") @click.pass_context def map_show( ctx, work_record_uuid, work_record_id, external_id, backend_name, mapping_db, output_format ): """Show active mapping by UUID, canonical id, or external id.""" if not any([work_record_uuid, work_record_id, external_id]): raise click.ClickException("Provide --uuid, --id, or --external") if external_id and not backend_name: backend = get_backend(ctx) backend_name = _backend_label(backend) try: backend.disconnect() except Exception: pass svc = _open_mapping(mapping_db) try: mapping = svc.resolve( work_record_uuid=work_record_uuid, work_record_id=work_record_id, backend=backend_name, external_id=external_id, ) if not mapping: raise click.ClickException("No active mapping found") _print_mapping(mapping, as_json=(output_format == "json")) finally: svc.disconnect() @map_group.command("detach") @click.option("--uuid", "work_record_uuid", help="Work-record UUID") @click.option("--id", "work_record_id", help="Canonical work-record id") @click.option("--backend", "backend_name", help="Limit detach to one backend") @click.option("--mapping-db", type=click.Path()) @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text") def map_detach(work_record_uuid, work_record_id, backend_name, mapping_db, output_format): """Detach (soft) an active mapping.""" if not work_record_uuid and not work_record_id: raise click.ClickException("Provide --uuid or --id") svc = _open_mapping(mapping_db) try: mapping = svc.detach( work_record_uuid=work_record_uuid, work_record_id=work_record_id, backend=backend_name, ) if not mapping: raise click.ClickException("No active mapping to detach") if output_format == "json": click.echo(json.dumps(mapping.to_dict(), indent=2)) else: echo_success("Detached.") _print_mapping(mapping) finally: svc.disconnect() @map_group.command("list") @click.option("--limit", type=int, default=50) @click.option("--mapping-db", type=click.Path()) @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text") def map_list(limit, mapping_db, output_format): """List active mappings.""" svc = _open_mapping(mapping_db) try: rows = svc.list_active(limit=limit) if output_format == "json": click.echo(json.dumps([r.to_dict() for r in rows], indent=2)) else: if not rows: click.echo("No active mappings.") return for m in rows: _print_mapping(m) finally: svc.disconnect() @map_group.command("push-status") @click.option("--uuid", "work_record_uuid", help="Work-record UUID") @click.option("--id", "work_record_id", help="Canonical work-record id") @click.option( "--status", "work_status", required=True, help="Fleet work-record status (e.g. progress, done) or tracker state", ) @click.option("--mapping-db", type=click.Path()) @click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text") @click.pass_context def map_push_status( ctx, work_record_uuid, work_record_id, work_status, mapping_db, output_format ): """Outward-only: set external issue state from work-record status (v1).""" if not work_record_uuid and not work_record_id: raise click.ClickException("Provide --uuid or --id") try: issue_state = map_work_record_status_to_issue_state(work_status) except ValueError as exc: raise click.ClickException(str(exc)) from exc svc = _open_mapping(mapping_db) backend = get_backend(ctx) try: mapping = svc.resolve( work_record_uuid=work_record_uuid, work_record_id=work_record_id, backend=_backend_label(backend), ) if not mapping: raise click.ClickException("No active mapping found") issue = backend.get_issue(mapping.external_id) if issue is None: try: issue = backend.get_issue_by_number(int(mapping.external_id)) except (TypeError, ValueError): issue = None if issue is None: raise click.ClickException(f"External issue {mapping.external_id} not found") issue.state = IssueState.from_string(issue_state) issue.updated_at = datetime.now(timezone.utc) updated = backend.update_issue(issue) svc.mark_pushed(mapping.id) payload = { "mapping": mapping.to_dict(), "work_record_status": work_status, "issue_state": issue_state, "external_id": mapping.external_id, "issue_number": updated.number, } if output_format == "json": click.echo(json.dumps(payload, indent=2)) else: echo_success( f"Pushed status {work_status!r} → issue state {issue_state!r} " f"on {mapping.backend}:{mapping.external_id}" ) finally: svc.disconnect() try: backend.disconnect() except Exception: pass