feat: implement ISSUE-WP-0005 connector mapping and scope alignment
Add MappingService (work_record_uuid ↔ external id), project/map CLI with outward push-status, optional TaskSpec work_record fields, boundary-sync policy, and packaging/capability framing cleanup. Mark WP-0005 finished.
This commit is contained in:
parent
6855fb2a32
commit
f88e9a28fe
16 changed files with 1300 additions and 179 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
25
README.md
25
README.md
|
|
@ -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)
|
||||||
|
|
|
||||||
39
SCOPE.md
39
SCOPE.md
|
|
@ -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
|
||||||
|
|
|
||||||
70
docs/boundary-sync-and-status-mapping.md
Normal file
70
docs/boundary-sync-and-status-mapping.md
Normal 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`
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
421
issue_core/cli/map_commands.py
Normal file
421
issue_core/cli/map_commands.py
Normal 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
381
issue_core/core/mapping.py
Normal 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()]
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=61,<77", "wheel"]
|
requires = ["setuptools>=61,<77", "wheel"]
|
||||||
build-backend = "setuptools.build_meta"
|
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",
|
||||||
|
|
@ -37,11 +37,11 @@ dynamic = ["version"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"build>=1.3",
|
"build>=1.3",
|
||||||
"pytest>=6.0",
|
"pytest>=6.0",
|
||||||
"pytest-cov>=2.0",
|
"pytest-cov>=2.0",
|
||||||
"pytest-mock>=3.0",
|
"pytest-mock>=3.0",
|
||||||
"twine>=6.0",
|
"twine>=6.0",
|
||||||
"black>=22.0",
|
"black>=22.0",
|
||||||
"isort>=5.0",
|
"isort>=5.0",
|
||||||
"flake8>=4.0",
|
"flake8>=4.0",
|
||||||
|
|
@ -50,7 +50,7 @@ dev = [
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
"fastapi>=0.110,<1.0",
|
"fastapi>=0.110,<1.0",
|
||||||
"pydantic>=2.0,<3.0",
|
"pydantic>=2.0,<3.0",
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
"sphinx>=4.0",
|
"sphinx>=4.0",
|
||||||
"sphinx-rtd-theme>=1.0",
|
"sphinx-rtd-theme>=1.0",
|
||||||
|
|
@ -172,4 +172,4 @@ exclude_lines = [
|
||||||
"if __name__ == .__main__.:",
|
"if __name__ == .__main__.:",
|
||||||
"class .*\\bProtocol\\):",
|
"class .*\\bProtocol\\):",
|
||||||
"@(abc\\.)?abstractmethod",
|
"@(abc\\.)?abstractmethod",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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`
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
129
tests/test_mapping.py
Normal 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()
|
||||||
|
|
@ -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"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue