diff --git a/AGENT_INTEGRATION.md b/AGENT_INTEGRATION.md index 7313553..297ca95 100644 --- a/AGENT_INTEGRATION.md +++ b/AGENT_INTEGRATION.md @@ -8,16 +8,17 @@ The **Issue Core** capability provides a standardized interface for autonomous c **Fleet note (2026-07-20):** Internal work originates as repo work records (ADR-001 / work-record canon), not as issue-core issues. Use this guide when an agent must project to or work inside a third-party tracker. See `INTENT.md` and `docs/uuid-external-id-mapping.md`. -### Why Issue Tracking for Agent Coordination? +### When to use a tracker (and when not to) -Issue tracking provides a natural coordination mechanism for multi-agent software development: +**Fleet claim/execute** runs on work records (repo files + state-hub), not on +Forgejo by default. Use issue-core when: -- **Task Distribution**: Issues represent discrete units of work that agents can claim and execute -- **State Management**: Issue states (open, in_progress, closed) track progress across the team -- **Communication Channel**: Comments enable inter-agent communication and human oversight -- **Progress Visibility**: Labels, assignees, and milestones provide real-time project status -- **Audit Trail**: Complete history of who did what and when -- **Human Integration**: Human developers can seamlessly participate in agent-driven projects +- A counterparty or OSS workflow lives in Gitea/GitHub/Jira +- You need to **project or link** a work record (`issue project` / `issue map`) +- You must update or comment on an **external** issue already in a tracker + +Do **not** treat issue-core as the org task board or as origin of intake/tasks. +Findings → `kind: intake` + promotion; optional later projection. ## Current Status: Production-Ready with Manual Setup diff --git a/CAPABILITY-issue-tracking.yaml b/CAPABILITY-issue-tracking.yaml index 0093c29..4f24830 100644 --- a/CAPABILITY-issue-tracking.yaml +++ b/CAPABILITY-issue-tracking.yaml @@ -3,21 +3,20 @@ metadata: name: issue-core - version: 1.0.0 - type: coordination-tool + version: 0.2.1 + type: connector description: > - Universal interface for issue tracking coordination across Gitea, GitHub, GitLab. - Provides unified API to prevent direct platform API usage and credential sprawl. + Backend-agnostic connector to external issue trackers (Gitea/Forgejo, SQLite). + Maps work-record UUIDs to external issue ids. Not the origin of fleet work records. # What problems this capability solves purpose: - primary: Agent coordination via issue tracking + primary: External tracker projection and ops (work-record-aware connector) problems_solved: - - Direct API calls to GitHub/GitLab/Gitea (avoid credential sprawl) - - Inconsistent issue tracking access patterns - - Token waste from redundant API calls - - Platform-specific code in agents - - Offline/online sync complexity + - Direct API calls to Gitea/GitHub/GitLab (credential sprawl) + - No durable link from work-record UUID to tracker issue + - Platform-specific agent code for tracker CRUD + - Offline SQLite cache / backend sync for tracker data # When agents should use this capability usage_rules: @@ -25,19 +24,21 @@ usage_rules: - "Direct Gitea API calls (requests.post to /api/v1/repos/...)" - "GitHub CLI (gh issue create/list/...)" - "GitLab CLI (glab issue create/list/...)" - - "Python libraries (PyGithub, python-gitlab)" - - "Direct SQL queries to issue databases" + - "Python libraries (PyGithub, python-gitlab) for routine tracker ops" PREFER_OVER: - - "Web scraping of issue tracker UIs" - - "Manual issue management" - - "Custom issue tracking scripts" + - "Ad hoc curl scripts against tracker APIs" + - "Fire-and-forget Forgejo issues with no work-record mapping" USE_WHEN: - - "Creating, updating, or querying issues" - - "Multi-agent coordination needed" - - "Offline work with sync required" - - "Cross-platform issue management" + - "Projecting or linking a work record to an external issue" + - "Creating, updating, or querying issues on a tracker backend" + - "Offline tracker work with backend sync" + - "Intentional external issues (not fleet intake origin)" + + DO_NOT_USE_WHEN: + - "Spawning fleet tasks/intake (use work records + promotion)" + - "Default sink for internal automation findings (see ACTIVITY-WP-0022)" # How to integrate this capability integration: diff --git a/README.md b/README.md index 7d64c83..823c92e 100644 --- a/README.md +++ b/README.md @@ -333,14 +333,33 @@ issue-core/ ## Documentation -- **[INTENT.md](INTENT.md)** — why issue-core exists (connector, not landing zone) -- **[SCOPE.md](SCOPE.md)** — in/out of scope and integration boundaries -- **[docs/uuid-external-id-mapping.md](docs/uuid-external-id-mapping.md)** — mapping design (not implemented yet) +- **[INTENT.md](INTENT.md)** — why issue-core exists (work-record-aligned connector) +- **[SCOPE.md](SCOPE.md)** — shipped inventory and product boundary +- **[docs/uuid-external-id-mapping.md](docs/uuid-external-id-mapping.md)** — mapping design +- **[docs/boundary-sync-and-status-mapping.md](docs/boundary-sync-and-status-mapping.md)** — dual-lifecycle policy - **[AGENT_INTEGRATION.md](AGENT_INTEGRATION.md)** — programmatic API for tracker backends - **[ROADMAP.md](ROADMAP.md)** — feature trajectory (connector-aligned) - Work-record canon: `the-custodian/canon/standards/work-record-types_v0.1.md` - Architecture §4.2: `the-custodian/research/WorkOrchestrationArchitectureDraft.md` +### Work-record projection (mapping) + +```bash +# Project a work record to the default backend (idempotent) +issue project --title "External title" --canonical-id ISSUE-WP-0005-T05 + +# Link an existing tracker issue +issue map link 42 + +# Resolve / detach / outward status push +issue map show --uuid +issue map push-status --uuid --status progress +issue map detach --uuid +``` + +Mappings live in `~/.config/issue-tracker/mappings.db` (bookkeeping), independent +of whether CRUD targets Gitea or local SQLite. + ## Roadmap (summary) ### Connector alignment (docs — ISSUE-WP-0004) diff --git a/SCOPE.md b/SCOPE.md index 62a8e78..1cd71ee 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -54,14 +54,26 @@ declared in `pyproject.toml`; no backend package under `issue_core/backends/`). | Group | Commands (shipped) | | --- | --- | | Issues | `list`, `show`, `create`, `edit`, `close`, `reopen`, `comment` | +| Project / map | `project`, `map link`, `map show`, `map detach`, `map list`, `map push-status` | | Backend | `backend list`, `add`, `remove`, `test`, `set-default` | | Sync | `sync status`, `pull`, `push`, `bidirectional` | | Server | `serve` — FastAPI process (requires `[api]` extra) | - JSON-friendly output for agents (`--format=json` on list/show paths). -- Backend configs: `~/.config/issue-core/` (default backend + named configs). +- Backend configs: `~/.config/issue-tracker/` (default backend + named configs). +- Mapping store: `~/.config/issue-tracker/mappings.db` (independent of CRUD backend). - Gitea token typically from env (`GITEA_API_TOKEN` / config); never commit secrets. +### 1.4b Work-record mapping (shipped v0.2.x) + +| Piece | Notes | +| --- | --- | +| `MappingService` | `issue_core/core/mapping.py` — SQLite `work_record_issue_map` | +| Keys | Work-record **UUIDv7** ↔ `(backend, external_id)`; optional canonical id denorm | +| Uniqueness | One active mapping per `(uuid, backend)` and per `(backend, external_id)` | +| Outward status | `map push-status` uses `docs/boundary-sync-and-status-mapping.md` | +| TaskSpec | Optional `work_record_uuid` / `work_record_id` / `work_record_kind` on `POST /issues/` upserts mapping; idempotent reuse | + ### 1.5 REST API (optional install: `pip install 'issue-core[api]'`) Auth: shared secret `ISSUE_CORE_API_KEY` via `Authorization: Bearer …` or @@ -105,7 +117,8 @@ optional `due_at` derived from `due_in_days`. | Item | Status | | --- | --- | -| Work-record UUID ↔ external-id **mapping store/API** | Design only — `docs/uuid-external-id-mapping.md` | +| Inward boundary sync (tracker → work-record file write) | Not shipped — v1 is outward-only | +| REST `/mappings/` resource | CLI primary; REST mapping is via optional fields on `POST /issues/` | | NATS subscriber | Design stub — `docs/nats-task-ingestion.md` | | Runtime calls to state-hub (`add_progress_event`, etc.) | **Not in `issue_core` package**; repo participates as a normal ADR-001 workplan host only | | Auto-detect backend from git remote | Roadmap / Makefile hints; not a reliable product path yet | @@ -114,6 +127,7 @@ optional `due_at` derived from `due_in_days`. | Per-`target_repo` backend routing on REST | Planned only | | Issue claiming locks beyond assignee + state | Convention only | | First-class due-date field on `Issue` | `due_in_days` → ingestion metadata only | +| Canon work-record YAML back-reference field | Field name TBD in the-custodian; mapping authority is issue-core | --- @@ -131,12 +145,14 @@ Things this repo **owns** and may grow, consistent with the connector role. ### 2.2 Connector / mapping (owned direction) -- Durable **work-record UUID ↔ (backend, external issue id)** mapping - (design agreed; implementation stage-3 work-record architecture). -- Optional two-way **boundary sync** when an integration is switched on; - **zero load** when not configured. -- Keep `triggering_event_id` as emitter lineage; **extend** with - `work_record_uuid` rather than overloading it (see mapping design). +- Durable **work-record UUID ↔ (backend, external issue id)** mapping — + **store + CLI shipped** (ISSUE-WP-0005); REST `/mappings/` and file + back-references remain optional growth. +- **Outward** status projection shipped; **inward** boundary sync later, + never silent ADR-001 mutation; **zero load** when no mapping is used. +- Keep `triggering_event_id` as emitter lineage; optional + **`work_record_uuid` on TaskSpec** is shipped (do not overload + `triggering_event_id`). ### 2.3 Surfaces @@ -242,12 +258,17 @@ Retained for intentional emits and backward compatibility: "source_type": "rule | instruction", "source_id": "string", "triggering_event_id": "event uuid or stable source key", - "activity_definition_id": "string" + "activity_definition_id": "string", + "work_record_uuid": "optional UUIDv7", + "work_record_id": "optional canonical id", + "work_record_kind": "optional kind" } ``` - `triggering_event_id`: non-empty string; activity event UUID or stable key such as `scheduled`. Stored in ingestion metadata — **not** a work-record UUID. +- `work_record_uuid` (optional): when set, upserts mapping and makes re-POST + idempotent for that UUID + backend. - Response: ```json diff --git a/docs/boundary-sync-and-status-mapping.md b/docs/boundary-sync-and-status-mapping.md new file mode 100644 index 0000000..1bd9e64 --- /dev/null +++ b/docs/boundary-sync-and-status-mapping.md @@ -0,0 +1,70 @@ +# Boundary sync and status mapping (v1) + +**Status:** normative for ISSUE-WP-0005 +**Date:** 2026-07-22 +**Implements:** INTENT “Boundary sync discipline” +**Related:** `docs/uuid-external-id-mapping.md`, `issue_core/core/mapping.py` + +## Principles + +1. Fleet **work-record `status`** and tracker **`IssueState`** are distinct + vocabularies. Never treat them as the same enum. +2. **v1 is outward-only.** Operators (or automation) call + `issue map push-status` with a work-record status; issue-core updates the + external issue. Inward sync does **not** write work-record files. +3. **Lane, tags, budgets** stay on the work record. Projections may carry + title, body, and agreed labels only. +4. Unmapped backend issues are **outside** the work-record spine. + +## Mapping keys + +| Side | Key | +| --- | --- | +| Work record | **UUIDv7** (bookkeeping); canonical id optional denorm for UX | +| External | `(backend, external_id)` where backend is `sqlite` \| `gitea` \| … | + +## Outward status table (task kind + pass-through) + +| Work-record / input status | Tracker `IssueState` | +| --- | --- | +| `wait` | `open` | +| `todo` | `open` | +| `progress` | `in_progress` | +| `done` | `closed` | +| `cancel` | `closed` | +| `open` | `open` (pass-through) | +| `in_progress` | `in_progress` | +| `blocked` | `blocked` | +| `closed` | `closed` | + +Other kinds (intake, decision, …) should not use this table until kind- +specific rules are added. Prefer projecting only after promotion to `task` +when unsure. + +## Fields + +| May project outward | Stay on work record only | +| --- | --- | +| title, description/body | `lane` | +| labels agreed for external collab | policy/derived `tags` | +| tracker state (via table above) | budgets / token envelopes | +| comments (future) | owner spine identity (`agt-…`) | + +## CLI + +```bash +issue map push-status --uuid --status progress +issue map push-status --id ISSUE-WP-0005-T07 --status done +``` + +## Non-goals (v1) + +- Silent mutation of ADR-001 work-record files from tracker webhooks +- Full comment CRDT merge +- Multi-backend active mappings per UUID (one active per backend) + +## Implementation + +- Policy function: `map_work_record_status_to_issue_state()` in + `issue_core/core/mapping.py` +- CLI: `issue map push-status` diff --git a/issue_core/api/ingest.py b/issue_core/api/ingest.py index c2faaf7..d492894 100644 --- a/issue_core/api/ingest.py +++ b/issue_core/api/ingest.py @@ -21,6 +21,7 @@ from ..backends.gitea import GiteaBackend from ..backends.local import LocalSQLiteBackend from ..cli.utils import get_config_dir, load_backend_configs from ..core.interfaces import BackendFactory, IssueBackend +from ..core.mapping import MappingService from ..core.models import Issue, IssueState, Label from .auth import require_api_key from .schemas import BackendName, TaskIngestionRequest, TaskIngestionResponse @@ -87,6 +88,12 @@ def _build_issue(payload: TaskIngestionRequest, backend_type: str) -> Issue: ingestion_meta["due_at"] = (now + timedelta(days=payload.due_in_days)).isoformat() sync_metadata: Dict[str, Any] = {"ingestion": ingestion_meta} + if payload.work_record_uuid: + sync_metadata["mapping"] = { + "work_record_uuid": payload.work_record_uuid, + "work_record_id": payload.work_record_id, + "work_record_kind": payload.work_record_kind, + } return Issue( id="", @@ -102,15 +109,65 @@ def _build_issue(payload: TaskIngestionRequest, backend_type: str) -> Issue: ) +def _mapping_backend_name(backend_type: str) -> str: + return "sqlite" if backend_type == "local" else backend_type + + +def _upsert_mapping_if_requested( + payload: TaskIngestionRequest, + *, + backend_type: str, + issue_id: str, + issue_url: Optional[str], +) -> None: + if not payload.work_record_uuid: + return + svc = MappingService() + svc.connect(str(get_config_dir() / "mappings.db")) + try: + svc.upsert( + work_record_uuid=payload.work_record_uuid, + work_record_id=payload.work_record_id, + work_record_kind=payload.work_record_kind, + backend=_mapping_backend_name(backend_type), + external_id=issue_id, + external_url=issue_url, + target_repo=payload.target_repo, + direction="outward", + ) + finally: + svc.disconnect() + + @router.post( "/issues/", response_model=TaskIngestionResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_api_key)], - summary="Ingest a task from an external emitter (e.g. activity-core).", + summary="Create an external tracker issue (intentional emit; optional work-record mapping).", ) async def ingest_task(payload: TaskIngestionRequest) -> TaskIngestionResponse: backend, backend_type = _resolve_backend() + backend_name: BackendName = _BACKEND_TYPE_TO_NAME.get(backend_type, backend_type) # type: ignore[assignment] + mapping_backend = _mapping_backend_name(backend_type) + + # Idempotent: if work_record_uuid already mapped, return existing issue. + if payload.work_record_uuid: + map_svc = MappingService() + map_svc.connect(str(get_config_dir() / "mappings.db")) + try: + existing = map_svc.get_active_by_uuid( + payload.work_record_uuid, backend=mapping_backend + ) + if existing: + return TaskIngestionResponse( + issue_id=existing.external_id, + issue_url=existing.external_url, + backend=backend_name, + ) + finally: + map_svc.disconnect() + draft = _build_issue(payload, backend_type) try: created: Issue = backend.create_issue(draft) @@ -131,7 +188,20 @@ async def ingest_task(payload: TaskIngestionRequest) -> TaskIngestionResponse: issue_url: Optional[str] = None if created.sync_metadata: issue_url = created.sync_metadata.get("url") or created.sync_metadata.get("html_url") - backend_name: BackendName = _BACKEND_TYPE_TO_NAME.get(backend_type, backend_type) # type: ignore[assignment] + + try: + _upsert_mapping_if_requested( + payload, + backend_type=backend_type, + issue_id=issue_id, + issue_url=issue_url, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + return TaskIngestionResponse( issue_id=issue_id, issue_url=issue_url, diff --git a/issue_core/api/schemas.py b/issue_core/api/schemas.py index 194658d..381473b 100644 --- a/issue_core/api/schemas.py +++ b/issue_core/api/schemas.py @@ -34,10 +34,25 @@ class TaskIngestionRequest(BaseModel): min_length=1, description=( "Activity event UUID, or a stable scheduler/source key when no " - "event row exists." + "event row exists. Not a work-record UUID." ), ) activity_definition_id: str = Field(..., min_length=1) + work_record_uuid: Optional[str] = Field( + default=None, + description=( + "Optional work-record UUIDv7. When set, ingestion upserts a " + "mapping row (work_record_uuid ↔ backend issue id)." + ), + ) + work_record_id: Optional[str] = Field( + default=None, + description="Optional canonical work-record id (human-facing).", + ) + work_record_kind: Optional[str] = Field( + default=None, + description="Optional work-record kind (task, intake, …).", + ) class TaskIngestionResponse(BaseModel): diff --git a/issue_core/cli/main.py b/issue_core/cli/main.py index dafab62..21986a3 100644 --- a/issue_core/cli/main.py +++ b/issue_core/cli/main.py @@ -12,6 +12,7 @@ from .commands import issue_group from .backend_commands import backend_group from .sync_commands import sync_group from .serve_command import serve_command +from .map_commands import map_group, project_command from .. import __version__ @@ -23,22 +24,19 @@ from .. import __version__ @click.pass_context def cli(ctx, config, backend, verbose): """ - Universal Issue Tracking System + External issue-tracker connector (Gitea/Forgejo, local SQLite). - A backend-agnostic issue tracking tool that works with local SQLite, - Gitea, GitHub, and other issue tracking systems. + Not the origin of fleet work records — see INTENT.md. Use project/map + to link work-record UUIDs to external issues when a tracker is in use. Examples: - issue list # List all issues - issue create "Bug in parser" # Create new issue - issue show 42 # Show issue #42 - issue close 42 # Close issue #42 - - backend add local ~/.issues # Add local backend - backend add gitea myrepo # Add Gitea backend - - sync pull gitea # Sync from Gitea - sync push gitea # Sync to Gitea + issue list + issue create "Tracker note" + issue project --title "..." + issue map show --uuid + issue map push-status --uuid --status progress + backend add local ~/.issues + sync pull gitea """ # Ensure the object exists ctx.ensure_object(dict) @@ -54,6 +52,8 @@ cli.add_command(issue_group, name='issue') cli.add_command(backend_group, name='backend') cli.add_command(sync_group, name='sync') cli.add_command(serve_command) +cli.add_command(map_group, name='map') +cli.add_command(project_command, name='project') # Convenience aliases - direct issue commands diff --git a/issue_core/cli/map_commands.py b/issue_core/cli/map_commands.py new file mode 100644 index 0000000..1af0c50 --- /dev/null +++ b/issue_core/cli/map_commands.py @@ -0,0 +1,421 @@ +""" +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 diff --git a/issue_core/core/mapping.py b/issue_core/core/mapping.py new file mode 100644 index 0000000..d70f16b --- /dev/null +++ b/issue_core/core/mapping.py @@ -0,0 +1,381 @@ +""" +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()] diff --git a/pyproject.toml b/pyproject.toml index 063c453..28222d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,17 @@ [build-system] -requires = ["setuptools>=61,<77", "wheel"] +requires = ["setuptools>=61,<77", "wheel"] build-backend = "setuptools.build_meta" [project] name = "issue-core" -description = "Authoritative task lifecycle manager for the Coulomb org — backend-agnostic with plugin architecture" +description = "External issue-tracker connector (Gitea/Forgejo, SQLite) with work-record UUID mapping — not the fleet work origin" readme = "README.md" requires-python = ">=3.8" license = {text = "MIT"} authors = [ {name = "MarkiTect Project", email = "noreply@example.com"}, ] -keywords = ["issue-tracking", "project-management", "cli", "gitea", "github", "jira"] +keywords = ["issue-tracking", "connector", "cli", "gitea", "forgejo", "work-record"] classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console", @@ -37,11 +37,11 @@ dynamic = ["version"] [project.optional-dependencies] dev = [ - "build>=1.3", + "build>=1.3", "pytest>=6.0", "pytest-cov>=2.0", "pytest-mock>=3.0", - "twine>=6.0", + "twine>=6.0", "black>=22.0", "isort>=5.0", "flake8>=4.0", @@ -50,7 +50,7 @@ dev = [ "httpx>=0.27", "fastapi>=0.110,<1.0", "pydantic>=2.0,<3.0", -] +] docs = [ "sphinx>=4.0", "sphinx-rtd-theme>=1.0", @@ -172,4 +172,4 @@ exclude_lines = [ "if __name__ == .__main__.:", "class .*\\bProtocol\\):", "@(abc\\.)?abstractmethod", -] +] diff --git a/registry/capabilities/capability.infotech.issue-tracking.md b/registry/capabilities/capability.infotech.issue-tracking.md index 09984ef..67ebd67 100644 --- a/registry/capabilities/capability.infotech.issue-tracking.md +++ b/registry/capabilities/capability.infotech.issue-tracking.md @@ -1,60 +1,62 @@ --- id: capability.infotech.issue-tracking -name: Universal Issue Tracking Coordination -summary: Unified Python/CLI interface for issue tracking across Gitea, GitHub, and GitLab, preventing - direct platform API usage and credential sprawl for coordinating agents. +name: External Issue Tracker Connector +summary: Backend-agnostic CLI/Python connector to Gitea/Forgejo (and SQLite cache) + with work-record UUID ↔ external issue mapping. Not the fleet work origin. owner: issue-core status: draft domain: infotech tags: - issue-tracking -- coordination -- multi-platform +- connector +- work-record maturity: discovery: current: D4 target: D5 confidence: high - rationale: SCOPE.md, CAPABILITY-issue-tracking.yaml (agent-integration deep spec), AGENT_INTEGRATION.md, - ROADMAP.md, and .capability/ feedback cover usage rules, API surface, and credential handling. This - registry entry is the federation-facing normalization of that material — the YAML manifest remains - the detailed agent-integration companion, not superseded. + rationale: INTENT.md, SCOPE.md, CAPABILITY-issue-tracking.yaml, mapping design, + boundary-sync policy, AGENT_INTEGRATION.md. availability: current: A2 target: A3 confidence: high - rationale: Both a Python API (`from issue_core.backends.gitea import GiteaBackend`) and a CLI (`issue - list/create/show/edit/close/comment`, JSON output) are installable via pip; MCP server is planned - but not yet available. + rationale: Python package + CLI (list/create/project/map) installable via pip; + mapping store shipped; MCP not yet available. external_evidence: completeness: level: C2 - confidence: low + confidence: medium basis: scope_vs_intent_and_consumer_expectations satisfied_expectations: - - Gitea backend implemented; GitHub/GitLab described in scope - - CLI with JSON output and offline/local caching - - credential handling via env vars per credential-routing conventions (GITEA_API_TOKEN, never in code/logs) + - Gitea + local SQLite backends + - CLI with JSON output; project/map commands + - work_record_uuid optional on TaskSpec + - credential handling via env vars (GITEA_API_TOKEN, ISSUE_CORE_API_KEY) broken_expectations: [] - out_of_scope_expectations: [] + out_of_scope_expectations: + - Fleet task origin / intake promotion (work-record canon) + - Default IssueSink for internal findings (activity-core) reliability: level: R1 confidence: low basis: consumer_quality_signals known_reliability_risks: - - manual backend configuration required in v1.0 (auto-detect planned for v1.1) - - no built-in issue locking beyond assignee+comment convention - - MCP server not yet available + - manual backend configuration (auto-detect planned) + - no built-in issue locking beyond assignee+state + - inward boundary sync not shipped discovery: - intent: Give coordinating agents a single, credential-safe interface for issue tracking across Gitea/GitHub/GitLab - instead of direct platform API calls, CLI wrapping, or custom scripts. + intent: Project and operate on external issue trackers with a stable link to + work-record UUIDs; never replace file-first work records as coordination substrate. includes: - - unified Python API and CLI over Gitea/GitHub/GitLab issue operations - - local caching and offline mode + - unified Python API and CLI over Gitea/SQLite issue operations + - work_record_issue_map store + project/map/push-status + - local offline cache and backend sync - credential handling via environment variables excludes: - - MCP server (planned, not yet shipped) - - distributed locking and query DSL (roadmapped for v2.0) + - originating workplans/tasks/intake + - MCP server (planned) + - multi-agent fleet board semantics on Forgejo assumptions: [] use_cases: [] research_memos: [] @@ -63,70 +65,21 @@ availability: target_level: A3 current_artifacts: - Python package (`issue_core`) - - '`issue` CLI' + - '`issue` CLI (including project/map)' target_artifacts: [] consumption_modes: - cli - library import + - rest (optional `[api]` extra) relations: depends_on: [] - supports: - - capability.procurement.vergabe-teilnahme + supports: [] related_to: - capability.activity.event-coordinate evidence: documentation: + - INTENT.md - SCOPE.md - CAPABILITY-issue-tracking.yaml - - AGENT_INTEGRATION.md - - ROADMAP.md - tests: - - tests/ (120 tests collected 2026-07-07; 61% coverage per CAPABILITY-issue-tracking.yaml) - consumer_feedback: [] - bug_reports: [] - incidents: [] -consumer_guidance: - recommended_for: - - agents coordinating via Gitea/GitHub/GitLab issues who would otherwise call platform APIs directly - not_recommended_for: - - needs requiring MCP server integration (not shipped yet) - known_limitations: - - manual backend configuration in v1.0; no built-in issue locking beyond assignee convention - - CAPABILITY-issue-tracking.yaml and registry/capabilities/ coexist — update both when agent rules change -promotion_history: [] ---- - -# Universal Issue Tracking Coordination - -## Overview - -`issue-core` is a universal interface for issue-tracking coordination across Gitea, GitHub, and GitLab, giving agents a unified Python API and CLI instead of direct platform API calls. This registry entry is the federation-facing form of the repo's `CAPABILITY-issue-tracking.yaml` manifest (120 tests, 61% coverage, documented usage rules and credential handling). The YAML file remains the detailed agent-integration companion. - -## Assessment notes - -### Discovery - -Repo already carries its own detailed capability manifest (CAPABILITY-issue-tracking.yaml) plus AGENT_INTEGRATION.md, ROADMAP.md, and a .capability/ feedback-submission mechanism; usage rules, API surface, and credential handling are all explicitly documented. This entry migrates that existing description into the standard registry location rather than drafting fresh. - -### Availability - -Both a Python API (`from issue_core.backends.gitea import GiteaBackend`) and a CLI (`issue list/create/show/edit/close/comment`, JSON output) are installable via pip; MCP server is planned but not yet available. - -### Completeness - -First-pass honest assessment from the REUSE-WP-0017 coverage campaign -(reuse-surface). No external consumer feedback exists yet; levels reflect -scope-vs-intent documentation quality, not internal code quality. - -### Reliability - -No production consumer telemetry exists yet; reliability level is -intentionally conservative pending REUSE-WP-0019 reuse-telemetry evidence. - -## Promotion checklist - -- [x] ID follows `capability..` pattern -- [x] Maturity enums match `specs/CapabilityMaturityStandard.md` -- [x] `external_evidence` is populated separately from `maturity` -- [x] Relations reference valid capability IDs (vergabe-teilnahme, activity-core) -- [x] Index entry added in `registry/indexes/capabilities.yaml` + - docs/uuid-external-id-mapping.md + - docs/boundary-sync-and-status-mapping.md diff --git a/tests/test_api_ingest.py b/tests/test_api_ingest.py index 0966ec9..417dff2 100644 --- a/tests/test_api_ingest.py +++ b/tests/test_api_ingest.py @@ -189,6 +189,42 @@ def test_ingest_persists_traceability_metadata(client, valid_payload, tmp_issue_ backend.disconnect() +@pytest.mark.unit +def test_ingest_with_work_record_uuid_creates_mapping(client, valid_payload, tmp_issue_store): + wr = str(uuid.uuid4()) + valid_payload["work_record_uuid"] = wr + valid_payload["work_record_id"] = "ISSUE-WP-0005-T06" + valid_payload["work_record_kind"] = "task" + response = client.post( + "/issues/", + json=valid_payload, + headers={"Authorization": f"Bearer {API_KEY}"}, + ) + assert response.status_code == 201, response.text + issue_id = response.json()["issue_id"] + + from issue_core.core.mapping import MappingService + + svc = MappingService() + svc.connect(str(tmp_issue_store / "mappings.db")) + try: + mapping = svc.get_active_by_uuid(wr, backend="sqlite") + assert mapping is not None + assert mapping.external_id == issue_id + assert mapping.work_record_id == "ISSUE-WP-0005-T06" + finally: + svc.disconnect() + + # Idempotent second post returns same issue + response2 = client.post( + "/issues/", + json=valid_payload, + headers={"Authorization": f"Bearer {API_KEY}"}, + ) + assert response2.status_code == 201 + assert response2.json()["issue_id"] == issue_id + + @pytest.mark.unit def test_app_refuses_without_api_key_env(monkeypatch, tmp_issue_store, valid_payload): monkeypatch.delenv("ISSUE_CORE_API_KEY", raising=False) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index b2d6af8..a7e57d3 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -27,9 +27,9 @@ class TestCLICommands: """Test main CLI help displays correctly.""" result = self.runner.invoke(cli, ['--help']) assert result.exit_code == 0 - assert 'Universal Issue Tracking System' in result.output - assert 'issue list' in result.output - assert 'issue show' in result.output + assert 'External issue-tracker connector' in result.output + assert 'project' in result.output + assert 'map' in result.output def test_backend_list_command(self): """Test backend list command.""" @@ -121,35 +121,39 @@ class TestCLICommands: result = self.runner.invoke(cli, ['--version']) assert result.exit_code == 0 - @patch('issue_core.cli.utils.get_backend') + @patch('issue_core.cli.commands.get_backend') def test_list_command_basic(self, mock_get_backend): """Test basic list command functionality.""" - # This test will help us identify the existing bug + from issue_core.core.models import Issue, IssueState + from datetime import timezone + mock_backend = Mock() - - # Create mock issues - mock_issue1 = Mock() - mock_issue1.number = 1 - mock_issue1.title = "First Issue" - mock_issue1.state.value = "open" - - mock_issue2 = Mock() - mock_issue2.number = 2 - mock_issue2.title = "Second Issue" - mock_issue2.state.value = "closed" - - mock_backend.list_issues.return_value = [mock_issue1, mock_issue2] + now = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_backend.list_issues.return_value = [ + Issue( + id="1", + number=1, + title="First Issue", + description="", + state=IssueState.OPEN, + created_at=now, + updated_at=now, + ), + Issue( + id="2", + number=2, + title="Second Issue", + description="", + state=IssueState.CLOSED, + created_at=now, + updated_at=now, + ), + ] mock_get_backend.return_value = mock_backend result = self.runner.invoke(cli, ['list']) - - # This might fail due to the existing bug, which is what we want to identify - if result.exit_code != 0: - print(f"List command failed with: {result.output}") - print(f"Exception: {result.exception}") - - # We expect this to work properly after fixes - assert result.exit_code == 0 or "'Sentinel' object has no attribute 'lower'" in str(result.exception) + assert result.exit_code == 0, result.output + assert "First Issue" in result.output class TestBackendConfiguration: diff --git a/tests/test_mapping.py b/tests/test_mapping.py new file mode 100644 index 0000000..1048099 --- /dev/null +++ b/tests/test_mapping.py @@ -0,0 +1,129 @@ +"""Unit tests for MappingService and status mapping.""" + +import uuid +from pathlib import Path + +import pytest + +from issue_core.core.mapping import ( + MappingService, + map_work_record_status_to_issue_state, +) +from issue_core.core.models import Issue, IssueState, Label +from issue_core.backends.local import LocalSQLiteBackend + + +@pytest.mark.unit +def test_status_mapping_table(): + assert map_work_record_status_to_issue_state("todo") == "open" + assert map_work_record_status_to_issue_state("progress") == "in_progress" + assert map_work_record_status_to_issue_state("done") == "closed" + assert map_work_record_status_to_issue_state("cancel") == "closed" + assert map_work_record_status_to_issue_state("blocked") == "blocked" + with pytest.raises(ValueError): + map_work_record_status_to_issue_state("nope") + + +@pytest.mark.unit +def test_upsert_idempotent(tmp_path): + db = str(tmp_path / "mappings.db") + svc = MappingService() + svc.connect(db) + wr = str(uuid.uuid4()) + m1 = svc.upsert( + work_record_uuid=wr, + work_record_id="ISSUE-WP-0005-T04", + work_record_kind="task", + backend="sqlite", + external_id="1", + direction="outward", + ) + m2 = svc.upsert( + work_record_uuid=wr, + work_record_id="ISSUE-WP-0005-T04", + work_record_kind="task", + backend="sqlite", + external_id="1", + direction="outward", + ) + assert m1.id == m2.id + assert m2.external_id == "1" + assert svc.get_active_by_canonical_id("ISSUE-WP-0005-T04").work_record_uuid == wr + svc.disconnect() + + +@pytest.mark.unit +def test_unique_external_conflict(tmp_path): + db = str(tmp_path / "mappings.db") + svc = MappingService() + svc.connect(db) + a = str(uuid.uuid4()) + b = str(uuid.uuid4()) + svc.upsert(work_record_uuid=a, backend="sqlite", external_id="9") + with pytest.raises(ValueError, match="already mapped"): + svc.upsert(work_record_uuid=b, backend="sqlite", external_id="9") + svc.disconnect() + + +@pytest.mark.unit +def test_detach(tmp_path): + db = str(tmp_path / "mappings.db") + svc = MappingService() + svc.connect(db) + wr = str(uuid.uuid4()) + svc.upsert(work_record_uuid=wr, backend="gitea", external_id="42") + detached = svc.detach(work_record_uuid=wr, backend="gitea") + assert detached is not None + assert detached.status == "detached" + assert svc.get_active_by_uuid(wr) is None + # Can re-map after detach + again = svc.upsert(work_record_uuid=wr, backend="gitea", external_id="43") + assert again.status == "active" + assert again.external_id == "43" + svc.disconnect() + + +@pytest.mark.unit +def test_project_flow_local_backend(tmp_path, monkeypatch): + """End-to-end: create issue on local backend + mapping row.""" + from datetime import datetime, timezone + from issue_core.core.mapping import MappingService + + issues_db = str(tmp_path / "issues.db") + map_db = str(tmp_path / "mappings.db") + backend = LocalSQLiteBackend() + backend.connect({"db_path": issues_db}) + wr = str(uuid.uuid4()) + now = datetime.now(timezone.utc) + issue = backend.create_issue( + Issue( + id="", + number=0, + title="Projected task", + description="", + state=IssueState.OPEN, + created_at=now, + updated_at=now, + labels=[Label(name="work_record:ISSUE-WP-0005-T04")], + backend_type="local", + ) + ) + svc = MappingService() + svc.connect(map_db) + mapping = svc.upsert( + work_record_uuid=wr, + work_record_id="ISSUE-WP-0005-T04", + work_record_kind="task", + backend="sqlite", + external_id=issue.id or str(issue.number), + direction="outward", + ) + # push-status simulation + target = map_work_record_status_to_issue_state("progress") + issue.state = IssueState.from_string(target) + updated = backend.update_issue(issue) + assert updated.state == IssueState.IN_PROGRESS + svc.mark_pushed(mapping.id) + assert svc.get_active_by_uuid(wr).last_pushed_at is not None + svc.disconnect() + backend.disconnect() diff --git a/workplans/ISSUE-WP-0005-connector-alignment-implementation.md b/workplans/ISSUE-WP-0005-connector-alignment-implementation.md index 1ee6a58..4d4b05f 100644 --- a/workplans/ISSUE-WP-0005-connector-alignment-implementation.md +++ b/workplans/ISSUE-WP-0005-connector-alignment-implementation.md @@ -4,7 +4,7 @@ type: workplan title: "Implement connector alignment: mapping, docs hygiene, scope gaps vs refined INTENT" domain: infotech repo: issue-core -status: proposed +status: finished owner: codex topic_slug: infotech created: "2026-07-22" @@ -66,7 +66,7 @@ without turning issue-core back into a work origin or fleet task board. ```task id: ISSUE-WP-0005-T01 -status: todo +status: done priority: high state_hub_task_id: "f538d48e-11b1-4859-9f39-a1103b90c05b" ``` @@ -83,7 +83,7 @@ done; no reintroduction of landing-zone language in those files. ```task id: ISSUE-WP-0005-T02 -status: todo +status: done priority: medium state_hub_task_id: "53889021-53ed-4f98-a144-f7c211a58def" ``` @@ -105,7 +105,7 @@ capability summary matches SCOPE §1 honesty. ```task id: ISSUE-WP-0005-T03 -status: todo +status: done priority: high state_hub_task_id: "d2fa5a3c-12ef-4a75-a8c9-b7843d507396" ``` @@ -129,7 +129,7 @@ policy. ```task id: ISSUE-WP-0005-T04 -status: todo +status: done priority: high state_hub_task_id: "a8f0d528-1b1f-4dfb-8df0-9c9ffd56e47c" ``` @@ -152,7 +152,7 @@ note until CLI/API land. ```task id: ISSUE-WP-0005-T05 -status: todo +status: done priority: high state_hub_task_id: "1866bec4-e4df-444c-b633-aa97c83c0940" ``` @@ -175,7 +175,7 @@ backend; idempotent re-project returns existing mapping. ```task id: ISSUE-WP-0005-T06 -status: todo +status: done priority: medium state_hub_task_id: "8e02509b-d304-45d9-b99b-1016dc325cfd" ``` @@ -195,7 +195,7 @@ uses it. ```task id: ISSUE-WP-0005-T07 -status: todo +status: done priority: medium state_hub_task_id: "5bea7ad1-3df5-4cf3-b79a-e89ac7ad5af1" ``` @@ -215,7 +215,7 @@ SCOPE distinguishes backend sync vs work-record boundary sync clearly. ```task id: ISSUE-WP-0005-T08 -status: todo +status: done priority: medium state_hub_task_id: "ca55014a-694d-4a46-beea-cb1f2f438ada" ``` @@ -231,7 +231,7 @@ leakage into “shipped today.” ```task id: ISSUE-WP-0005-T09 -status: todo +status: done priority: low state_hub_task_id: "fe209013-d9de-4709-bc58-41abe186959d" ```