simplicio-sprint 1.0.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.
Files changed (43) hide show
  1. sendsprint/__init__.py +10 -0
  2. sendsprint/bootstrap.py +170 -0
  3. sendsprint/cli.py +333 -0
  4. sendsprint/credentials.py +132 -0
  5. sendsprint/delivery/__init__.py +20 -0
  6. sendsprint/delivery/evidence.py +138 -0
  7. sendsprint/delivery/git_ops.py +84 -0
  8. sendsprint/delivery/pr.py +187 -0
  9. sendsprint/delivery/worktree.py +74 -0
  10. sendsprint/executor/__init__.py +23 -0
  11. sendsprint/executor/simplicio.py +247 -0
  12. sendsprint/flow.py +419 -0
  13. sendsprint/github_integration.py +360 -0
  14. sendsprint/installer.py +143 -0
  15. sendsprint/llm/__init__.py +5 -0
  16. sendsprint/llm/client.py +175 -0
  17. sendsprint/logging_setup.py +87 -0
  18. sendsprint/mapper/__init__.py +15 -0
  19. sendsprint/mapper/adapter.py +283 -0
  20. sendsprint/models/__init__.py +59 -0
  21. sendsprint/models/reports.py +89 -0
  22. sendsprint/models/sprint.py +116 -0
  23. sendsprint/models/workspace.py +92 -0
  24. sendsprint/operators/__init__.py +15 -0
  25. sendsprint/operators/_mcp_bridge.py +79 -0
  26. sendsprint/operators/azure_devops_operator.py +261 -0
  27. sendsprint/operators/base.py +86 -0
  28. sendsprint/operators/github_issues_operator.py +186 -0
  29. sendsprint/operators/jira_operator.py +274 -0
  30. sendsprint/profile.py +138 -0
  31. sendsprint/prompt/__init__.py +14 -0
  32. sendsprint/prompt/fanout.py +182 -0
  33. sendsprint/scope.py +97 -0
  34. sendsprint/tech/__init__.py +5 -0
  35. sendsprint/tech/detector.py +288 -0
  36. sendsprint/watch.py +94 -0
  37. sendsprint/workspace/__init__.py +5 -0
  38. sendsprint/workspace/loader.py +69 -0
  39. simplicio_sprint-1.0.0.dist-info/METADATA +490 -0
  40. simplicio_sprint-1.0.0.dist-info/RECORD +43 -0
  41. simplicio_sprint-1.0.0.dist-info/WHEEL +4 -0
  42. simplicio_sprint-1.0.0.dist-info/entry_points.txt +2 -0
  43. simplicio_sprint-1.0.0.dist-info/licenses/LICENSE +21 -0
sendsprint/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """SendSprint — autonomous sprint-to-PR delivery agent.
2
+
3
+ The agent (Claude, driving this package) reads a sprint from Jira / Azure
4
+ DevOps / GitHub Issues, hands each task to simplicio-cli for the code edit,
5
+ captures evidence, and opens a draft PR for review. simplicio-cli is the
6
+ executor; SendSprint owns the flow start to finish.
7
+ """
8
+
9
+ __version__ = "3.0.0"
10
+ __all__ = ["__version__"]
@@ -0,0 +1,170 @@
1
+ """Keep the external simplicio tools current.
2
+
3
+ ``sendsprint update`` (and, per the runtime profile, the start of ``run`` /
4
+ ``watch``) pulls the latest simplicio-cli (pip), the simplicio-prompt kernel
5
+ (git), and optionally simplicio-mapper (git). Everything here is best-effort: a
6
+ stale or missing tool degrades to a skipped/failed :class:`UpdateResult`, never
7
+ an aborted run. Network can be disabled with ``SENDSPRINT_NO_UPDATE=1``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ Runner = Callable[..., subprocess.CompletedProcess[str]]
24
+
25
+ SIMPLICIO_PROMPT_REPO = "https://github.com/wesleysimplicio/simplicio-prompt"
26
+ SIMPLICIO_MAPPER_REPO = "https://github.com/wesleysimplicio/simplicio-mapper"
27
+ PROMPT_KERNEL_REL = Path("kernel/subagent_runtime.py")
28
+ DEFAULT_TIMEOUT_S = 300
29
+
30
+
31
+ def cache_dir() -> Path:
32
+ """Where SendSprint caches git-cloned tools (override: ``SENDSPRINT_CACHE_DIR``)."""
33
+ return Path(os.environ.get("SENDSPRINT_CACHE_DIR", "~/.cache/sendsprint")).expanduser()
34
+
35
+
36
+ def default_prompt_kernel() -> Path:
37
+ """Conventional path to the simplicio-prompt kernel inside the cache."""
38
+ return cache_dir() / "simplicio-prompt" / PROMPT_KERNEL_REL
39
+
40
+
41
+ @dataclass
42
+ class UpdateResult:
43
+ """Outcome of one tool update / check."""
44
+
45
+ name: str
46
+ status: str # "ok" | "skipped" | "failed"
47
+ detail: str = ""
48
+
49
+ def line(self) -> str:
50
+ return f"{self.name}: {self.status}" + (f" — {self.detail}" if self.detail else "")
51
+
52
+
53
+ @dataclass
54
+ class StartupReport:
55
+ """Aggregated results of an update/startup pass."""
56
+
57
+ results: list[UpdateResult] = field(default_factory=list)
58
+
59
+ @property
60
+ def ok(self) -> bool:
61
+ return all(r.status != "failed" for r in self.results)
62
+
63
+
64
+ class Updater:
65
+ """Run the tool updates. Subprocess calls go through an injectable runner."""
66
+
67
+ def __init__(
68
+ self,
69
+ *,
70
+ runner: Runner = subprocess.run,
71
+ python: str = sys.executable,
72
+ cache: Path | None = None,
73
+ timeout_s: int = DEFAULT_TIMEOUT_S,
74
+ ) -> None:
75
+ self._runner = runner
76
+ self.python = python
77
+ self.cache = Path(cache) if cache is not None else cache_dir()
78
+ self.timeout_s = timeout_s
79
+
80
+ def _run(self, argv: list[str]) -> subprocess.CompletedProcess[str]:
81
+ return self._runner(argv, capture_output=True, text=True, timeout=self.timeout_s)
82
+
83
+ # -- individual tools ---------------------------------------------------
84
+
85
+ def update_simplicio_cli(self) -> UpdateResult:
86
+ try:
87
+ proc = self._run([self.python, "-m", "pip", "install", "-U", "simplicio-cli"])
88
+ except (FileNotFoundError, subprocess.SubprocessError) as exc:
89
+ return UpdateResult("simplicio-cli", "failed", str(exc))
90
+ if proc.returncode != 0:
91
+ return UpdateResult("simplicio-cli", "failed", (proc.stderr or "").strip()[:200])
92
+ return UpdateResult("simplicio-cli", "ok", "pip install -U")
93
+
94
+ def update_simplicio_prompt(self) -> UpdateResult:
95
+ dest = self.cache / "simplicio-prompt"
96
+ res = self._git_sync("simplicio-prompt", SIMPLICIO_PROMPT_REPO, dest)
97
+ if res.status == "ok":
98
+ kernel = dest / PROMPT_KERNEL_REL
99
+ res.detail = f"{res.detail}; kernel at {kernel}"
100
+ os.environ.setdefault("SIMPLICIO_PROMPT_KERNEL", str(kernel))
101
+ return res
102
+
103
+ def update_simplicio_mapper(self) -> UpdateResult:
104
+ dest = self.cache / "simplicio-mapper"
105
+ return self._git_sync("simplicio-mapper", SIMPLICIO_MAPPER_REPO, dest)
106
+
107
+ # -- batches ------------------------------------------------------------
108
+
109
+ def update_all(
110
+ self, *, cli: bool = True, prompt: bool = True, mapper: bool = True
111
+ ) -> StartupReport:
112
+ report = StartupReport()
113
+ if cli:
114
+ report.results.append(self.update_simplicio_cli())
115
+ if prompt:
116
+ report.results.append(self.update_simplicio_prompt())
117
+ if mapper:
118
+ report.results.append(self.update_simplicio_mapper())
119
+ return report
120
+
121
+ def run_startup(self, profile: object) -> StartupReport:
122
+ """Run only the passes enabled by the runtime profile flags."""
123
+ runtime = getattr(profile, "runtime", None)
124
+ report = StartupReport()
125
+ if getattr(runtime, "verify_dependencies_on_start", False):
126
+ report.results.extend(self.verify_dependencies())
127
+ if getattr(runtime, "update_simplicio_prompt_on_start", False):
128
+ report.results.append(self.update_simplicio_prompt())
129
+ if getattr(runtime, "update_llm_project_mapper_on_start", False):
130
+ report.results.append(self.update_simplicio_mapper())
131
+ return report
132
+
133
+ def verify_dependencies(self) -> list[UpdateResult]:
134
+ results = [
135
+ _which("simplicio", "not installed (pip install simplicio-cli)"),
136
+ _which("git", "git not found on PATH", fail=True),
137
+ ]
138
+ kernel = Path(os.getenv("SIMPLICIO_PROMPT_KERNEL") or default_prompt_kernel())
139
+ results.append(
140
+ UpdateResult(
141
+ "simplicio-prompt kernel",
142
+ "ok" if kernel.exists() else "skipped",
143
+ str(kernel),
144
+ )
145
+ )
146
+ return results
147
+
148
+ # -- internals ----------------------------------------------------------
149
+
150
+ def _git_sync(self, name: str, repo_url: str, dest: Path) -> UpdateResult:
151
+ dest = Path(dest)
152
+ try:
153
+ if (dest / ".git").exists():
154
+ proc = self._run(["git", "-C", str(dest), "pull", "--ff-only"])
155
+ action = "pulled"
156
+ else:
157
+ dest.parent.mkdir(parents=True, exist_ok=True)
158
+ proc = self._run(["git", "clone", "--depth", "1", repo_url, str(dest)])
159
+ action = "cloned"
160
+ except (FileNotFoundError, subprocess.SubprocessError) as exc:
161
+ return UpdateResult(name, "failed", str(exc))
162
+ if proc.returncode != 0:
163
+ return UpdateResult(name, "failed", (proc.stderr or "").strip()[:200])
164
+ return UpdateResult(name, "ok", action)
165
+
166
+
167
+ def _which(binary: str, missing_detail: str, *, fail: bool = False) -> UpdateResult:
168
+ if shutil.which(binary):
169
+ return UpdateResult(binary, "ok", "on PATH")
170
+ return UpdateResult(binary, "failed" if fail else "skipped", missing_detail)
sendsprint/cli.py ADDED
@@ -0,0 +1,333 @@
1
+ """SendSprint CLI — read a sprint, let simplicio-cli execute, ship draft PRs.
2
+
3
+ SendSprint is the agent that owns the flow; simplicio-cli is the executor it
4
+ calls per task. Commands:
5
+
6
+ sendsprint version
7
+ sendsprint login jira|azuredevops|github
8
+ sendsprint logout jira|azuredevops|github
9
+ sendsprint run <source> <sprint> --repo PATH --scope mine
10
+ sendsprint watch <source> <sprint> --repo PATH --once # unattended trigger
11
+ """
12
+
13
+ # ruff: noqa: B008 - Typer's documented API uses Option/Argument call in defaults.
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ from pathlib import Path
20
+
21
+ import typer
22
+ from rich.console import Console
23
+
24
+ from sendsprint import __version__
25
+ from sendsprint import credentials as creds
26
+ from sendsprint.flow import RepoTarget, SprintFlow
27
+ from sendsprint.models import ScopeConfig
28
+ from sendsprint.operators import (
29
+ AzureDevopsOperator,
30
+ BaseOperator,
31
+ GitHubIssuesOperator,
32
+ JiraOperator,
33
+ )
34
+ from sendsprint.prompt import PromptFanout
35
+ from sendsprint.scope import build_scope
36
+ from sendsprint.watch import Watcher
37
+
38
+ app = typer.Typer(add_completion=False, help="Autonomous sprint-to-PR delivery.")
39
+ console = Console()
40
+ logger = logging.getLogger("sendsprint.cli")
41
+
42
+ SOURCES = ("jira", "azuredevops", "github")
43
+
44
+
45
+ @app.callback()
46
+ def _main(
47
+ log_level: str = typer.Option("INFO", "--log-level", help="DEBUG | INFO | WARNING | ERROR"),
48
+ log_file: Path | None = typer.Option(None, "--log-file", help="Override the log file path"),
49
+ log_json: bool = typer.Option(False, "--log-json", help="Write logs as JSON lines"),
50
+ ) -> None:
51
+ """Configure logging for every command (logs capture every step to a file)."""
52
+ from sendsprint.logging_setup import configure
53
+
54
+ path = configure(level=log_level, log_file=log_file, json_lines=log_json)
55
+ logger.debug("sendsprint %s — logging to %s", __version__, path)
56
+
57
+
58
+ @app.command()
59
+ def version() -> None:
60
+ """Print the SendSprint version."""
61
+ console.print(f"SendSprint {__version__}")
62
+
63
+
64
+ @app.command()
65
+ def login(provider: str) -> None:
66
+ """Store credentials for a source in the OS keyring (one-time)."""
67
+ provider = provider.lower()
68
+ if provider == "jira":
69
+ creds.get_or_prompt("jira", "JIRA_EMAIL", "JIRA_API_TOKEN", account_label="email")
70
+ elif provider == "azuredevops":
71
+ creds.get_or_prompt(
72
+ "azuredevops", "AZURE_DEVOPS_ORG", "AZURE_DEVOPS_PAT", account_label="organization"
73
+ )
74
+ elif provider == "github":
75
+ typer.echo("github uses the GITHUB_TOKEN environment variable; no keyring entry needed.")
76
+ else:
77
+ raise typer.BadParameter(f"provider must be one of {SOURCES}")
78
+
79
+
80
+ @app.command()
81
+ def logout(provider: str, account: str) -> None:
82
+ """Delete a stored credential."""
83
+ creds.delete_secret(provider, account) # type: ignore[arg-type]
84
+ console.print(f"removed {provider} credential for {account}")
85
+
86
+
87
+ @app.command()
88
+ def update(
89
+ cli: bool = typer.Option(True, help="Upgrade simplicio-cli via pip"),
90
+ prompt: bool = typer.Option(True, help="Sync the simplicio-prompt kernel via git"),
91
+ mapper: bool = typer.Option(True, help="Sync simplicio-mapper via git"),
92
+ ) -> None:
93
+ """Pull the latest of simplicio-cli / simplicio-prompt / simplicio-mapper."""
94
+ from sendsprint.bootstrap import Updater
95
+
96
+ report = Updater().update_all(cli=cli, prompt=prompt, mapper=mapper)
97
+ for result in report.results:
98
+ console.print(f" {result.line()}")
99
+ raise typer.Exit(code=0 if report.ok else 1)
100
+
101
+
102
+ @app.command()
103
+ def install(
104
+ target: list[str] = typer.Option(
105
+ [], "--target", "-t", help="Agent(s) to install for; repeatable"
106
+ ),
107
+ all_: bool = typer.Option(False, "--all", help="Install for every supported agent"),
108
+ repo: Path = typer.Option(Path("."), help="Target project directory"),
109
+ ) -> None:
110
+ """Install the SendSprint skill into each agent's convention (Cursor, Claude, ...)."""
111
+ from sendsprint.installer import TARGETS
112
+ from sendsprint.installer import install as do_install
113
+
114
+ names = sorted(TARGETS) if all_ or not target else target
115
+ try:
116
+ results = do_install(repo, names)
117
+ except ValueError as exc:
118
+ raise typer.BadParameter(str(exc)) from exc
119
+ for result in results:
120
+ console.print(f" {result.line()}")
121
+
122
+
123
+ @app.command()
124
+ def run(
125
+ source: str = typer.Argument(..., help="jira | azuredevops | github"),
126
+ sprint: str = typer.Argument(..., help="Jira sprint id, ADO iteration path, or GH milestone"),
127
+ repo: Path = typer.Option(Path("."), help="Path to the target git repo"),
128
+ scope: str = typer.Option("mine", help="mine | all"),
129
+ pr_provider: str = typer.Option("github", help="github | azuredevops"),
130
+ repo_slug: str = typer.Option("", help="owner/repo (github) or repository id (ado)"),
131
+ base: str = typer.Option("develop", help="PR target branch"),
132
+ tech: str | None = typer.Option(None, help="Override detected stack for simplicio --stack"),
133
+ test_command: str | None = typer.Option(None, help="Command to run for test evidence"),
134
+ frontend_url: str | None = typer.Option(None, help="URL to screenshot for screen evidence"),
135
+ draft: bool = typer.Option(True, help="Open PRs as drafts pending your review"),
136
+ specs: bool = typer.Option(True, help="Write simplicio-mapper .specs/ task files per card"),
137
+ fanout: bool = typer.Option(False, help="Run a simplicio-prompt subagent fan-out per card"),
138
+ fanout_subagents: int = typer.Option(600, help="Subagents per fan-out"),
139
+ fanout_provider: str = typer.Option("deepseek", help="simplicio-prompt provider"),
140
+ fanout_dry_run: bool = typer.Option(False, help="Run the fan-out offline (no key/network)"),
141
+ no_update: bool = typer.Option(False, help="Skip the start-up tool update (profile-driven)"),
142
+ output: Path | None = typer.Option(None, "-o", "--output", help="Write RunReport JSON"),
143
+ ) -> None:
144
+ """Deliver a sprint: each card → simplicio task → evidence → draft PR."""
145
+ _startup(no_update)
146
+ operator = _build_operator(source)
147
+ flow = _build_flow(
148
+ operator,
149
+ source=source,
150
+ repo=repo,
151
+ scope_mode=scope,
152
+ pr_provider=pr_provider,
153
+ repo_slug=repo_slug,
154
+ base=base,
155
+ tech=tech,
156
+ test_command=test_command,
157
+ frontend_url=frontend_url,
158
+ draft=draft,
159
+ write_specs=specs,
160
+ fanout=fanout,
161
+ fanout_subagents=fanout_subagents,
162
+ fanout_provider=fanout_provider,
163
+ fanout_dry_run=fanout_dry_run,
164
+ )
165
+ report = flow.run(**_read_kwargs(source, sprint, flow.scope))
166
+ console.print(f"[bold]{report.summary}[/bold]")
167
+ for pr in report.prs:
168
+ console.print(f" PR: {pr.url or pr.number} ({pr.state})")
169
+ _archive_report(report)
170
+ if output:
171
+ output.write_text(report.model_dump_json(indent=2), encoding="utf-8")
172
+ console.print(f"report written to {output}")
173
+ raise typer.Exit(code=1 if report.failed else 0)
174
+
175
+
176
+ @app.command()
177
+ def watch(
178
+ source: str = typer.Argument(..., help="jira | azuredevops | github"),
179
+ sprint: str = typer.Argument(..., help="Jira sprint id, ADO iteration path, or GH milestone"),
180
+ repo: Path = typer.Option(Path("."), help="Path to the target git repo"),
181
+ pr_provider: str = typer.Option("github", help="github | azuredevops"),
182
+ repo_slug: str = typer.Option("", help="owner/repo (github) or repository id (ado)"),
183
+ base: str = typer.Option("develop", help="PR target branch"),
184
+ tech: str | None = typer.Option(None, help="Override detected stack"),
185
+ test_command: str | None = typer.Option(None, help="Command to run for test evidence"),
186
+ interval: int = typer.Option(15, help="Minutes between cycles in loop mode"),
187
+ once: bool = typer.Option(False, help="Run a single cycle and exit (for cron/CI triggers)"),
188
+ max_per_cycle: int = typer.Option(1, help="Max cards delivered per cycle"),
189
+ no_update: bool = typer.Option(False, help="Skip the start-up tool update (profile-driven)"),
190
+ ) -> None:
191
+ """Unattended trigger: finish cards assigned to me, scoped with --scope mine."""
192
+ _startup(no_update)
193
+ operator = _build_operator(source)
194
+ flow = _build_flow(
195
+ operator,
196
+ source=source,
197
+ repo=repo,
198
+ scope_mode="mine",
199
+ pr_provider=pr_provider,
200
+ repo_slug=repo_slug,
201
+ base=base,
202
+ tech=tech,
203
+ test_command=test_command,
204
+ frontend_url=None,
205
+ draft=True,
206
+ )
207
+ watcher = Watcher(flow, interval_minutes=interval, max_per_cycle=max_per_cycle)
208
+ read_kwargs = _read_kwargs(source, sprint, flow.scope)
209
+ if once:
210
+ report = watcher.run_once(**read_kwargs)
211
+ console.print(f"[bold]{report.summary}[/bold]")
212
+ raise typer.Exit(code=1 if report.failed else 0)
213
+ console.print(f"watching {source} {sprint} every {interval}m (scope=mine). Ctrl-C to stop.")
214
+ watcher.loop(**read_kwargs)
215
+
216
+
217
+ # -- builders ---------------------------------------------------------------
218
+
219
+
220
+ def _startup(skip: bool) -> None:
221
+ """Run the profile-driven tool update before a delivery. Never fatal."""
222
+ if skip or os.getenv("SENDSPRINT_NO_UPDATE") == "1":
223
+ return
224
+ try:
225
+ from sendsprint import profile as profile_mod
226
+ from sendsprint.bootstrap import Updater
227
+
228
+ report = Updater().run_startup(profile_mod.load())
229
+ for result in report.results:
230
+ console.print(f" [dim]{result.line()}[/dim]")
231
+ except Exception as exc: # noqa: BLE001 — startup updates are best-effort
232
+ logger.warning("startup update skipped: %s", exc)
233
+
234
+
235
+ def _archive_report(report: object) -> None:
236
+ """Persist the run report JSON next to the logs. Best-effort."""
237
+ try:
238
+ from datetime import UTC, datetime
239
+
240
+ from sendsprint.logging_setup import log_dir
241
+
242
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
243
+ path = log_dir() / f"run-{stamp}.report.json"
244
+ path.parent.mkdir(parents=True, exist_ok=True)
245
+ path.write_text(report.model_dump_json(indent=2), encoding="utf-8") # type: ignore[attr-defined]
246
+ console.print(f"[dim]run report archived to {path}[/dim]")
247
+ except Exception as exc: # noqa: BLE001 — archiving is best-effort
248
+ logger.warning("could not archive run report: %s", exc)
249
+
250
+
251
+ def _build_operator(source: str) -> BaseOperator:
252
+ source = source.lower()
253
+ if source == "jira":
254
+ return JiraOperator()
255
+ if source == "azuredevops":
256
+ return AzureDevopsOperator()
257
+ if source == "github":
258
+ return GitHubIssuesOperator()
259
+ raise typer.BadParameter(f"source must be one of {SOURCES}")
260
+
261
+
262
+ def _build_flow(
263
+ operator: BaseOperator,
264
+ *,
265
+ source: str,
266
+ repo: Path,
267
+ scope_mode: str,
268
+ pr_provider: str,
269
+ repo_slug: str,
270
+ base: str,
271
+ tech: str | None,
272
+ test_command: str | None,
273
+ frontend_url: str | None,
274
+ draft: bool,
275
+ write_specs: bool = True,
276
+ fanout: bool = False,
277
+ fanout_subagents: int = 600,
278
+ fanout_provider: str = "deepseek",
279
+ fanout_dry_run: bool = False,
280
+ ) -> SprintFlow:
281
+ target = RepoTarget(
282
+ path=repo,
283
+ name=repo_slug or repo.name,
284
+ tech=tech,
285
+ test_command=test_command,
286
+ base_branch=base,
287
+ pr_provider=pr_provider,
288
+ repo_slug=repo_slug,
289
+ frontend_url=frontend_url,
290
+ )
291
+ scope = _build_scope(operator, scope_mode)
292
+ fan = (
293
+ PromptFanout(provider=fanout_provider, subagents=fanout_subagents, dry_run=fanout_dry_run)
294
+ if fanout
295
+ else None
296
+ )
297
+ return SprintFlow(
298
+ operator, target, scope=scope, draft_prs=draft, write_specs=write_specs, fanout=fan
299
+ )
300
+
301
+
302
+ def _build_scope(operator: BaseOperator, mode: str) -> ScopeConfig:
303
+ if mode != "mine":
304
+ return build_scope(mode="all")
305
+ user = operator.current_user() if hasattr(operator, "current_user") else {}
306
+ return build_scope(
307
+ mode="mine",
308
+ user_email=user.get("emailAddress") or user.get("email"),
309
+ user_account_id=user.get("accountId"),
310
+ user_descriptor=user.get("descriptor"),
311
+ user_display_name=user.get("displayName") or user.get("name") or user.get("login"),
312
+ )
313
+
314
+
315
+ def _read_kwargs(source: str, sprint: str, scope: ScopeConfig | None) -> dict[str, object]:
316
+ source = source.lower()
317
+ if source == "jira":
318
+ return {"sprint_id": sprint}
319
+ if source == "azuredevops":
320
+ return {"iteration_path": sprint}
321
+ # github: milestone + assignee login when scoping to me
322
+ kwargs: dict[str, object] = {"sprint_id": sprint}
323
+ if scope and scope.mode == "mine" and scope.user_display_name:
324
+ kwargs["assignee"] = scope.user_display_name
325
+ return kwargs
326
+
327
+
328
+ def main() -> None:
329
+ app()
330
+
331
+
332
+ if __name__ == "__main__":
333
+ main()
@@ -0,0 +1,132 @@
1
+ """Persistent credential store backed by the OS keyring.
2
+
3
+ First call to :func:`get_or_prompt` asks the user once; the value is then
4
+ stored in macOS Keychain / Linux Secret Service / Windows Credential Manager
5
+ via the ``keyring`` library and returned silently on subsequent calls.
6
+
7
+ No secret ever touches disk in plaintext. Profile metadata (non-secret prefs
8
+ like default provider / sprint id) lives in :mod:`sendsprint.profile`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import logging
15
+ import os
16
+ from typing import Literal
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ SERVICE = "sendsprint"
21
+ Provider = Literal["jira", "azuredevops"]
22
+ _VOLATILE_SECRETS: dict[str, str] = {}
23
+
24
+
25
+ class CredentialError(RuntimeError):
26
+ """Raised when credentials cannot be read or written."""
27
+
28
+
29
+ def _keyring():
30
+ try:
31
+ import keyring # type: ignore[import-not-found]
32
+ except ImportError as exc:
33
+ raise CredentialError(
34
+ "keyring not installed. run: pip install 'sendsprint[keyring]'"
35
+ ) from exc
36
+ return keyring
37
+
38
+
39
+ def _username(provider: Provider, account: str) -> str:
40
+ return f"{provider}:{account}"
41
+
42
+
43
+ def _cache_key(provider: Provider, account: str) -> str:
44
+ return _username(provider, account.strip())
45
+
46
+
47
+ def get_secret(provider: Provider, account: str) -> str | None:
48
+ """Return the stored secret for ``provider:account`` or ``None``."""
49
+ normalized = account.strip()
50
+ if not normalized:
51
+ return None
52
+ kr = _keyring()
53
+ try:
54
+ secret = kr.get_password(SERVICE, _username(provider, normalized))
55
+ except Exception as exc: # pragma: no cover - keyring backend errors
56
+ logger.warning("keyring read failed: %s", exc)
57
+ secret = None
58
+ if secret:
59
+ _VOLATILE_SECRETS[_cache_key(provider, normalized)] = secret
60
+ return secret
61
+ return _VOLATILE_SECRETS.get(_cache_key(provider, normalized))
62
+
63
+
64
+ def set_secret(provider: Provider, account: str, secret: str) -> None:
65
+ """Persist ``secret`` for ``provider:account`` in the OS keyring."""
66
+ normalized = account.strip()
67
+ if not normalized:
68
+ raise CredentialError("account is required")
69
+ _VOLATILE_SECRETS[_cache_key(provider, normalized)] = secret
70
+ kr = _keyring()
71
+ try:
72
+ kr.set_password(SERVICE, _username(provider, normalized), secret)
73
+ except Exception as exc:
74
+ raise CredentialError(f"keyring write failed: {exc}") from exc
75
+
76
+
77
+ def delete_secret(provider: Provider, account: str) -> None:
78
+ """Remove the stored secret. No-op if absent."""
79
+ normalized = account.strip()
80
+ if normalized:
81
+ _VOLATILE_SECRETS.pop(_cache_key(provider, normalized), None)
82
+ kr = _keyring()
83
+ with contextlib.suppress(Exception): # pragma: no cover - already gone
84
+ kr.delete_password(SERVICE, _username(provider, normalized))
85
+
86
+
87
+ def get_or_prompt(
88
+ provider: Provider,
89
+ account_env: str,
90
+ secret_env: str,
91
+ *,
92
+ account_label: str = "email/org",
93
+ secret_label: str = "API token",
94
+ interactive: bool = True,
95
+ ) -> tuple[str, str]:
96
+ """Resolve ``(account, secret)`` from env, then keyring, then prompt.
97
+
98
+ Lookup order:
99
+ 1. ``os.environ[account_env]`` + ``os.environ[secret_env]`` - used as-is,
100
+ and (if both present) persisted to keyring for next time.
101
+ 2. Keyring entry for ``provider`` keyed by the env-var account.
102
+ 3. Interactive prompt (only when ``interactive`` is true). Both values
103
+ are written to keyring on success.
104
+
105
+ Raises :class:`CredentialError` when non-interactive and nothing found.
106
+ """
107
+ account = os.environ.get(account_env)
108
+ secret_from_env = os.environ.get(secret_env)
109
+
110
+ if account and secret_from_env:
111
+ set_secret(provider, account, secret_from_env)
112
+ return account, secret_from_env
113
+
114
+ if account:
115
+ stored = get_secret(provider, account)
116
+ if stored:
117
+ return account, stored
118
+
119
+ if not interactive:
120
+ raise CredentialError(
121
+ f"missing credentials for {provider}: set {account_env} + {secret_env}"
122
+ f" or run 'sendsprint login {provider}' once."
123
+ )
124
+
125
+ import typer
126
+
127
+ if not account:
128
+ account = typer.prompt(f"{provider} {account_label}")
129
+ secret = typer.prompt(f"{provider} {secret_label}", hide_input=True)
130
+ set_secret(provider, account, secret)
131
+ typer.echo(f"saved {provider} credentials to keyring (account={account})")
132
+ return account, secret
@@ -0,0 +1,20 @@
1
+ """Delivery layer: worktree isolation, commits, evidence, pull requests.
2
+
3
+ SendSprint owns this whole layer. simplicio-cli only edits the working tree;
4
+ everything here — branching, committing, capturing evidence, opening the PR and
5
+ driving the review loop — is the agent's job.
6
+ """
7
+
8
+ from sendsprint.delivery.evidence import EvidenceCollector
9
+ from sendsprint.delivery.git_ops import GitError, GitOps
10
+ from sendsprint.delivery.pr import PullRequestManager
11
+ from sendsprint.delivery.worktree import WorktreeError, WorktreeManager
12
+
13
+ __all__ = [
14
+ "EvidenceCollector",
15
+ "GitError",
16
+ "GitOps",
17
+ "PullRequestManager",
18
+ "WorktreeError",
19
+ "WorktreeManager",
20
+ ]