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.
- sendsprint/__init__.py +10 -0
- sendsprint/bootstrap.py +170 -0
- sendsprint/cli.py +333 -0
- sendsprint/credentials.py +132 -0
- sendsprint/delivery/__init__.py +20 -0
- sendsprint/delivery/evidence.py +138 -0
- sendsprint/delivery/git_ops.py +84 -0
- sendsprint/delivery/pr.py +187 -0
- sendsprint/delivery/worktree.py +74 -0
- sendsprint/executor/__init__.py +23 -0
- sendsprint/executor/simplicio.py +247 -0
- sendsprint/flow.py +419 -0
- sendsprint/github_integration.py +360 -0
- sendsprint/installer.py +143 -0
- sendsprint/llm/__init__.py +5 -0
- sendsprint/llm/client.py +175 -0
- sendsprint/logging_setup.py +87 -0
- sendsprint/mapper/__init__.py +15 -0
- sendsprint/mapper/adapter.py +283 -0
- sendsprint/models/__init__.py +59 -0
- sendsprint/models/reports.py +89 -0
- sendsprint/models/sprint.py +116 -0
- sendsprint/models/workspace.py +92 -0
- sendsprint/operators/__init__.py +15 -0
- sendsprint/operators/_mcp_bridge.py +79 -0
- sendsprint/operators/azure_devops_operator.py +261 -0
- sendsprint/operators/base.py +86 -0
- sendsprint/operators/github_issues_operator.py +186 -0
- sendsprint/operators/jira_operator.py +274 -0
- sendsprint/profile.py +138 -0
- sendsprint/prompt/__init__.py +14 -0
- sendsprint/prompt/fanout.py +182 -0
- sendsprint/scope.py +97 -0
- sendsprint/tech/__init__.py +5 -0
- sendsprint/tech/detector.py +288 -0
- sendsprint/watch.py +94 -0
- sendsprint/workspace/__init__.py +5 -0
- sendsprint/workspace/loader.py +69 -0
- simplicio_sprint-1.0.0.dist-info/METADATA +490 -0
- simplicio_sprint-1.0.0.dist-info/RECORD +43 -0
- simplicio_sprint-1.0.0.dist-info/WHEEL +4 -0
- simplicio_sprint-1.0.0.dist-info/entry_points.txt +2 -0
- 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__"]
|
sendsprint/bootstrap.py
ADDED
|
@@ -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
|
+
]
|