raise-cli 2.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- raise_cli/__init__.py +38 -0
- raise_cli/__main__.py +30 -0
- raise_cli/adapters/__init__.py +91 -0
- raise_cli/adapters/declarative/__init__.py +26 -0
- raise_cli/adapters/declarative/adapter.py +267 -0
- raise_cli/adapters/declarative/discovery.py +94 -0
- raise_cli/adapters/declarative/expressions.py +150 -0
- raise_cli/adapters/declarative/reference/__init__.py +1 -0
- raise_cli/adapters/declarative/reference/github.yaml +143 -0
- raise_cli/adapters/declarative/schema.py +98 -0
- raise_cli/adapters/filesystem.py +299 -0
- raise_cli/adapters/mcp_bridge.py +10 -0
- raise_cli/adapters/mcp_confluence.py +246 -0
- raise_cli/adapters/mcp_jira.py +405 -0
- raise_cli/adapters/models.py +205 -0
- raise_cli/adapters/protocols.py +180 -0
- raise_cli/adapters/registry.py +90 -0
- raise_cli/adapters/sync.py +149 -0
- raise_cli/agents/__init__.py +14 -0
- raise_cli/agents/antigravity.yaml +8 -0
- raise_cli/agents/claude.yaml +8 -0
- raise_cli/agents/copilot.yaml +8 -0
- raise_cli/agents/copilot_plugin.py +124 -0
- raise_cli/agents/cursor.yaml +7 -0
- raise_cli/agents/roo.yaml +8 -0
- raise_cli/agents/windsurf.yaml +8 -0
- raise_cli/artifacts/__init__.py +30 -0
- raise_cli/artifacts/models.py +43 -0
- raise_cli/artifacts/reader.py +55 -0
- raise_cli/artifacts/renderer.py +104 -0
- raise_cli/artifacts/story_design.py +69 -0
- raise_cli/artifacts/writer.py +45 -0
- raise_cli/backlog/__init__.py +1 -0
- raise_cli/backlog/sync.py +115 -0
- raise_cli/cli/__init__.py +3 -0
- raise_cli/cli/commands/__init__.py +3 -0
- raise_cli/cli/commands/_resolve.py +153 -0
- raise_cli/cli/commands/adapters.py +362 -0
- raise_cli/cli/commands/artifact.py +137 -0
- raise_cli/cli/commands/backlog.py +333 -0
- raise_cli/cli/commands/base.py +31 -0
- raise_cli/cli/commands/discover.py +551 -0
- raise_cli/cli/commands/docs.py +130 -0
- raise_cli/cli/commands/doctor.py +177 -0
- raise_cli/cli/commands/gate.py +223 -0
- raise_cli/cli/commands/graph.py +1086 -0
- raise_cli/cli/commands/info.py +81 -0
- raise_cli/cli/commands/init.py +746 -0
- raise_cli/cli/commands/journal.py +167 -0
- raise_cli/cli/commands/mcp.py +524 -0
- raise_cli/cli/commands/memory.py +467 -0
- raise_cli/cli/commands/pattern.py +348 -0
- raise_cli/cli/commands/profile.py +59 -0
- raise_cli/cli/commands/publish.py +80 -0
- raise_cli/cli/commands/release.py +338 -0
- raise_cli/cli/commands/session.py +528 -0
- raise_cli/cli/commands/signal.py +410 -0
- raise_cli/cli/commands/skill.py +350 -0
- raise_cli/cli/commands/skill_set.py +145 -0
- raise_cli/cli/error_handler.py +158 -0
- raise_cli/cli/main.py +163 -0
- raise_cli/compat.py +66 -0
- raise_cli/config/__init__.py +41 -0
- raise_cli/config/agent_plugin.py +105 -0
- raise_cli/config/agent_registry.py +233 -0
- raise_cli/config/agents.py +120 -0
- raise_cli/config/ide.py +32 -0
- raise_cli/config/paths.py +379 -0
- raise_cli/config/settings.py +180 -0
- raise_cli/context/__init__.py +42 -0
- raise_cli/context/analyzers/__init__.py +16 -0
- raise_cli/context/analyzers/models.py +36 -0
- raise_cli/context/analyzers/protocol.py +43 -0
- raise_cli/context/analyzers/python.py +292 -0
- raise_cli/context/builder.py +1569 -0
- raise_cli/context/diff.py +213 -0
- raise_cli/context/extractors/__init__.py +13 -0
- raise_cli/context/extractors/skills.py +121 -0
- raise_cli/core/__init__.py +37 -0
- raise_cli/core/files.py +66 -0
- raise_cli/core/text.py +174 -0
- raise_cli/core/tools.py +441 -0
- raise_cli/discovery/__init__.py +50 -0
- raise_cli/discovery/analyzer.py +691 -0
- raise_cli/discovery/drift.py +355 -0
- raise_cli/discovery/scanner.py +1687 -0
- raise_cli/doctor/__init__.py +4 -0
- raise_cli/doctor/checks/__init__.py +1 -0
- raise_cli/doctor/checks/environment.py +110 -0
- raise_cli/doctor/checks/project.py +238 -0
- raise_cli/doctor/fix.py +80 -0
- raise_cli/doctor/models.py +56 -0
- raise_cli/doctor/protocol.py +43 -0
- raise_cli/doctor/registry.py +100 -0
- raise_cli/doctor/report.py +141 -0
- raise_cli/doctor/runner.py +95 -0
- raise_cli/engines/__init__.py +3 -0
- raise_cli/exceptions.py +215 -0
- raise_cli/gates/__init__.py +19 -0
- raise_cli/gates/builtin/__init__.py +1 -0
- raise_cli/gates/builtin/coverage.py +52 -0
- raise_cli/gates/builtin/lint.py +48 -0
- raise_cli/gates/builtin/tests.py +48 -0
- raise_cli/gates/builtin/types.py +48 -0
- raise_cli/gates/models.py +40 -0
- raise_cli/gates/protocol.py +41 -0
- raise_cli/gates/registry.py +141 -0
- raise_cli/governance/__init__.py +11 -0
- raise_cli/governance/extractor.py +412 -0
- raise_cli/governance/models.py +134 -0
- raise_cli/governance/parsers/__init__.py +35 -0
- raise_cli/governance/parsers/_convert.py +38 -0
- raise_cli/governance/parsers/adr.py +274 -0
- raise_cli/governance/parsers/backlog.py +356 -0
- raise_cli/governance/parsers/constitution.py +119 -0
- raise_cli/governance/parsers/epic.py +323 -0
- raise_cli/governance/parsers/glossary.py +316 -0
- raise_cli/governance/parsers/guardrails.py +345 -0
- raise_cli/governance/parsers/prd.py +112 -0
- raise_cli/governance/parsers/roadmap.py +118 -0
- raise_cli/governance/parsers/vision.py +116 -0
- raise_cli/graph/__init__.py +1 -0
- raise_cli/graph/backends/__init__.py +57 -0
- raise_cli/graph/backends/api.py +137 -0
- raise_cli/graph/backends/dual.py +139 -0
- raise_cli/graph/backends/pending.py +84 -0
- raise_cli/handlers/__init__.py +3 -0
- raise_cli/hooks/__init__.py +54 -0
- raise_cli/hooks/builtin/__init__.py +1 -0
- raise_cli/hooks/builtin/backlog.py +216 -0
- raise_cli/hooks/builtin/gate_bridge.py +83 -0
- raise_cli/hooks/builtin/jira_sync.py +127 -0
- raise_cli/hooks/builtin/memory.py +117 -0
- raise_cli/hooks/builtin/telemetry.py +72 -0
- raise_cli/hooks/emitter.py +184 -0
- raise_cli/hooks/events.py +262 -0
- raise_cli/hooks/protocol.py +38 -0
- raise_cli/hooks/registry.py +117 -0
- raise_cli/mcp/__init__.py +33 -0
- raise_cli/mcp/bridge.py +218 -0
- raise_cli/mcp/models.py +43 -0
- raise_cli/mcp/registry.py +77 -0
- raise_cli/mcp/schema.py +41 -0
- raise_cli/memory/__init__.py +58 -0
- raise_cli/memory/loader.py +247 -0
- raise_cli/memory/migration.py +241 -0
- raise_cli/memory/models.py +169 -0
- raise_cli/memory/writer.py +598 -0
- raise_cli/onboarding/__init__.py +103 -0
- raise_cli/onboarding/bootstrap.py +324 -0
- raise_cli/onboarding/claudemd.py +17 -0
- raise_cli/onboarding/conventions.py +742 -0
- raise_cli/onboarding/detection.py +374 -0
- raise_cli/onboarding/governance.py +443 -0
- raise_cli/onboarding/instructions.py +672 -0
- raise_cli/onboarding/manifest.py +201 -0
- raise_cli/onboarding/memory_md.py +399 -0
- raise_cli/onboarding/migration.py +207 -0
- raise_cli/onboarding/profile.py +624 -0
- raise_cli/onboarding/skill_conflict.py +100 -0
- raise_cli/onboarding/skill_manifest.py +176 -0
- raise_cli/onboarding/skills.py +437 -0
- raise_cli/onboarding/workflows.py +101 -0
- raise_cli/output/__init__.py +28 -0
- raise_cli/output/console.py +394 -0
- raise_cli/output/formatters/__init__.py +9 -0
- raise_cli/output/formatters/adapters.py +135 -0
- raise_cli/output/formatters/discover.py +439 -0
- raise_cli/output/formatters/skill.py +298 -0
- raise_cli/publish/__init__.py +3 -0
- raise_cli/publish/changelog.py +80 -0
- raise_cli/publish/check.py +179 -0
- raise_cli/publish/version.py +172 -0
- raise_cli/rai_base/__init__.py +22 -0
- raise_cli/rai_base/framework/__init__.py +7 -0
- raise_cli/rai_base/framework/methodology.yaml +233 -0
- raise_cli/rai_base/governance/__init__.py +1 -0
- raise_cli/rai_base/governance/architecture/__init__.py +1 -0
- raise_cli/rai_base/governance/architecture/domain-model.md +20 -0
- raise_cli/rai_base/governance/architecture/system-context.md +34 -0
- raise_cli/rai_base/governance/architecture/system-design.md +24 -0
- raise_cli/rai_base/governance/backlog.md +8 -0
- raise_cli/rai_base/governance/guardrails.md +17 -0
- raise_cli/rai_base/governance/prd.md +25 -0
- raise_cli/rai_base/governance/vision.md +16 -0
- raise_cli/rai_base/identity/__init__.py +8 -0
- raise_cli/rai_base/identity/core.md +119 -0
- raise_cli/rai_base/identity/perspective.md +119 -0
- raise_cli/rai_base/memory/__init__.py +7 -0
- raise_cli/rai_base/memory/patterns-base.jsonl +55 -0
- raise_cli/schemas/__init__.py +3 -0
- raise_cli/schemas/journal.py +49 -0
- raise_cli/schemas/session_state.py +117 -0
- raise_cli/session/__init__.py +5 -0
- raise_cli/session/bundle.py +820 -0
- raise_cli/session/close.py +268 -0
- raise_cli/session/journal.py +119 -0
- raise_cli/session/resolver.py +126 -0
- raise_cli/session/state.py +187 -0
- raise_cli/skills/__init__.py +44 -0
- raise_cli/skills/locator.py +141 -0
- raise_cli/skills/name_checker.py +199 -0
- raise_cli/skills/parser.py +145 -0
- raise_cli/skills/scaffold.py +212 -0
- raise_cli/skills/schema.py +132 -0
- raise_cli/skills/skillsets.py +195 -0
- raise_cli/skills/validator.py +197 -0
- raise_cli/skills_base/__init__.py +80 -0
- raise_cli/skills_base/contract-template.md +60 -0
- raise_cli/skills_base/preamble.md +37 -0
- raise_cli/skills_base/rai-architecture-review/SKILL.md +137 -0
- raise_cli/skills_base/rai-debug/SKILL.md +171 -0
- raise_cli/skills_base/rai-discover/SKILL.md +167 -0
- raise_cli/skills_base/rai-discover-document/SKILL.md +128 -0
- raise_cli/skills_base/rai-discover-scan/SKILL.md +147 -0
- raise_cli/skills_base/rai-discover-start/SKILL.md +145 -0
- raise_cli/skills_base/rai-discover-validate/SKILL.md +142 -0
- raise_cli/skills_base/rai-docs-update/SKILL.md +142 -0
- raise_cli/skills_base/rai-doctor/SKILL.md +120 -0
- raise_cli/skills_base/rai-epic-close/SKILL.md +165 -0
- raise_cli/skills_base/rai-epic-close/templates/retrospective.md +68 -0
- raise_cli/skills_base/rai-epic-design/SKILL.md +146 -0
- raise_cli/skills_base/rai-epic-design/templates/design.md +24 -0
- raise_cli/skills_base/rai-epic-design/templates/scope.md +76 -0
- raise_cli/skills_base/rai-epic-plan/SKILL.md +153 -0
- raise_cli/skills_base/rai-epic-plan/_references/sequencing-strategies.md +67 -0
- raise_cli/skills_base/rai-epic-plan/templates/plan-section.md +49 -0
- raise_cli/skills_base/rai-epic-run/SKILL.md +208 -0
- raise_cli/skills_base/rai-epic-start/SKILL.md +136 -0
- raise_cli/skills_base/rai-epic-start/templates/brief.md +34 -0
- raise_cli/skills_base/rai-mcp-add/SKILL.md +176 -0
- raise_cli/skills_base/rai-mcp-remove/SKILL.md +120 -0
- raise_cli/skills_base/rai-mcp-status/SKILL.md +147 -0
- raise_cli/skills_base/rai-problem-shape/SKILL.md +138 -0
- raise_cli/skills_base/rai-project-create/SKILL.md +144 -0
- raise_cli/skills_base/rai-project-onboard/SKILL.md +162 -0
- raise_cli/skills_base/rai-quality-review/SKILL.md +189 -0
- raise_cli/skills_base/rai-research/SKILL.md +143 -0
- raise_cli/skills_base/rai-research/references/research-prompt-template.md +317 -0
- raise_cli/skills_base/rai-session-close/SKILL.md +176 -0
- raise_cli/skills_base/rai-session-start/SKILL.md +110 -0
- raise_cli/skills_base/rai-story-close/SKILL.md +198 -0
- raise_cli/skills_base/rai-story-design/SKILL.md +203 -0
- raise_cli/skills_base/rai-story-design/references/tech-design-story-v2.md +293 -0
- raise_cli/skills_base/rai-story-implement/SKILL.md +115 -0
- raise_cli/skills_base/rai-story-plan/SKILL.md +135 -0
- raise_cli/skills_base/rai-story-review/SKILL.md +178 -0
- raise_cli/skills_base/rai-story-run/SKILL.md +282 -0
- raise_cli/skills_base/rai-story-start/SKILL.md +166 -0
- raise_cli/skills_base/rai-story-start/templates/story.md +38 -0
- raise_cli/skills_base/rai-welcome/SKILL.md +134 -0
- raise_cli/telemetry/__init__.py +42 -0
- raise_cli/telemetry/schemas.py +285 -0
- raise_cli/telemetry/writer.py +217 -0
- raise_cli/tier/__init__.py +0 -0
- raise_cli/tier/context.py +134 -0
- raise_cli/viz/__init__.py +7 -0
- raise_cli/viz/generator.py +406 -0
- raise_cli-2.2.1.dist-info/METADATA +433 -0
- raise_cli-2.2.1.dist-info/RECORD +264 -0
- raise_cli-2.2.1.dist-info/WHEEL +4 -0
- raise_cli-2.2.1.dist-info/entry_points.txt +40 -0
- raise_cli-2.2.1.dist-info/licenses/LICENSE +190 -0
- raise_cli-2.2.1.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Graph backend implementations — CLI layer with env-var-based selection.
|
|
2
|
+
|
|
3
|
+
The factory `get_active_backend()` checks RAI_SERVER_URL + RAI_API_KEY
|
|
4
|
+
and returns DualWriteBackend when both are set, else FilesystemGraphBackend.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from raise_core.graph.backends.filesystem import FilesystemGraphBackend
|
|
14
|
+
from raise_core.graph.backends.protocol import KnowledgeGraphBackend
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
__all__ = ["get_active_backend"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_active_backend(path: Path) -> KnowledgeGraphBackend:
|
|
22
|
+
"""Resolve the active graph backend based on environment.
|
|
23
|
+
|
|
24
|
+
Returns DualWriteBackend when RAI_SERVER_URL and RAI_API_KEY are both
|
|
25
|
+
set. Falls back to FilesystemGraphBackend otherwise.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
path: Path to the graph JSON file (for local backend).
|
|
29
|
+
"""
|
|
30
|
+
server_url = os.environ.get("RAI_SERVER_URL", "").strip()
|
|
31
|
+
api_key = os.environ.get("RAI_API_KEY", "").strip()
|
|
32
|
+
|
|
33
|
+
if server_url and api_key:
|
|
34
|
+
from raise_cli.graph.backends.api import ApiGraphBackend
|
|
35
|
+
from raise_cli.graph.backends.dual import DualWriteBackend
|
|
36
|
+
|
|
37
|
+
project_id = Path.cwd().name
|
|
38
|
+
raise_dir = Path.cwd() / ".raise"
|
|
39
|
+
local = FilesystemGraphBackend(path=path)
|
|
40
|
+
remote = ApiGraphBackend(
|
|
41
|
+
server_url=server_url,
|
|
42
|
+
api_key=api_key,
|
|
43
|
+
project_id=project_id,
|
|
44
|
+
)
|
|
45
|
+
return DualWriteBackend(
|
|
46
|
+
local=local,
|
|
47
|
+
remote=remote,
|
|
48
|
+
raise_dir=raise_dir if raise_dir.exists() else None,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if server_url and not api_key:
|
|
52
|
+
logger.warning(
|
|
53
|
+
"RAI_SERVER_URL is set but RAI_API_KEY is missing. "
|
|
54
|
+
"Using local filesystem backend only."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return FilesystemGraphBackend(path=path)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""API-based graph backend — sends graph data to rai-server via HTTP.
|
|
2
|
+
|
|
3
|
+
PRO backend. Requires RAI_SERVER_URL and RAI_API_KEY.
|
|
4
|
+
Implements KnowledgeGraphBackend protocol from raise-core (ADR-036).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import httpx
|
|
14
|
+
except ModuleNotFoundError as exc:
|
|
15
|
+
raise ModuleNotFoundError(
|
|
16
|
+
"httpx is required for the API graph backend. "
|
|
17
|
+
"Install with: pip install 'raise-cli[dev]'"
|
|
18
|
+
) from exc
|
|
19
|
+
|
|
20
|
+
from raise_core.graph.backends.models import BackendHealth
|
|
21
|
+
from raise_core.graph.engine import Graph
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
__all__ = ["ApiGraphBackend"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApiGraphBackend:
|
|
29
|
+
"""HTTP client backend — persists graph to rai-server.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
server_url: Base URL of the rai-server (e.g. "http://localhost:8000").
|
|
33
|
+
api_key: API key for authentication (rsk_ prefix).
|
|
34
|
+
project_id: Project identifier for graph sync.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, server_url: str, api_key: str, project_id: str) -> None:
|
|
38
|
+
self.server_url = server_url
|
|
39
|
+
self.api_key = api_key
|
|
40
|
+
self.project_id = project_id
|
|
41
|
+
self._client = httpx.Client(
|
|
42
|
+
base_url=server_url,
|
|
43
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
44
|
+
timeout=httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
"""Close the underlying HTTP connection pool."""
|
|
49
|
+
self._client.close()
|
|
50
|
+
|
|
51
|
+
def persist(self, graph: Graph) -> None:
|
|
52
|
+
"""Send graph to server via POST /api/v1/graph/sync.
|
|
53
|
+
|
|
54
|
+
Serializes all nodes and edges from the Graph into the server's
|
|
55
|
+
expected sync payload format.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
graph: The graph to persist.
|
|
59
|
+
"""
|
|
60
|
+
skipped = 0
|
|
61
|
+
nodes: list[dict[str, Any]] = []
|
|
62
|
+
for node in graph.iter_concepts():
|
|
63
|
+
if not node.content:
|
|
64
|
+
skipped += 1
|
|
65
|
+
continue
|
|
66
|
+
nodes.append(
|
|
67
|
+
{
|
|
68
|
+
"node_id": node.id,
|
|
69
|
+
"node_type": node.type,
|
|
70
|
+
"scope": "project",
|
|
71
|
+
"content": node.content,
|
|
72
|
+
"source_file": node.source_file,
|
|
73
|
+
"properties": node.metadata,
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
if skipped:
|
|
77
|
+
logger.info("Skipped %d nodes with empty content", skipped)
|
|
78
|
+
edges: list[dict[str, Any]] = [
|
|
79
|
+
{
|
|
80
|
+
"source_node_id": edge.source,
|
|
81
|
+
"target_node_id": edge.target,
|
|
82
|
+
"edge_type": edge.type,
|
|
83
|
+
"weight": edge.weight,
|
|
84
|
+
"properties": edge.metadata,
|
|
85
|
+
}
|
|
86
|
+
for edge in graph.iter_relationships()
|
|
87
|
+
]
|
|
88
|
+
payload: dict[str, Any] = {
|
|
89
|
+
"project_id": self.project_id,
|
|
90
|
+
"nodes": nodes,
|
|
91
|
+
"edges": edges,
|
|
92
|
+
}
|
|
93
|
+
response = self._client.post(url="/api/v1/graph/sync", json=payload)
|
|
94
|
+
response.raise_for_status()
|
|
95
|
+
logger.info(
|
|
96
|
+
"Synced to remote server (%d nodes, %d edges)",
|
|
97
|
+
len(nodes),
|
|
98
|
+
len(edges),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def load(self) -> Graph:
|
|
102
|
+
"""Not supported — DualWriteBackend reads from local.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
NotImplementedError: Always. Use DualWriteBackend for load,
|
|
106
|
+
which delegates to local FilesystemGraphBackend.
|
|
107
|
+
"""
|
|
108
|
+
raise NotImplementedError(
|
|
109
|
+
"ApiGraphBackend.load() is not supported. "
|
|
110
|
+
"Use DualWriteBackend, which loads from local filesystem."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def health(self) -> BackendHealth:
|
|
114
|
+
"""Check server availability via GET /health.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
BackendHealth with status healthy or unavailable.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
response = self._client.get(url="/health")
|
|
121
|
+
if response.status_code == 200:
|
|
122
|
+
return BackendHealth(
|
|
123
|
+
status="healthy",
|
|
124
|
+
message="API server operational",
|
|
125
|
+
metadata={"backend": "api", "server_url": self.server_url},
|
|
126
|
+
)
|
|
127
|
+
return BackendHealth(
|
|
128
|
+
status="degraded",
|
|
129
|
+
message=f"Server returned {response.status_code}",
|
|
130
|
+
metadata={"backend": "api", "server_url": self.server_url},
|
|
131
|
+
)
|
|
132
|
+
except httpx.HTTPError as e:
|
|
133
|
+
return BackendHealth(
|
|
134
|
+
status="unavailable",
|
|
135
|
+
message=f"Connection failed: {e}",
|
|
136
|
+
metadata={"backend": "api", "server_url": self.server_url},
|
|
137
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Dual-write graph backend — local + remote with best-effort sync.
|
|
2
|
+
|
|
3
|
+
Writes to both FilesystemGraphBackend (local, always succeeds) and
|
|
4
|
+
ApiGraphBackend (remote, best-effort). Loads from local only.
|
|
5
|
+
|
|
6
|
+
Architecture: ADR-036 (KnowledgeGraphBackend)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from raise_core.graph.backends.models import BackendHealth
|
|
16
|
+
from raise_core.graph.backends.protocol import KnowledgeGraphBackend
|
|
17
|
+
from raise_core.graph.engine import Graph
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
__all__ = ["DualWriteBackend"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DualWriteBackend:
|
|
25
|
+
"""Dual-write backend — local always, remote best-effort.
|
|
26
|
+
|
|
27
|
+
Local is source of truth. Remote failures are logged as warnings,
|
|
28
|
+
never raised as exceptions. When raise_dir is provided, a pending_sync
|
|
29
|
+
marker is created on remote failure and cleared on success.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
local: Local backend (FilesystemGraphBackend).
|
|
33
|
+
remote: Remote backend (ApiGraphBackend).
|
|
34
|
+
raise_dir: Path to .raise/ directory for pending_sync marker.
|
|
35
|
+
If None, marker behavior is skipped.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
local: KnowledgeGraphBackend,
|
|
41
|
+
remote: KnowledgeGraphBackend,
|
|
42
|
+
raise_dir: Path | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.local = local
|
|
45
|
+
self.remote = remote
|
|
46
|
+
self._raise_dir = raise_dir
|
|
47
|
+
|
|
48
|
+
def persist(self, graph: Graph) -> None:
|
|
49
|
+
"""Persist to local first, then sync to remote (best-effort).
|
|
50
|
+
|
|
51
|
+
Local failure raises (critical). Remote failure logs warning
|
|
52
|
+
and creates a pending_sync marker if raise_dir is configured.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
graph: The graph to persist.
|
|
56
|
+
"""
|
|
57
|
+
self.local.persist(graph)
|
|
58
|
+
try:
|
|
59
|
+
self.remote.persist(graph)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
detail = ""
|
|
62
|
+
try:
|
|
63
|
+
import httpx
|
|
64
|
+
|
|
65
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
66
|
+
detail = f" Response: {e.response.text[:200]}"
|
|
67
|
+
except ImportError:
|
|
68
|
+
pass
|
|
69
|
+
logger.warning(
|
|
70
|
+
"Remote sync failed: %s.%s Graph saved locally only.", e, detail
|
|
71
|
+
)
|
|
72
|
+
self._write_marker(graph, str(e))
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Remote succeeded — clear any pending marker
|
|
76
|
+
self._clear_marker()
|
|
77
|
+
|
|
78
|
+
def _write_marker(self, graph: Graph, error: str) -> None:
|
|
79
|
+
"""Create pending_sync marker on remote failure."""
|
|
80
|
+
if self._raise_dir is None:
|
|
81
|
+
return
|
|
82
|
+
from raise_cli.graph.backends.pending import (
|
|
83
|
+
PendingSyncMarker,
|
|
84
|
+
write_pending_marker,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
marker = PendingSyncMarker(
|
|
88
|
+
timestamp=datetime.now(tz=UTC),
|
|
89
|
+
graph_path=str(getattr(self.local, "path", "unknown")),
|
|
90
|
+
node_count=graph.node_count,
|
|
91
|
+
edge_count=graph.edge_count,
|
|
92
|
+
error=error,
|
|
93
|
+
)
|
|
94
|
+
write_pending_marker(self._raise_dir, marker)
|
|
95
|
+
|
|
96
|
+
def _clear_marker(self) -> None:
|
|
97
|
+
"""Clear pending_sync marker on remote success."""
|
|
98
|
+
if self._raise_dir is None:
|
|
99
|
+
return
|
|
100
|
+
from raise_cli.graph.backends.pending import clear_pending_marker
|
|
101
|
+
|
|
102
|
+
clear_pending_marker(self._raise_dir)
|
|
103
|
+
|
|
104
|
+
def load(self) -> Graph:
|
|
105
|
+
"""Load from local backend (source of truth).
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Graph loaded from local filesystem.
|
|
109
|
+
"""
|
|
110
|
+
return self.local.load()
|
|
111
|
+
|
|
112
|
+
def health(self) -> BackendHealth:
|
|
113
|
+
"""Aggregate health from both backends.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
BackendHealth with combined status:
|
|
117
|
+
- healthy: both healthy
|
|
118
|
+
- degraded: local healthy, remote not
|
|
119
|
+
- unavailable: local not healthy
|
|
120
|
+
"""
|
|
121
|
+
local_health = self.local.health()
|
|
122
|
+
remote_health = self.remote.health()
|
|
123
|
+
|
|
124
|
+
if local_health.status != "healthy":
|
|
125
|
+
status = "unavailable"
|
|
126
|
+
elif remote_health.status != "healthy":
|
|
127
|
+
status = "degraded"
|
|
128
|
+
else:
|
|
129
|
+
status = "healthy"
|
|
130
|
+
|
|
131
|
+
return BackendHealth(
|
|
132
|
+
status=status,
|
|
133
|
+
message=f"local: {local_health.status}, remote: {remote_health.status}",
|
|
134
|
+
metadata={
|
|
135
|
+
"backend": "dual",
|
|
136
|
+
"local": local_health.status,
|
|
137
|
+
"remote": remote_health.status,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Pending sync marker — tracks failed remote writes for retry.
|
|
2
|
+
|
|
3
|
+
When DualWriteBackend fails to sync remotely, a marker file is created
|
|
4
|
+
in .raise/pending_sync.json. On next successful sync, the marker is cleared.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"PendingSyncMarker",
|
|
20
|
+
"write_pending_marker",
|
|
21
|
+
"read_pending_marker",
|
|
22
|
+
"clear_pending_marker",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
MARKER_FILENAME = "pending_sync.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PendingSyncMarker(BaseModel):
|
|
29
|
+
"""Marker for a failed remote graph sync."""
|
|
30
|
+
|
|
31
|
+
timestamp: datetime
|
|
32
|
+
graph_path: str
|
|
33
|
+
node_count: int
|
|
34
|
+
edge_count: int
|
|
35
|
+
error: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_pending_marker(raise_dir: Path, marker: PendingSyncMarker) -> None:
|
|
39
|
+
"""Write pending_sync.json to .raise/ directory.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
raise_dir: Path to .raise/ directory.
|
|
43
|
+
marker: The marker data to write.
|
|
44
|
+
"""
|
|
45
|
+
path = raise_dir / MARKER_FILENAME
|
|
46
|
+
path.write_text(marker.model_dump_json(indent=2))
|
|
47
|
+
logger.warning("Remote sync failed, marked as pending: %s", path)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def read_pending_marker(raise_dir: Path) -> PendingSyncMarker | None:
|
|
51
|
+
"""Read pending_sync.json, return None if not present or corrupt.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
raise_dir: Path to .raise/ directory.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
PendingSyncMarker if file exists and is valid, None otherwise.
|
|
58
|
+
"""
|
|
59
|
+
path = raise_dir / MARKER_FILENAME
|
|
60
|
+
if not path.exists():
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads(path.read_text())
|
|
64
|
+
return PendingSyncMarker.model_validate(data)
|
|
65
|
+
except (json.JSONDecodeError, ValueError):
|
|
66
|
+
logger.warning("Corrupt pending_sync marker at %s, ignoring", path)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def clear_pending_marker(raise_dir: Path) -> bool:
|
|
71
|
+
"""Delete pending_sync.json.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
raise_dir: Path to .raise/ directory.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True if file existed and was deleted, False otherwise.
|
|
78
|
+
"""
|
|
79
|
+
path = raise_dir / MARKER_FILENAME
|
|
80
|
+
if path.exists():
|
|
81
|
+
path.unlink()
|
|
82
|
+
logger.info("Cleared pending sync marker — remote sync successful")
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Lifecycle hooks infrastructure for raise-cli.
|
|
2
|
+
|
|
3
|
+
Provides typed event infrastructure for cross-cutting concerns (telemetry,
|
|
4
|
+
notifications, compliance) to react to CLI operations without coupling
|
|
5
|
+
to skill content.
|
|
6
|
+
|
|
7
|
+
Architecture: ADR-039 (Lifecycle Hooks & Workflow Gates)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from raise_cli.hooks.emitter import EventEmitter, create_emitter
|
|
11
|
+
from raise_cli.hooks.events import (
|
|
12
|
+
AdapterFailedEvent,
|
|
13
|
+
AdapterLoadedEvent,
|
|
14
|
+
BeforeReleasePublishEvent,
|
|
15
|
+
BeforeSessionCloseEvent,
|
|
16
|
+
DiscoverScanEvent,
|
|
17
|
+
EmitResult,
|
|
18
|
+
GraphBuildEvent,
|
|
19
|
+
HookEvent,
|
|
20
|
+
HookResult,
|
|
21
|
+
InitCompleteEvent,
|
|
22
|
+
PatternAddedEvent,
|
|
23
|
+
ReleasePublishEvent,
|
|
24
|
+
SessionCloseEvent,
|
|
25
|
+
SessionStartEvent,
|
|
26
|
+
)
|
|
27
|
+
from raise_cli.hooks.protocol import LifecycleHook
|
|
28
|
+
from raise_cli.hooks.registry import HookRegistry
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# Base types
|
|
32
|
+
"HookEvent",
|
|
33
|
+
"HookResult",
|
|
34
|
+
"EmitResult",
|
|
35
|
+
# Protocol + Registry
|
|
36
|
+
"LifecycleHook",
|
|
37
|
+
"HookRegistry",
|
|
38
|
+
# Emitter
|
|
39
|
+
"EventEmitter",
|
|
40
|
+
"create_emitter",
|
|
41
|
+
# After-events (9)
|
|
42
|
+
"SessionStartEvent",
|
|
43
|
+
"SessionCloseEvent",
|
|
44
|
+
"GraphBuildEvent",
|
|
45
|
+
"PatternAddedEvent",
|
|
46
|
+
"DiscoverScanEvent",
|
|
47
|
+
"InitCompleteEvent",
|
|
48
|
+
"AdapterLoadedEvent",
|
|
49
|
+
"AdapterFailedEvent",
|
|
50
|
+
"ReleasePublishEvent",
|
|
51
|
+
# Before-events (2)
|
|
52
|
+
"BeforeSessionCloseEvent",
|
|
53
|
+
"BeforeReleasePublishEvent",
|
|
54
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Built-in lifecycle hooks for raise-cli (COMMUNITY tier)."""
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Built-in BacklogHook — syncs backlog state on work lifecycle events.
|
|
2
|
+
|
|
3
|
+
Listens to ``work:lifecycle`` events (fired by ``rai signal emit-work``)
|
|
4
|
+
and calls the resolved ProjectManagementAdapter to create/transition issues.
|
|
5
|
+
|
|
6
|
+
Error isolation: ``handle()`` never raises. Adapter or config failures are
|
|
7
|
+
logged and returned as ``HookResult(status="error")``.
|
|
8
|
+
|
|
9
|
+
Architecture: ADR-039 §5 (Built-in hooks), S325.4, S347.4
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from raise_cli.adapters.protocols import ProjectManagementAdapter
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
from raise_cli.adapters.models import IssueSpec
|
|
24
|
+
from raise_cli.hooks.events import HookEvent, HookResult, WorkLifecycleEvent
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Events that trigger backlog actions
|
|
29
|
+
_ACTIONABLE_EVENTS = frozenset({"start", "complete"})
|
|
30
|
+
|
|
31
|
+
# Map (work_type, event) → lifecycle_mapping key
|
|
32
|
+
_LIFECYCLE_KEY_MAP: dict[tuple[str, str], str] = {
|
|
33
|
+
("story", "start"): "story_start",
|
|
34
|
+
("story", "complete"): "story_close",
|
|
35
|
+
("epic", "start"): "epic_start",
|
|
36
|
+
("epic", "complete"): "epic_close",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Map lifecycle_mapping key → target status for rai backlog transition
|
|
40
|
+
_STATUS_MAP: dict[str, str] = {
|
|
41
|
+
"story_start": "in-progress",
|
|
42
|
+
"story_close": "done",
|
|
43
|
+
"epic_start": "in-progress",
|
|
44
|
+
"epic_close": "done",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _JiraConfig:
|
|
49
|
+
"""Parsed jira.yaml config relevant to BacklogHook."""
|
|
50
|
+
|
|
51
|
+
__slots__ = ("project_key", "lifecycle_mapping")
|
|
52
|
+
|
|
53
|
+
def __init__(self, project_key: str, lifecycle_mapping: dict[str, int]) -> None:
|
|
54
|
+
self.project_key = project_key
|
|
55
|
+
self.lifecycle_mapping = lifecycle_mapping
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_jira_config(project_root: Path) -> _JiraConfig | None:
|
|
59
|
+
"""Load project key and lifecycle_mapping from .raise/jira.yaml.
|
|
60
|
+
|
|
61
|
+
Returns None if file missing, unparseable, or missing required sections.
|
|
62
|
+
"""
|
|
63
|
+
config_path = project_root / ".raise" / "jira.yaml"
|
|
64
|
+
if not config_path.exists():
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
with open(config_path) as f:
|
|
68
|
+
data: dict[str, Any] = yaml.safe_load(f)
|
|
69
|
+
workflow = data.get("workflow", {})
|
|
70
|
+
mapping: dict[str, int] | None = workflow.get("lifecycle_mapping")
|
|
71
|
+
if mapping is None:
|
|
72
|
+
return None
|
|
73
|
+
projects = data.get("projects", {})
|
|
74
|
+
project_key = next(iter(projects), None) if projects else None
|
|
75
|
+
if project_key is None:
|
|
76
|
+
return None
|
|
77
|
+
return _JiraConfig(project_key=project_key, lifecycle_mapping=mapping)
|
|
78
|
+
except Exception: # noqa: BLE001
|
|
79
|
+
logger.warning("Failed to parse .raise/jira.yaml")
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _resolve_issue_key(
|
|
84
|
+
adapter: ProjectManagementAdapter, work_id: str, project_key: str
|
|
85
|
+
) -> str | None:
|
|
86
|
+
"""Search for a backlog issue by work_id.
|
|
87
|
+
|
|
88
|
+
Strategy depends on adapter type:
|
|
89
|
+
- FilesystemPMAdapter: direct key lookup (no JQL)
|
|
90
|
+
- Jira/MCP: label-first (``rai:{work_id}``), then summary fallback
|
|
91
|
+
|
|
92
|
+
Returns the issue key if found, None otherwise.
|
|
93
|
+
"""
|
|
94
|
+
# Import inside function to avoid circular imports
|
|
95
|
+
from raise_cli.adapters.filesystem import FilesystemPMAdapter
|
|
96
|
+
|
|
97
|
+
if isinstance(adapter, FilesystemPMAdapter):
|
|
98
|
+
# FileAdapter: direct key lookup, no JQL
|
|
99
|
+
results = adapter.search(work_id, limit=1)
|
|
100
|
+
return results[0].key if results else None
|
|
101
|
+
|
|
102
|
+
# Jira/MCP: label-first search
|
|
103
|
+
label_query = f'project = "{project_key}" AND labels = "rai:{work_id}"'
|
|
104
|
+
results = adapter.search(label_query, limit=1)
|
|
105
|
+
if results:
|
|
106
|
+
return results[0].key
|
|
107
|
+
|
|
108
|
+
# Summary fallback with warning
|
|
109
|
+
logger.warning("No label match for %s — falling back to summary search", work_id)
|
|
110
|
+
summary_query = f'project = "{project_key}" AND summary ~ "{work_id}"'
|
|
111
|
+
results = adapter.search(summary_query, limit=1)
|
|
112
|
+
if results:
|
|
113
|
+
return results[0].key
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_adapter() -> ProjectManagementAdapter:
|
|
118
|
+
"""Resolve ProjectManagementAdapter via manifest default. Separated for testability."""
|
|
119
|
+
from raise_cli.cli.commands._resolve import (
|
|
120
|
+
resolve_adapter as _resolve,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return _resolve(None)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class BacklogHook:
|
|
127
|
+
"""Syncs backlog state on work lifecycle events.
|
|
128
|
+
|
|
129
|
+
Subscribes to ``work:lifecycle`` events and maps them to
|
|
130
|
+
``rai backlog`` actions using ``.raise/jira.yaml`` lifecycle_mapping.
|
|
131
|
+
|
|
132
|
+
Registered via ``rai.hooks`` entry point in pyproject.toml.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
events: ClassVar[list[str]] = ["work:lifecycle"]
|
|
136
|
+
priority: ClassVar[int] = 0
|
|
137
|
+
timeout: ClassVar[float] = 30.0 # MCP bridge cold start needs >5s
|
|
138
|
+
|
|
139
|
+
def __init__(self, project_root: Path | None = None) -> None:
|
|
140
|
+
self._project_root = project_root or Path(".")
|
|
141
|
+
|
|
142
|
+
def handle(self, event: HookEvent) -> HookResult:
|
|
143
|
+
"""Handle a work lifecycle event by syncing Jira.
|
|
144
|
+
|
|
145
|
+
Never raises — returns HookResult with status and message.
|
|
146
|
+
"""
|
|
147
|
+
if not isinstance(event, WorkLifecycleEvent):
|
|
148
|
+
return HookResult(status="ok")
|
|
149
|
+
|
|
150
|
+
# Only act on start/complete
|
|
151
|
+
if event.event not in _ACTIONABLE_EVENTS:
|
|
152
|
+
return HookResult(status="ok")
|
|
153
|
+
|
|
154
|
+
# Load jira config
|
|
155
|
+
config = _load_jira_config(self._project_root)
|
|
156
|
+
if config is None:
|
|
157
|
+
return HookResult(
|
|
158
|
+
status="error", message="no jira.yaml or lifecycle_mapping"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Determine lifecycle key
|
|
162
|
+
lifecycle_key = _LIFECYCLE_KEY_MAP.get((event.work_type, event.event))
|
|
163
|
+
if lifecycle_key is None:
|
|
164
|
+
return HookResult(status="ok")
|
|
165
|
+
|
|
166
|
+
# Resolve adapter
|
|
167
|
+
try:
|
|
168
|
+
adapter = resolve_adapter()
|
|
169
|
+
except Exception as exc: # noqa: BLE001
|
|
170
|
+
return HookResult(status="error", message=f"adapter unavailable: {exc}")
|
|
171
|
+
|
|
172
|
+
# Resolve Jira key
|
|
173
|
+
try:
|
|
174
|
+
jira_key = _resolve_issue_key(adapter, event.work_id, config.project_key)
|
|
175
|
+
except Exception as exc: # noqa: BLE001
|
|
176
|
+
return HookResult(status="error", message=f"search failed: {exc}")
|
|
177
|
+
|
|
178
|
+
target_status = _STATUS_MAP[lifecycle_key]
|
|
179
|
+
|
|
180
|
+
# Handle start: create if missing, then transition
|
|
181
|
+
if event.event == "start":
|
|
182
|
+
if jira_key is None:
|
|
183
|
+
try:
|
|
184
|
+
issue_type = "Epic" if event.work_type == "epic" else "Story"
|
|
185
|
+
spec = IssueSpec(
|
|
186
|
+
summary=f"{event.work_id}",
|
|
187
|
+
issue_type=issue_type,
|
|
188
|
+
labels=[f"rai:{event.work_id}"],
|
|
189
|
+
)
|
|
190
|
+
ref = adapter.create_issue(config.project_key, spec)
|
|
191
|
+
jira_key = ref.key
|
|
192
|
+
except Exception as exc: # noqa: BLE001
|
|
193
|
+
return HookResult(status="error", message=f"create failed: {exc}")
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
adapter.transition_issue(jira_key, target_status)
|
|
197
|
+
except Exception as exc: # noqa: BLE001
|
|
198
|
+
return HookResult(status="error", message=f"transition failed: {exc}")
|
|
199
|
+
|
|
200
|
+
return HookResult(status="ok")
|
|
201
|
+
|
|
202
|
+
# Handle complete: transition existing issue
|
|
203
|
+
if event.event == "complete":
|
|
204
|
+
if jira_key is None:
|
|
205
|
+
return HookResult(
|
|
206
|
+
status="error", message=f"no Jira issue found for {event.work_id}"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
adapter.transition_issue(jira_key, target_status)
|
|
211
|
+
except Exception as exc: # noqa: BLE001
|
|
212
|
+
return HookResult(status="error", message=f"transition failed: {exc}")
|
|
213
|
+
|
|
214
|
+
return HookResult(status="ok")
|
|
215
|
+
|
|
216
|
+
return HookResult(status="ok")
|