symphony-linear 0.1.0__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.
- symphony_linear/__init__.py +3 -0
- symphony_linear/__main__.py +5 -0
- symphony_linear/cli.py +85 -0
- symphony_linear/config.py +198 -0
- symphony_linear/linear.py +571 -0
- symphony_linear/logging.py +32 -0
- symphony_linear/opencode.py +424 -0
- symphony_linear/orchestrator.py +1537 -0
- symphony_linear/project_config.py +192 -0
- symphony_linear/sandbox.py +192 -0
- symphony_linear/state.py +199 -0
- symphony_linear/workspace.py +511 -0
- symphony_linear-0.1.0.dist-info/METADATA +464 -0
- symphony_linear-0.1.0.dist-info/RECORD +17 -0
- symphony_linear-0.1.0.dist-info/WHEEL +4 -0
- symphony_linear-0.1.0.dist-info/entry_points.txt +2 -0
- symphony_linear-0.1.0.dist-info/licenses/LICENSE +661 -0
symphony_linear/cli.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""CLI entry-point for ``symphony-linear``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from symphony_linear.config import AppConfig, load_config
|
|
11
|
+
from symphony_linear.linear import LinearClient
|
|
12
|
+
from symphony_linear.logging import get_logger, setup_logging
|
|
13
|
+
from symphony_linear.orchestrator import Orchestrator
|
|
14
|
+
from symphony_linear.state import load_state
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="symphony-linear",
|
|
22
|
+
description="AI-powered ticket orchestration daemon",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--workspace",
|
|
26
|
+
type=str,
|
|
27
|
+
default=None,
|
|
28
|
+
help="Path to workspace directory (default: current working directory). "
|
|
29
|
+
"Expects config.yaml and state.json inside this directory.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--debug",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="Enable debug-level logging",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--validate-config",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="Load and validate the config file, then exit",
|
|
40
|
+
)
|
|
41
|
+
return parser
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv: list[str] | None = None) -> None:
|
|
45
|
+
"""Entry point for the ``symphony-linear`` CLI."""
|
|
46
|
+
parser = _build_parser()
|
|
47
|
+
args = parser.parse_args(argv)
|
|
48
|
+
|
|
49
|
+
setup_logging(debug=args.debug)
|
|
50
|
+
|
|
51
|
+
workspace = (
|
|
52
|
+
Path(args.workspace).expanduser().resolve()
|
|
53
|
+
if args.workspace
|
|
54
|
+
else Path.cwd().resolve()
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
config: AppConfig = load_config(workspace)
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
config_path = workspace / "config.yaml"
|
|
61
|
+
print(
|
|
62
|
+
f"Config file not found: {config_path}\n"
|
|
63
|
+
f"Create a config.yaml file in {workspace} with the required settings.",
|
|
64
|
+
file=sys.stderr,
|
|
65
|
+
)
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
logger.error("Config error: %s", exc)
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
if args.validate_config:
|
|
72
|
+
logger.info("Config is valid.")
|
|
73
|
+
logger.info(" workspace = %s", workspace)
|
|
74
|
+
logger.info(" poll_interval = %s s", config.poll_interval_seconds)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
# Load state and create the Linear client.
|
|
78
|
+
state = load_state(workspace)
|
|
79
|
+
linear = LinearClient(api_key=config.linear.api_key)
|
|
80
|
+
|
|
81
|
+
# Create and run the orchestrator daemon.
|
|
82
|
+
orchestrator = Orchestrator(
|
|
83
|
+
config=config, state=state, linear=linear, workspace=workspace
|
|
84
|
+
)
|
|
85
|
+
orchestrator.run()
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Typed configuration loader for symphony-lite.
|
|
2
|
+
|
|
3
|
+
Reads YAML from ``<workspace_dir>/config.yaml``, validates with Pydantic v2,
|
|
4
|
+
and expands ``~`` / ``$VAR`` references in string values.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Defaults
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
DEFAULT_HIDE_PATHS: list[str] = [
|
|
23
|
+
"~/.ssh",
|
|
24
|
+
"~/.gnupg",
|
|
25
|
+
"~/.aws",
|
|
26
|
+
"~/.config/gcloud",
|
|
27
|
+
"~/.netrc",
|
|
28
|
+
"~/.docker",
|
|
29
|
+
# Docker socket (real path; /var/run is a symlink to /run on most systems)
|
|
30
|
+
"/run/docker.sock",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Helpers: env-var / tilde expansion
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
_VAR_RE = re.compile(r"\$\{?(\w+)\}?")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _expand(value: str) -> str:
|
|
42
|
+
"""Expand ``~`` and ``$VAR`` / ``${VAR}`` references in *value*."""
|
|
43
|
+
# Tilde expansion
|
|
44
|
+
if value.startswith("~") and (len(value) == 1 or value[1] in ("/", os.sep)):
|
|
45
|
+
value = str(Path(value).expanduser())
|
|
46
|
+
|
|
47
|
+
# Env-var expansion
|
|
48
|
+
def _sub(m: re.Match[str]) -> str:
|
|
49
|
+
return os.environ.get(m.group(1), "")
|
|
50
|
+
|
|
51
|
+
return _VAR_RE.sub(_sub, value)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _expand_values(obj: Any) -> Any:
|
|
55
|
+
"""Recursively expand env vars in every string inside *obj*."""
|
|
56
|
+
if isinstance(obj, str):
|
|
57
|
+
return _expand(obj)
|
|
58
|
+
if isinstance(obj, dict):
|
|
59
|
+
return {k: _expand_values(v) for k, v in obj.items()}
|
|
60
|
+
if isinstance(obj, list):
|
|
61
|
+
return [_expand_values(v) for v in obj]
|
|
62
|
+
return obj
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Pydantic sub-models
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _LinearConfig(BaseModel):
|
|
71
|
+
api_key: str = Field(..., description="Linear API key (bearer token)")
|
|
72
|
+
trigger_label: str = Field("agent", description="Label that triggers the bot")
|
|
73
|
+
in_progress_state: str = Field(
|
|
74
|
+
"In Progress", description="Linear state for active work"
|
|
75
|
+
)
|
|
76
|
+
needs_input_state: str = Field(
|
|
77
|
+
"Needs Input", description="Linear state when input is needed"
|
|
78
|
+
)
|
|
79
|
+
qa_state: str | None = Field(
|
|
80
|
+
None,
|
|
81
|
+
description="Optional Linear state for QA; polled in addition to in_progress and needs_input",
|
|
82
|
+
)
|
|
83
|
+
bot_user_email: str = Field(..., description="Email of the bot user in Linear")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _SandboxConfig(BaseModel):
|
|
87
|
+
hide_paths: list[str] = Field(
|
|
88
|
+
default_factory=lambda: list(DEFAULT_HIDE_PATHS),
|
|
89
|
+
description="Paths to conceal inside the sandbox",
|
|
90
|
+
)
|
|
91
|
+
extra_rw_paths: list[str] = Field(
|
|
92
|
+
default_factory=list,
|
|
93
|
+
description="Additional host paths to bind read-write inside the sandbox",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AppConfig(BaseModel):
|
|
98
|
+
"""Top-level application configuration."""
|
|
99
|
+
|
|
100
|
+
model_config = ConfigDict(extra="forbid")
|
|
101
|
+
|
|
102
|
+
linear: _LinearConfig
|
|
103
|
+
sandbox: _SandboxConfig = Field(default_factory=_SandboxConfig)
|
|
104
|
+
poll_interval_seconds: int = Field(
|
|
105
|
+
30, gt=0, description="Seconds between Linear poll cycles"
|
|
106
|
+
)
|
|
107
|
+
turn_timeout_seconds: int = Field(1800, gt=0, description="Max seconds per AI turn")
|
|
108
|
+
auto_branch: bool = Field(
|
|
109
|
+
True,
|
|
110
|
+
description=(
|
|
111
|
+
"If true (default), switch the workspace to a per-ticket branch "
|
|
112
|
+
"during prepare() — Linear's branchName, or symphony/<id> as "
|
|
113
|
+
"fallback. If false, no branch switch runs and the workspace "
|
|
114
|
+
"stays on whatever git clone checked out (the remote default "
|
|
115
|
+
"branch). Useful when the agent commits straight to the default "
|
|
116
|
+
"branch and pushes are handled outside Symphony's scope."
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@model_validator(mode="before")
|
|
121
|
+
@classmethod
|
|
122
|
+
def _drop_null_subconfigs(cls, data: Any) -> Any:
|
|
123
|
+
# YAML keys with empty values (e.g. `sandbox:` on its own line) parse
|
|
124
|
+
# as ``None``. For sub-config fields that have a default, treat ``None``
|
|
125
|
+
# as "use the default" rather than failing validation.
|
|
126
|
+
if isinstance(data, dict):
|
|
127
|
+
if "sandbox" in data and data["sandbox"] is None:
|
|
128
|
+
del data["sandbox"]
|
|
129
|
+
return data
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Public API
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def load_config(workspace_dir: Path) -> AppConfig:
|
|
138
|
+
"""Load, expand, and validate the application configuration.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
workspace_dir: Path to the workspace directory containing
|
|
142
|
+
``config.yaml``.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
A fully-validated ``AppConfig`` instance.
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
FileNotFoundError: The config file does not exist.
|
|
149
|
+
ValueError: The config file contains invalid YAML or fails validation.
|
|
150
|
+
"""
|
|
151
|
+
path = workspace_dir / "config.yaml"
|
|
152
|
+
|
|
153
|
+
if not path.exists():
|
|
154
|
+
raise FileNotFoundError(
|
|
155
|
+
f"Config file not found: {path}. "
|
|
156
|
+
f"Create a config.yaml file in {workspace_dir} with the required settings."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
raw = yaml.safe_load(path.read_text())
|
|
161
|
+
except yaml.YAMLError as exc:
|
|
162
|
+
raise ValueError(f"Invalid YAML in {path}: {exc}") from exc
|
|
163
|
+
|
|
164
|
+
if raw is None:
|
|
165
|
+
raise ValueError(f"Config file is empty: {path}")
|
|
166
|
+
|
|
167
|
+
expanded = _expand_values(raw)
|
|
168
|
+
|
|
169
|
+
# LINEAR_API_KEY environment variable fallback: if linear.api_key is
|
|
170
|
+
# missing or empty in the YAML, fall back to the LINEAR_API_KEY env var.
|
|
171
|
+
if isinstance(expanded, dict):
|
|
172
|
+
linear = expanded.setdefault("linear", {})
|
|
173
|
+
if isinstance(linear, dict) and not linear.get("api_key", ""):
|
|
174
|
+
env_key = os.environ.get("LINEAR_API_KEY", "")
|
|
175
|
+
if env_key:
|
|
176
|
+
linear["api_key"] = env_key
|
|
177
|
+
else:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"Linear API key not set. Provide it in {path} "
|
|
180
|
+
f"(linear.api_key) or via the LINEAR_API_KEY "
|
|
181
|
+
f"environment variable."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
return AppConfig.model_validate(expanded)
|
|
186
|
+
except ValidationError as exc:
|
|
187
|
+
# Re-raise with a friendlier message that includes the file path.
|
|
188
|
+
msg = f"Config validation failed for {path}:\n{_format_errors(exc)}"
|
|
189
|
+
raise ValueError(msg) from exc
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _format_errors(exc: ValidationError) -> str:
|
|
193
|
+
"""Format Pydantic validation errors into a readable multi-line string."""
|
|
194
|
+
lines: list[str] = []
|
|
195
|
+
for err in exc.errors():
|
|
196
|
+
loc = " -> ".join(str(part) for part in err["loc"])
|
|
197
|
+
lines.append(f" {loc}: {err['msg']}")
|
|
198
|
+
return "\n".join(lines)
|