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.
@@ -0,0 +1,3 @@
1
+ """symphony-lite: AI-powered ticket orchestration daemon."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as ``python -m symphony_linear``."""
2
+
3
+ from symphony_linear.cli import main
4
+
5
+ main()
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)