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.
421 lines
14 KiB
Python
421 lines
14 KiB
Python
"""
|
|
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
|