generated from coulomb/repo-seed
feat: rename to issue-core and add task ingestion endpoint
Renames the package, distribution, CLI alias, Makefile targets, and working directory from issue-facade to issue-core, signalling its role as the authoritative task lifecycle manager for the Coulomb org (peer to activity-core, rules-core, project-core). Adds POST /issues/ ingestion endpoint for activity-core's IssueSink, under a new optional [api] extra. The endpoint is served by `issue serve`, authenticates via the ISSUE_CORE_API_KEY env var (Bearer or X-API-Key header), and routes the TaskSpec payload to the configured default backend with full traceability metadata embedded in sync_metadata. - T01: Python package issue_tracker -> issue_core, dir rename - T02: registered in state hub under custodian domain - T03: INTENT.md (what it is, what it isn't, how it fits) - T04: SCOPE.md (in/out-of-scope, integration boundaries) - T05: POST /issues/ via FastAPI + Uvicorn, 9 unit tests - T06: docs/nats-task-ingestion.md design stub Closes ISSC-WP-0001. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
"""Test suite for issue-facade capability."""
|
||||
"""Test suite for issue-core capability."""
|
||||
|
||||
177
tests/test_api_ingest.py
Normal file
177
tests/test_api_ingest.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Tests for POST /issues/ ingestion endpoint.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
pytest.importorskip("httpx")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from issue_core.api.app import create_app
|
||||
|
||||
|
||||
API_KEY = "test-key-not-a-real-secret-only-for-pytest"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_issue_store(monkeypatch, tmp_path):
|
||||
"""Point cli.utils.get_config_dir at a tmp dir and the default backend at local."""
|
||||
config_dir = tmp_path / "issue-core"
|
||||
config_dir.mkdir()
|
||||
monkeypatch.setattr(
|
||||
"issue_core.cli.utils.get_config_dir", lambda: config_dir, raising=True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"issue_core.api.ingest.get_config_dir", lambda: config_dir, raising=True
|
||||
)
|
||||
|
||||
db_path = str(config_dir / "issues.db")
|
||||
monkeypatch.setattr(
|
||||
"issue_core.cli.utils.load_backend_configs",
|
||||
lambda: {"default": "local", "local": {"type": "local", "db_path": db_path}},
|
||||
raising=True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"issue_core.api.ingest.load_backend_configs",
|
||||
lambda: {"default": "local", "local": {"type": "local", "db_path": db_path}},
|
||||
raising=True,
|
||||
)
|
||||
return config_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, tmp_issue_store):
|
||||
monkeypatch.setenv("ISSUE_CORE_API_KEY", API_KEY)
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_payload():
|
||||
return {
|
||||
"title": "Fix the parser",
|
||||
"description": "Parser fails on multi-line input.",
|
||||
"target_repo": "coulomb/parser",
|
||||
"priority": "high",
|
||||
"labels": ["bug"],
|
||||
"due_in_days": 7,
|
||||
"source_type": "rule",
|
||||
"source_id": "rule:parse-failure",
|
||||
"triggering_event_id": str(uuid.uuid4()),
|
||||
"activity_definition_id": "ad:parser-monitor",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_healthz(client):
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ingest_rejects_missing_api_key(client, valid_payload):
|
||||
response = client.post("/issues/", json=valid_payload)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ingest_rejects_wrong_api_key(client, valid_payload):
|
||||
response = client.post(
|
||||
"/issues/", json=valid_payload, headers={"Authorization": "Bearer wrong"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ingest_creates_issue_with_bearer(client, valid_payload):
|
||||
response = client.post(
|
||||
"/issues/",
|
||||
json=valid_payload,
|
||||
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert body["backend"] == "sqlite"
|
||||
assert body["issue_id"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ingest_creates_issue_with_x_api_key(client, valid_payload):
|
||||
response = client.post(
|
||||
"/issues/",
|
||||
json=valid_payload,
|
||||
headers={"X-API-Key": API_KEY},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ingest_rejects_invalid_payload(client):
|
||||
bad = {"title": "no required fields"}
|
||||
response = client.post(
|
||||
"/issues/", json=bad, headers={"Authorization": f"Bearer {API_KEY}"}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ingest_rejects_invalid_priority(client, valid_payload):
|
||||
valid_payload["priority"] = "urgent"
|
||||
response = client.post(
|
||||
"/issues/",
|
||||
json=valid_payload,
|
||||
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ingest_persists_traceability_metadata(client, valid_payload, tmp_issue_store):
|
||||
"""Check that triggering_event_id, source_*, target_repo are stored on the issue."""
|
||||
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.backends.local import LocalSQLiteBackend
|
||||
|
||||
backend = LocalSQLiteBackend()
|
||||
backend.connect({"type": "local", "db_path": str(tmp_issue_store / "issues.db")})
|
||||
try:
|
||||
stored = backend.get_issue(issue_id)
|
||||
assert stored is not None
|
||||
ingestion = stored.sync_metadata.get("ingestion") or {}
|
||||
assert ingestion["target_repo"] == valid_payload["target_repo"]
|
||||
assert ingestion["source_type"] == valid_payload["source_type"]
|
||||
assert ingestion["source_id"] == valid_payload["source_id"]
|
||||
assert ingestion["triggering_event_id"] == valid_payload["triggering_event_id"]
|
||||
assert ingestion["activity_definition_id"] == valid_payload["activity_definition_id"]
|
||||
label_names = {label.name for label in stored.labels}
|
||||
assert f"priority:{valid_payload['priority']}" in label_names
|
||||
assert f"source:{valid_payload['source_type']}" in label_names
|
||||
assert f"repo:{valid_payload['target_repo']}" in label_names
|
||||
assert "bug" in label_names
|
||||
finally:
|
||||
backend.disconnect()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_app_refuses_without_api_key_env(monkeypatch, tmp_issue_store, valid_payload):
|
||||
monkeypatch.delenv("ISSUE_CORE_API_KEY", raising=False)
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/issues/", json=valid_payload, headers={"Authorization": "Bearer anything"}
|
||||
)
|
||||
assert response.status_code == 503
|
||||
assert "ISSUE_CORE_API_KEY" in response.json()["detail"]
|
||||
@@ -12,8 +12,8 @@ from pathlib import Path
|
||||
from click.testing import CliRunner
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
from issue_tracker.cli.main import cli
|
||||
from issue_tracker.cli.utils import load_backend_configs, save_backend_configs
|
||||
from issue_core.cli.main import cli
|
||||
from issue_core.cli.utils import load_backend_configs, save_backend_configs
|
||||
|
||||
|
||||
class TestCLICommands:
|
||||
@@ -37,9 +37,9 @@ class TestCLICommands:
|
||||
assert result.exit_code == 0
|
||||
# Should show either configured backends or "No backends configured"
|
||||
|
||||
@patch('issue_tracker.cli.backend_commands.load_backend_configs')
|
||||
@patch('issue_tracker.cli.backend_commands.save_backend_configs')
|
||||
@patch('issue_tracker.cli.backend_commands.test_backend_connection')
|
||||
@patch('issue_core.cli.backend_commands.load_backend_configs')
|
||||
@patch('issue_core.cli.backend_commands.save_backend_configs')
|
||||
@patch('issue_core.cli.backend_commands.test_backend_connection')
|
||||
def test_backend_add_gitea_with_env_token(self, mock_test_conn, mock_save, mock_load):
|
||||
"""Test adding Gitea backend with environment token."""
|
||||
# Mock empty initial config
|
||||
@@ -63,9 +63,9 @@ class TestCLICommands:
|
||||
assert saved_config['test-gitea']['type'] == 'gitea'
|
||||
assert saved_config['test-gitea']['token'] == 'test-token'
|
||||
|
||||
@patch('issue_tracker.cli.backend_commands.load_backend_configs')
|
||||
@patch('issue_tracker.cli.backend_commands.save_backend_configs')
|
||||
@patch('issue_tracker.cli.backend_commands.test_backend_connection')
|
||||
@patch('issue_core.cli.backend_commands.load_backend_configs')
|
||||
@patch('issue_core.cli.backend_commands.save_backend_configs')
|
||||
@patch('issue_core.cli.backend_commands.test_backend_connection')
|
||||
def test_backend_add_local(self, mock_test_conn, mock_save, mock_load):
|
||||
"""Test adding local backend."""
|
||||
mock_load.return_value = {}
|
||||
@@ -78,7 +78,7 @@ class TestCLICommands:
|
||||
assert result.exit_code == 0
|
||||
assert 'Backend \'test-local\' added successfully' in result.output
|
||||
|
||||
@patch('issue_tracker.cli.commands.get_backend')
|
||||
@patch('issue_core.cli.commands.get_backend')
|
||||
def test_show_command(self, mock_get_backend):
|
||||
"""Test issue show command."""
|
||||
# Mock backend and issue
|
||||
@@ -104,7 +104,7 @@ class TestCLICommands:
|
||||
assert 'Test description' in result.output
|
||||
assert 'State: open' in result.output
|
||||
|
||||
@patch('issue_tracker.cli.utils.get_backend')
|
||||
@patch('issue_core.cli.utils.get_backend')
|
||||
def test_show_command_issue_not_found(self, mock_get_backend):
|
||||
"""Test issue show command when issue doesn't exist."""
|
||||
mock_backend = Mock()
|
||||
@@ -121,7 +121,7 @@ class TestCLICommands:
|
||||
result = self.runner.invoke(cli, ['--version'])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch('issue_tracker.cli.utils.get_backend')
|
||||
@patch('issue_core.cli.utils.get_backend')
|
||||
def test_list_command_basic(self, mock_get_backend):
|
||||
"""Test basic list command functionality."""
|
||||
# This test will help us identify the existing bug
|
||||
@@ -157,7 +157,7 @@ class TestBackendConfiguration:
|
||||
|
||||
def test_config_directory_creation(self):
|
||||
"""Test configuration directory is created properly."""
|
||||
from issue_tracker.cli.utils import get_config_dir
|
||||
from issue_core.cli.utils import get_config_dir
|
||||
|
||||
config_dir = get_config_dir()
|
||||
assert config_dir.exists()
|
||||
@@ -177,7 +177,7 @@ class TestBackendConfiguration:
|
||||
}
|
||||
|
||||
# Test saving
|
||||
with patch('issue_tracker.cli.utils.get_backend_config_path', return_value=config_file):
|
||||
with patch('issue_core.cli.utils.get_backend_config_path', return_value=config_file):
|
||||
save_backend_configs(test_config)
|
||||
|
||||
# Test loading
|
||||
@@ -190,7 +190,7 @@ class TestBackendConfiguration:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
non_existent_file = Path(temp_dir) / 'nonexistent.json'
|
||||
|
||||
with patch('issue_tracker.cli.utils.get_backend_config_path', return_value=non_existent_file):
|
||||
with patch('issue_core.cli.utils.get_backend_config_path', return_value=non_existent_file):
|
||||
config = load_backend_configs()
|
||||
|
||||
assert config == {}
|
||||
@@ -204,13 +204,13 @@ class TestEnvironmentTokenDetection:
|
||||
"""Test GITEA_API_TOKEN environment variable detection."""
|
||||
mock_getenv.return_value = 'test-env-token'
|
||||
|
||||
from issue_tracker.cli.backend_commands import add_backend
|
||||
from issue_core.cli.backend_commands import add_backend
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
with patch('issue_tracker.cli.backend_commands.load_backend_configs', return_value={}):
|
||||
with patch('issue_tracker.cli.backend_commands.save_backend_configs'):
|
||||
with patch('issue_tracker.cli.backend_commands.test_backend_connection', return_value=True):
|
||||
with patch('issue_core.cli.backend_commands.load_backend_configs', return_value={}):
|
||||
with patch('issue_core.cli.backend_commands.save_backend_configs'):
|
||||
with patch('issue_core.cli.backend_commands.test_backend_connection', return_value=True):
|
||||
result = runner.invoke(add_backend, [
|
||||
'test-gitea', 'gitea'
|
||||
], input='https://git.example.com\ntestorg\ntestrepo\n')
|
||||
|
||||
@@ -7,7 +7,7 @@ including state management, validation, and business logic.
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from issue_tracker.core.models import (
|
||||
from issue_core.core.models import (
|
||||
Issue, Label, User, Milestone, Comment,
|
||||
IssueState, Priority, IssueType, LabelCategories
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ These tests ensure the Gitea backend works correctly with the API.
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from issue_tracker.backends.gitea.backend import GiteaBackend, GiteaAPIError
|
||||
from issue_core.backends.gitea.backend import GiteaBackend, GiteaAPIError
|
||||
|
||||
|
||||
class TestGiteaBackend:
|
||||
@@ -32,7 +32,7 @@ class TestGiteaBackend:
|
||||
assert self.backend.repo is None
|
||||
assert self.backend.session is not None
|
||||
|
||||
@patch('issue_tracker.backends.gitea.backend.requests.Session')
|
||||
@patch('issue_core.backends.gitea.backend.requests.Session')
|
||||
def test_connect_success(self, mock_session_class):
|
||||
"""Test successful connection to Gitea API."""
|
||||
mock_session = MagicMock()
|
||||
@@ -61,7 +61,7 @@ class TestGiteaBackend:
|
||||
'Accept': 'application/json'
|
||||
})
|
||||
|
||||
@patch('issue_tracker.backends.gitea.backend.requests.Session')
|
||||
@patch('issue_core.backends.gitea.backend.requests.Session')
|
||||
def test_connect_failure(self, mock_session_class):
|
||||
"""Test failed connection raises appropriate error."""
|
||||
mock_session = MagicMock()
|
||||
@@ -96,7 +96,7 @@ class TestGiteaBackend:
|
||||
called_url = mock_request.call_args[1]['url'] if 'url' in mock_request.call_args[1] else mock_request.call_args[0][1]
|
||||
assert called_url == 'https://git.example.com/api/v1/repos/owner/repo'
|
||||
|
||||
@patch('issue_tracker.backends.gitea.backend.requests.Session')
|
||||
@patch('issue_core.backends.gitea.backend.requests.Session')
|
||||
def test_test_connection_success(self, mock_session_class):
|
||||
"""Test test_connection method works correctly."""
|
||||
mock_session = MagicMock()
|
||||
@@ -117,7 +117,7 @@ class TestGiteaBackend:
|
||||
result = backend.test_connection()
|
||||
assert result is True
|
||||
|
||||
@patch('issue_tracker.backends.gitea.backend.requests.Session')
|
||||
@patch('issue_core.backends.gitea.backend.requests.Session')
|
||||
def test_test_connection_failure(self, mock_session_class):
|
||||
"""Test test_connection handles failures gracefully."""
|
||||
mock_session = MagicMock()
|
||||
@@ -139,7 +139,7 @@ class TestGiteaBackend:
|
||||
result = backend.test_connection()
|
||||
assert result is False
|
||||
|
||||
@patch('issue_tracker.backends.gitea.backend.requests.Session')
|
||||
@patch('issue_core.backends.gitea.backend.requests.Session')
|
||||
def test_get_issue_success(self, mock_session_class):
|
||||
"""Test successful issue retrieval."""
|
||||
mock_session = MagicMock()
|
||||
|
||||
@@ -10,9 +10,9 @@ import tempfile
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from issue_tracker.backends.local.backend import LocalSQLiteBackend
|
||||
from issue_tracker.core.models import Issue, Label, User, Milestone, Comment, IssueState
|
||||
from issue_tracker.core.interfaces import IssueFilter
|
||||
from issue_core.backends.local.backend import LocalSQLiteBackend
|
||||
from issue_core.core.models import Issue, Label, User, Milestone, Comment, IssueState
|
||||
from issue_core.core.interfaces import IssueFilter
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
Reference in New Issue
Block a user