feat: implement ISSUE-WP-0005 connector mapping and scope alignment
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

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.
This commit is contained in:
tegwick 2026-07-22 00:25:06 +02:00
parent 6855fb2a32
commit f88e9a28fe
16 changed files with 1300 additions and 179 deletions

View file

@ -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`. **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 - A counterparty or OSS workflow lives in Gitea/GitHub/Jira
- **State Management**: Issue states (open, in_progress, closed) track progress across the team - You need to **project or link** a work record (`issue project` / `issue map`)
- **Communication Channel**: Comments enable inter-agent communication and human oversight - You must update or comment on an **external** issue already in a tracker
- **Progress Visibility**: Labels, assignees, and milestones provide real-time project status
- **Audit Trail**: Complete history of who did what and when Do **not** treat issue-core as the org task board or as origin of intake/tasks.
- **Human Integration**: Human developers can seamlessly participate in agent-driven projects Findings → `kind: intake` + promotion; optional later projection.
## Current Status: Production-Ready with Manual Setup ## Current Status: Production-Ready with Manual Setup

View file

@ -3,21 +3,20 @@
metadata: metadata:
name: issue-core name: issue-core
version: 1.0.0 version: 0.2.1
type: coordination-tool type: connector
description: > description: >
Universal interface for issue tracking coordination across Gitea, GitHub, GitLab. Backend-agnostic connector to external issue trackers (Gitea/Forgejo, SQLite).
Provides unified API to prevent direct platform API usage and credential sprawl. Maps work-record UUIDs to external issue ids. Not the origin of fleet work records.
# What problems this capability solves # What problems this capability solves
purpose: purpose:
primary: Agent coordination via issue tracking primary: External tracker projection and ops (work-record-aware connector)
problems_solved: problems_solved:
- Direct API calls to GitHub/GitLab/Gitea (avoid credential sprawl) - Direct API calls to Gitea/GitHub/GitLab (credential sprawl)
- Inconsistent issue tracking access patterns - No durable link from work-record UUID to tracker issue
- Token waste from redundant API calls - Platform-specific agent code for tracker CRUD
- Platform-specific code in agents - Offline SQLite cache / backend sync for tracker data
- Offline/online sync complexity
# When agents should use this capability # When agents should use this capability
usage_rules: usage_rules:
@ -25,19 +24,21 @@ usage_rules:
- "Direct Gitea API calls (requests.post to /api/v1/repos/...)" - "Direct Gitea API calls (requests.post to /api/v1/repos/...)"
- "GitHub CLI (gh issue create/list/...)" - "GitHub CLI (gh issue create/list/...)"
- "GitLab CLI (glab issue create/list/...)" - "GitLab CLI (glab issue create/list/...)"
- "Python libraries (PyGithub, python-gitlab)" - "Python libraries (PyGithub, python-gitlab) for routine tracker ops"
- "Direct SQL queries to issue databases"
PREFER_OVER: PREFER_OVER:
- "Web scraping of issue tracker UIs" - "Ad hoc curl scripts against tracker APIs"
- "Manual issue management" - "Fire-and-forget Forgejo issues with no work-record mapping"
- "Custom issue tracking scripts"
USE_WHEN: USE_WHEN:
- "Creating, updating, or querying issues" - "Projecting or linking a work record to an external issue"
- "Multi-agent coordination needed" - "Creating, updating, or querying issues on a tracker backend"
- "Offline work with sync required" - "Offline tracker work with backend sync"
- "Cross-platform issue management" - "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 # How to integrate this capability
integration: integration:

View file

@ -333,14 +333,33 @@ issue-core/
## Documentation ## Documentation
- **[INTENT.md](INTENT.md)** — why issue-core exists (connector, not landing zone) - **[INTENT.md](INTENT.md)** — why issue-core exists (work-record-aligned connector)
- **[SCOPE.md](SCOPE.md)** — in/out of scope and integration boundaries - **[SCOPE.md](SCOPE.md)** — shipped inventory and product boundary
- **[docs/uuid-external-id-mapping.md](docs/uuid-external-id-mapping.md)** — mapping design (not implemented yet) - **[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 - **[AGENT_INTEGRATION.md](AGENT_INTEGRATION.md)** — programmatic API for tracker backends
- **[ROADMAP.md](ROADMAP.md)** — feature trajectory (connector-aligned) - **[ROADMAP.md](ROADMAP.md)** — feature trajectory (connector-aligned)
- Work-record canon: `the-custodian/canon/standards/work-record-types_v0.1.md` - Work-record canon: `the-custodian/canon/standards/work-record-types_v0.1.md`
- Architecture §4.2: `the-custodian/research/WorkOrchestrationArchitectureDraft.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 <work-record-uuid> --title "External title" --canonical-id ISSUE-WP-0005-T05
# Link an existing tracker issue
issue map link <work-record-uuid> 42
# Resolve / detach / outward status push
issue map show --uuid <work-record-uuid>
issue map push-status --uuid <work-record-uuid> --status progress
issue map detach --uuid <work-record-uuid>
```
Mappings live in `~/.config/issue-tracker/mappings.db` (bookkeeping), independent
of whether CRUD targets Gitea or local SQLite.
## Roadmap (summary) ## Roadmap (summary)
### Connector alignment (docs — ISSUE-WP-0004) ### Connector alignment (docs — ISSUE-WP-0004)

View file

@ -54,14 +54,26 @@ declared in `pyproject.toml`; no backend package under `issue_core/backends/`).
| Group | Commands (shipped) | | Group | Commands (shipped) |
| --- | --- | | --- | --- |
| Issues | `list`, `show`, `create`, `edit`, `close`, `reopen`, `comment` | | 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` | | Backend | `backend list`, `add`, `remove`, `test`, `set-default` |
| Sync | `sync status`, `pull`, `push`, `bidirectional` | | Sync | `sync status`, `pull`, `push`, `bidirectional` |
| Server | `serve` — FastAPI process (requires `[api]` extra) | | Server | `serve` — FastAPI process (requires `[api]` extra) |
- JSON-friendly output for agents (`--format=json` on list/show paths). - 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. - 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]'`) ### 1.5 REST API (optional install: `pip install 'issue-core[api]'`)
Auth: shared secret `ISSUE_CORE_API_KEY` via `Authorization: Bearer …` or 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 | | 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` | | 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 | | 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 | | 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 | | Per-`target_repo` backend routing on REST | Planned only |
| Issue claiming locks beyond assignee + state | Convention only | | Issue claiming locks beyond assignee + state | Convention only |
| First-class due-date field on `Issue` | `due_in_days` → ingestion metadata 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) ### 2.2 Connector / mapping (owned direction)
- Durable **work-record UUID ↔ (backend, external issue id)** mapping - Durable **work-record UUID ↔ (backend, external issue id)** mapping —
(design agreed; implementation stage-3 work-record architecture). **store + CLI shipped** (ISSUE-WP-0005); REST `/mappings/` and file
- Optional two-way **boundary sync** when an integration is switched on; back-references remain optional growth.
**zero load** when not configured. - **Outward** status projection shipped; **inward** boundary sync later,
- Keep `triggering_event_id` as emitter lineage; **extend** with never silent ADR-001 mutation; **zero load** when no mapping is used.
`work_record_uuid` rather than overloading it (see mapping design). - Keep `triggering_event_id` as emitter lineage; optional
**`work_record_uuid` on TaskSpec** is shipped (do not overload
`triggering_event_id`).
### 2.3 Surfaces ### 2.3 Surfaces
@ -242,12 +258,17 @@ Retained for intentional emits and backward compatibility:
"source_type": "rule | instruction", "source_type": "rule | instruction",
"source_id": "string", "source_id": "string",
"triggering_event_id": "event uuid or stable source key", "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 - `triggering_event_id`: non-empty string; activity event UUID or stable key
such as `scheduled`. Stored in ingestion metadata — **not** a work-record UUID. 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: - Response:
```json ```json

View file

@ -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 <work-record-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`

View file

@ -21,6 +21,7 @@ from ..backends.gitea import GiteaBackend
from ..backends.local import LocalSQLiteBackend from ..backends.local import LocalSQLiteBackend
from ..cli.utils import get_config_dir, load_backend_configs from ..cli.utils import get_config_dir, load_backend_configs
from ..core.interfaces import BackendFactory, IssueBackend from ..core.interfaces import BackendFactory, IssueBackend
from ..core.mapping import MappingService
from ..core.models import Issue, IssueState, Label from ..core.models import Issue, IssueState, Label
from .auth import require_api_key from .auth import require_api_key
from .schemas import BackendName, TaskIngestionRequest, TaskIngestionResponse 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() ingestion_meta["due_at"] = (now + timedelta(days=payload.due_in_days)).isoformat()
sync_metadata: Dict[str, Any] = {"ingestion": ingestion_meta} 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( return Issue(
id="", 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( @router.post(
"/issues/", "/issues/",
response_model=TaskIngestionResponse, response_model=TaskIngestionResponse,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_key)], 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: async def ingest_task(payload: TaskIngestionRequest) -> TaskIngestionResponse:
backend, backend_type = _resolve_backend() 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) draft = _build_issue(payload, backend_type)
try: try:
created: Issue = backend.create_issue(draft) created: Issue = backend.create_issue(draft)
@ -131,7 +188,20 @@ async def ingest_task(payload: TaskIngestionRequest) -> TaskIngestionResponse:
issue_url: Optional[str] = None issue_url: Optional[str] = None
if created.sync_metadata: if created.sync_metadata:
issue_url = created.sync_metadata.get("url") or created.sync_metadata.get("html_url") 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( return TaskIngestionResponse(
issue_id=issue_id, issue_id=issue_id,
issue_url=issue_url, issue_url=issue_url,

View file

@ -34,10 +34,25 @@ class TaskIngestionRequest(BaseModel):
min_length=1, min_length=1,
description=( description=(
"Activity event UUID, or a stable scheduler/source key when no " "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) 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): class TaskIngestionResponse(BaseModel):

View file

@ -12,6 +12,7 @@ from .commands import issue_group
from .backend_commands import backend_group from .backend_commands import backend_group
from .sync_commands import sync_group from .sync_commands import sync_group
from .serve_command import serve_command from .serve_command import serve_command
from .map_commands import map_group, project_command
from .. import __version__ from .. import __version__
@ -23,22 +24,19 @@ from .. import __version__
@click.pass_context @click.pass_context
def cli(ctx, config, backend, verbose): 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, Not the origin of fleet work records see INTENT.md. Use project/map
Gitea, GitHub, and other issue tracking systems. to link work-record UUIDs to external issues when a tracker is in use.
Examples: Examples:
issue list # List all issues issue list
issue create "Bug in parser" # Create new issue issue create "Tracker note"
issue show 42 # Show issue #42 issue project <work-record-uuid> --title "..."
issue close 42 # Close issue #42 issue map show --uuid <uuid>
issue map push-status --uuid <uuid> --status progress
backend add local ~/.issues # Add local backend backend add local ~/.issues
backend add gitea myrepo # Add Gitea backend sync pull gitea
sync pull gitea # Sync from Gitea
sync push gitea # Sync to Gitea
""" """
# Ensure the object exists # Ensure the object exists
ctx.ensure_object(dict) 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(backend_group, name='backend')
cli.add_command(sync_group, name='sync') cli.add_command(sync_group, name='sync')
cli.add_command(serve_command) cli.add_command(serve_command)
cli.add_command(map_group, name='map')
cli.add_command(project_command, name='project')
# Convenience aliases - direct issue commands # Convenience aliases - direct issue commands

View file

@ -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

381
issue_core/core/mapping.py Normal file
View file

@ -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()]

View file

@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "issue-core" 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" readme = "README.md"
requires-python = ">=3.8" requires-python = ">=3.8"
license = {text = "MIT"} license = {text = "MIT"}
authors = [ authors = [
{name = "MarkiTect Project", email = "noreply@example.com"}, {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 = [ classifiers = [
"Development Status :: 4 - Beta", "Development Status :: 4 - Beta",
"Environment :: Console", "Environment :: Console",

View file

@ -1,60 +1,62 @@
--- ---
id: capability.infotech.issue-tracking id: capability.infotech.issue-tracking
name: Universal Issue Tracking Coordination name: External Issue Tracker Connector
summary: Unified Python/CLI interface for issue tracking across Gitea, GitHub, and GitLab, preventing summary: Backend-agnostic CLI/Python connector to Gitea/Forgejo (and SQLite cache)
direct platform API usage and credential sprawl for coordinating agents. with work-record UUID ↔ external issue mapping. Not the fleet work origin.
owner: issue-core owner: issue-core
status: draft status: draft
domain: infotech domain: infotech
tags: tags:
- issue-tracking - issue-tracking
- coordination - connector
- multi-platform - work-record
maturity: maturity:
discovery: discovery:
current: D4 current: D4
target: D5 target: D5
confidence: high confidence: high
rationale: SCOPE.md, CAPABILITY-issue-tracking.yaml (agent-integration deep spec), AGENT_INTEGRATION.md, rationale: INTENT.md, SCOPE.md, CAPABILITY-issue-tracking.yaml, mapping design,
ROADMAP.md, and .capability/ feedback cover usage rules, API surface, and credential handling. This boundary-sync policy, AGENT_INTEGRATION.md.
registry entry is the federation-facing normalization of that material — the YAML manifest remains
the detailed agent-integration companion, not superseded.
availability: availability:
current: A2 current: A2
target: A3 target: A3
confidence: high confidence: high
rationale: Both a Python API (`from issue_core.backends.gitea import GiteaBackend`) and a CLI (`issue rationale: Python package + CLI (list/create/project/map) installable via pip;
list/create/show/edit/close/comment`, JSON output) are installable via pip; MCP server is planned mapping store shipped; MCP not yet available.
but not yet available.
external_evidence: external_evidence:
completeness: completeness:
level: C2 level: C2
confidence: low confidence: medium
basis: scope_vs_intent_and_consumer_expectations basis: scope_vs_intent_and_consumer_expectations
satisfied_expectations: satisfied_expectations:
- Gitea backend implemented; GitHub/GitLab described in scope - Gitea + local SQLite backends
- CLI with JSON output and offline/local caching - CLI with JSON output; project/map commands
- credential handling via env vars per credential-routing conventions (GITEA_API_TOKEN, never in code/logs) - work_record_uuid optional on TaskSpec
- credential handling via env vars (GITEA_API_TOKEN, ISSUE_CORE_API_KEY)
broken_expectations: [] 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: reliability:
level: R1 level: R1
confidence: low confidence: low
basis: consumer_quality_signals basis: consumer_quality_signals
known_reliability_risks: known_reliability_risks:
- manual backend configuration required in v1.0 (auto-detect planned for v1.1) - manual backend configuration (auto-detect planned)
- no built-in issue locking beyond assignee+comment convention - no built-in issue locking beyond assignee+state
- MCP server not yet available - inward boundary sync not shipped
discovery: discovery:
intent: Give coordinating agents a single, credential-safe interface for issue tracking across Gitea/GitHub/GitLab intent: Project and operate on external issue trackers with a stable link to
instead of direct platform API calls, CLI wrapping, or custom scripts. work-record UUIDs; never replace file-first work records as coordination substrate.
includes: includes:
- unified Python API and CLI over Gitea/GitHub/GitLab issue operations - unified Python API and CLI over Gitea/SQLite issue operations
- local caching and offline mode - work_record_issue_map store + project/map/push-status
- local offline cache and backend sync
- credential handling via environment variables - credential handling via environment variables
excludes: excludes:
- MCP server (planned, not yet shipped) - originating workplans/tasks/intake
- distributed locking and query DSL (roadmapped for v2.0) - MCP server (planned)
- multi-agent fleet board semantics on Forgejo
assumptions: [] assumptions: []
use_cases: [] use_cases: []
research_memos: [] research_memos: []
@ -63,70 +65,21 @@ availability:
target_level: A3 target_level: A3
current_artifacts: current_artifacts:
- Python package (`issue_core`) - Python package (`issue_core`)
- '`issue` CLI' - '`issue` CLI (including project/map)'
target_artifacts: [] target_artifacts: []
consumption_modes: consumption_modes:
- cli - cli
- library import - library import
- rest (optional `[api]` extra)
relations: relations:
depends_on: [] depends_on: []
supports: supports: []
- capability.procurement.vergabe-teilnahme
related_to: related_to:
- capability.activity.event-coordinate - capability.activity.event-coordinate
evidence: evidence:
documentation: documentation:
- INTENT.md
- SCOPE.md - SCOPE.md
- CAPABILITY-issue-tracking.yaml - CAPABILITY-issue-tracking.yaml
- AGENT_INTEGRATION.md - docs/uuid-external-id-mapping.md
- ROADMAP.md - docs/boundary-sync-and-status-mapping.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.<domain>.<name>` 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`

View file

@ -189,6 +189,42 @@ def test_ingest_persists_traceability_metadata(client, valid_payload, tmp_issue_
backend.disconnect() 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 @pytest.mark.unit
def test_app_refuses_without_api_key_env(monkeypatch, tmp_issue_store, valid_payload): def test_app_refuses_without_api_key_env(monkeypatch, tmp_issue_store, valid_payload):
monkeypatch.delenv("ISSUE_CORE_API_KEY", raising=False) monkeypatch.delenv("ISSUE_CORE_API_KEY", raising=False)

View file

@ -27,9 +27,9 @@ class TestCLICommands:
"""Test main CLI help displays correctly.""" """Test main CLI help displays correctly."""
result = self.runner.invoke(cli, ['--help']) result = self.runner.invoke(cli, ['--help'])
assert result.exit_code == 0 assert result.exit_code == 0
assert 'Universal Issue Tracking System' in result.output assert 'External issue-tracker connector' in result.output
assert 'issue list' in result.output assert 'project' in result.output
assert 'issue show' in result.output assert 'map' in result.output
def test_backend_list_command(self): def test_backend_list_command(self):
"""Test backend list command.""" """Test backend list command."""
@ -121,35 +121,39 @@ class TestCLICommands:
result = self.runner.invoke(cli, ['--version']) result = self.runner.invoke(cli, ['--version'])
assert result.exit_code == 0 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): def test_list_command_basic(self, mock_get_backend):
"""Test basic list command functionality.""" """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() mock_backend = Mock()
now = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
# Create mock issues mock_backend.list_issues.return_value = [
mock_issue1 = Mock() Issue(
mock_issue1.number = 1 id="1",
mock_issue1.title = "First Issue" number=1,
mock_issue1.state.value = "open" title="First Issue",
description="",
mock_issue2 = Mock() state=IssueState.OPEN,
mock_issue2.number = 2 created_at=now,
mock_issue2.title = "Second Issue" updated_at=now,
mock_issue2.state.value = "closed" ),
Issue(
mock_backend.list_issues.return_value = [mock_issue1, mock_issue2] id="2",
number=2,
title="Second Issue",
description="",
state=IssueState.CLOSED,
created_at=now,
updated_at=now,
),
]
mock_get_backend.return_value = mock_backend mock_get_backend.return_value = mock_backend
result = self.runner.invoke(cli, ['list']) result = self.runner.invoke(cli, ['list'])
assert result.exit_code == 0, result.output
# This might fail due to the existing bug, which is what we want to identify assert "First Issue" in result.output
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)
class TestBackendConfiguration: class TestBackendConfiguration:

129
tests/test_mapping.py Normal file
View file

@ -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()

View file

@ -4,7 +4,7 @@ type: workplan
title: "Implement connector alignment: mapping, docs hygiene, scope gaps vs refined INTENT" title: "Implement connector alignment: mapping, docs hygiene, scope gaps vs refined INTENT"
domain: infotech domain: infotech
repo: issue-core repo: issue-core
status: proposed status: finished
owner: codex owner: codex
topic_slug: infotech topic_slug: infotech
created: "2026-07-22" created: "2026-07-22"
@ -66,7 +66,7 @@ without turning issue-core back into a work origin or fleet task board.
```task ```task
id: ISSUE-WP-0005-T01 id: ISSUE-WP-0005-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "f538d48e-11b1-4859-9f39-a1103b90c05b" state_hub_task_id: "f538d48e-11b1-4859-9f39-a1103b90c05b"
``` ```
@ -83,7 +83,7 @@ done; no reintroduction of landing-zone language in those files.
```task ```task
id: ISSUE-WP-0005-T02 id: ISSUE-WP-0005-T02
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "53889021-53ed-4f98-a144-f7c211a58def" state_hub_task_id: "53889021-53ed-4f98-a144-f7c211a58def"
``` ```
@ -105,7 +105,7 @@ capability summary matches SCOPE §1 honesty.
```task ```task
id: ISSUE-WP-0005-T03 id: ISSUE-WP-0005-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "d2fa5a3c-12ef-4a75-a8c9-b7843d507396" state_hub_task_id: "d2fa5a3c-12ef-4a75-a8c9-b7843d507396"
``` ```
@ -129,7 +129,7 @@ policy.
```task ```task
id: ISSUE-WP-0005-T04 id: ISSUE-WP-0005-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "a8f0d528-1b1f-4dfb-8df0-9c9ffd56e47c" state_hub_task_id: "a8f0d528-1b1f-4dfb-8df0-9c9ffd56e47c"
``` ```
@ -152,7 +152,7 @@ note until CLI/API land.
```task ```task
id: ISSUE-WP-0005-T05 id: ISSUE-WP-0005-T05
status: todo status: done
priority: high priority: high
state_hub_task_id: "1866bec4-e4df-444c-b633-aa97c83c0940" state_hub_task_id: "1866bec4-e4df-444c-b633-aa97c83c0940"
``` ```
@ -175,7 +175,7 @@ backend; idempotent re-project returns existing mapping.
```task ```task
id: ISSUE-WP-0005-T06 id: ISSUE-WP-0005-T06
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "8e02509b-d304-45d9-b99b-1016dc325cfd" state_hub_task_id: "8e02509b-d304-45d9-b99b-1016dc325cfd"
``` ```
@ -195,7 +195,7 @@ uses it.
```task ```task
id: ISSUE-WP-0005-T07 id: ISSUE-WP-0005-T07
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "5bea7ad1-3df5-4cf3-b79a-e89ac7ad5af1" state_hub_task_id: "5bea7ad1-3df5-4cf3-b79a-e89ac7ad5af1"
``` ```
@ -215,7 +215,7 @@ SCOPE distinguishes backend sync vs work-record boundary sync clearly.
```task ```task
id: ISSUE-WP-0005-T08 id: ISSUE-WP-0005-T08
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "ca55014a-694d-4a46-beea-cb1f2f438ada" state_hub_task_id: "ca55014a-694d-4a46-beea-cb1f2f438ada"
``` ```
@ -231,7 +231,7 @@ leakage into “shipped today.”
```task ```task
id: ISSUE-WP-0005-T09 id: ISSUE-WP-0005-T09
status: todo status: done
priority: low priority: low
state_hub_task_id: "fe209013-d9de-4709-bc58-41abe186959d" state_hub_task_id: "fe209013-d9de-4709-bc58-41abe186959d"
``` ```