sendsprint 0.7.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.
- sendsprint/__init__.py +4 -0
- sendsprint/agentic_starter.py +281 -0
- sendsprint/agents/__init__.py +23 -0
- sendsprint/agents/dev.py +105 -0
- sendsprint/agents/lint_runner.py +91 -0
- sendsprint/agents/pr_body_builder.py +122 -0
- sendsprint/agents/pr_creator.py +162 -0
- sendsprint/agents/pr_reviewer.py +104 -0
- sendsprint/agents/security_reviewer.py +246 -0
- sendsprint/agents/sprint_importer.py +194 -0
- sendsprint/agents/test_runner.py +150 -0
- sendsprint/agents/worktree.py +72 -0
- sendsprint/api/README.md +106 -0
- sendsprint/api/__init__.py +10 -0
- sendsprint/api/__main__.py +6 -0
- sendsprint/api/assets/__init__.py +6 -0
- sendsprint/api/assets/screenshots/coverage.png +0 -0
- sendsprint/api/assets/screenshots/dashboard.png +0 -0
- sendsprint/api/assets/screenshots/login.png +0 -0
- sendsprint/api/assets/screenshots/regression-diff.png +0 -0
- sendsprint/api/assets/screenshots/regression-fail.png +0 -0
- sendsprint/api/assets/screenshots/regression-pass.png +0 -0
- sendsprint/api/routes/__init__.py +1 -0
- sendsprint/api/routes/auth.py +86 -0
- sendsprint/api/routes/runs.py +72 -0
- sendsprint/api/routes/sprints.py +226 -0
- sendsprint/api/runs/__init__.py +5 -0
- sendsprint/api/runs/bridge.py +354 -0
- sendsprint/api/runs/events.py +45 -0
- sendsprint/api/runs/manager.py +87 -0
- sendsprint/api/schemas.py +120 -0
- sendsprint/api/server.py +74 -0
- sendsprint/architecture/__init__.py +6 -0
- sendsprint/architecture/builder.py +308 -0
- sendsprint/architecture/mapper.py +122 -0
- sendsprint/cli.py +539 -0
- sendsprint/credentials.py +113 -0
- sendsprint/flow/__init__.py +5 -0
- sendsprint/flow/sprint_flow.py +515 -0
- sendsprint/llm/__init__.py +5 -0
- sendsprint/llm/client.py +174 -0
- sendsprint/models/__init__.py +51 -0
- sendsprint/models/reports.py +84 -0
- sendsprint/models/sprint.py +112 -0
- sendsprint/models/workspace.py +64 -0
- sendsprint/operators/__init__.py +13 -0
- sendsprint/operators/azure_devops_operator.py +257 -0
- sendsprint/operators/base.py +65 -0
- sendsprint/operators/jira_operator.py +275 -0
- sendsprint/profile.py +108 -0
- sendsprint/scaffolder.py +309 -0
- sendsprint/scope.py +99 -0
- sendsprint/tech/__init__.py +5 -0
- sendsprint/tech/detector.py +238 -0
- sendsprint/workspace/__init__.py +5 -0
- sendsprint/workspace/loader.py +65 -0
- sendsprint-0.7.1.dist-info/METADATA +350 -0
- sendsprint-0.7.1.dist-info/RECORD +61 -0
- sendsprint-0.7.1.dist-info/WHEEL +4 -0
- sendsprint-0.7.1.dist-info/entry_points.txt +3 -0
- sendsprint-0.7.1.dist-info/licenses/LICENSE +21 -0
sendsprint/__init__.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Sync the agentic-starter scaffold into a repository.
|
|
2
|
+
|
|
3
|
+
The sync is intentionally file-based and conservative: existing files are
|
|
4
|
+
preserved unless ``force=True``. Scheduled automation can run the same command
|
|
5
|
+
and open a PR for review instead of mutating ``main`` directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import tempfile
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
import zipfile
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
DEFAULT_AGENTIC_STARTER_SOURCE = "https://github.com/wesleysimplicio/agentic-starter"
|
|
22
|
+
DEFAULT_AGENTIC_STARTER_REF = "latest"
|
|
23
|
+
AGENTIC_STARTER_LOCK = ".agentic-starter.json"
|
|
24
|
+
|
|
25
|
+
AGENTIC_STARTER_PATHS: tuple[str, ...] = (
|
|
26
|
+
"AGENTS.md",
|
|
27
|
+
"CLAUDE.md",
|
|
28
|
+
"INIT.md",
|
|
29
|
+
"_BOOTSTRAP.md",
|
|
30
|
+
"bootstrap.ps1",
|
|
31
|
+
"bootstrap.sh",
|
|
32
|
+
"playwright.config.ts",
|
|
33
|
+
"bin",
|
|
34
|
+
".agents",
|
|
35
|
+
".claude",
|
|
36
|
+
".codex",
|
|
37
|
+
".skills",
|
|
38
|
+
".github/CODEOWNERS",
|
|
39
|
+
".github/ISSUE_TEMPLATE",
|
|
40
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
41
|
+
".github/workflows/ci.yml",
|
|
42
|
+
".github/workflows/dod.yml",
|
|
43
|
+
".github/workflows/scaffold-self-check.yml",
|
|
44
|
+
"templates/ADR-template.md",
|
|
45
|
+
"templates/task-template.md",
|
|
46
|
+
".specs/architecture/ADR-template.md",
|
|
47
|
+
".specs/product/PERSONAS.md",
|
|
48
|
+
".specs/sprints/BACKLOG.md",
|
|
49
|
+
".specs/sprints/task-template.md",
|
|
50
|
+
".specs/workflow/CONTRIBUTING.md",
|
|
51
|
+
".specs/workflow/RELEASE.md",
|
|
52
|
+
".specs/workflow/WORKFLOW.md",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class AgenticStarterSyncResult:
|
|
58
|
+
"""Result returned by :func:`sync_agentic_starter`."""
|
|
59
|
+
|
|
60
|
+
repo_path: Path
|
|
61
|
+
source: str
|
|
62
|
+
requested_ref: str
|
|
63
|
+
resolved_ref: str
|
|
64
|
+
created: list[Path] = field(default_factory=list)
|
|
65
|
+
updated: list[Path] = field(default_factory=list)
|
|
66
|
+
skipped: list[Path] = field(default_factory=list)
|
|
67
|
+
missing: list[str] = field(default_factory=list)
|
|
68
|
+
dry_run: bool = False
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def changed(self) -> bool:
|
|
72
|
+
return bool(self.created or self.updated)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def sync_agentic_starter(
|
|
76
|
+
repo_path: str | Path,
|
|
77
|
+
*,
|
|
78
|
+
source: str = DEFAULT_AGENTIC_STARTER_SOURCE,
|
|
79
|
+
ref: str = DEFAULT_AGENTIC_STARTER_REF,
|
|
80
|
+
paths: tuple[str, ...] = AGENTIC_STARTER_PATHS,
|
|
81
|
+
force: bool = False,
|
|
82
|
+
dry_run: bool = False,
|
|
83
|
+
) -> AgenticStarterSyncResult:
|
|
84
|
+
"""Copy the latest agentic-starter structure into ``repo_path``.
|
|
85
|
+
|
|
86
|
+
``source`` may be a local directory, a GitHub repo URL, or ``owner/repo``.
|
|
87
|
+
GitHub sources use the latest release when ``ref="latest"`` and fall back to
|
|
88
|
+
the default branch name ``main`` if the repository has no release yet.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
repo = Path(repo_path).expanduser().resolve()
|
|
92
|
+
if not repo.exists():
|
|
93
|
+
raise FileNotFoundError(f"repo path not found: {repo}")
|
|
94
|
+
|
|
95
|
+
source_path = Path(source).expanduser()
|
|
96
|
+
if source_path.exists():
|
|
97
|
+
result = AgenticStarterSyncResult(
|
|
98
|
+
repo_path=repo,
|
|
99
|
+
source=str(source_path.resolve()),
|
|
100
|
+
requested_ref=ref,
|
|
101
|
+
resolved_ref=ref,
|
|
102
|
+
dry_run=dry_run,
|
|
103
|
+
)
|
|
104
|
+
_copy_paths(source_path.resolve(), repo, paths, result, force=force, dry_run=dry_run)
|
|
105
|
+
_write_lock(result, paths, force=force, dry_run=dry_run)
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
owner, repo_name = _parse_github_source(source)
|
|
109
|
+
with tempfile.TemporaryDirectory(prefix="sendsprint-agentic-starter-") as tmp:
|
|
110
|
+
tmp_path = Path(tmp)
|
|
111
|
+
archive, resolved_ref = _download_github_archive(owner, repo_name, ref, tmp_path)
|
|
112
|
+
with zipfile.ZipFile(archive) as zf:
|
|
113
|
+
zf.extractall(tmp_path / "src")
|
|
114
|
+
source_root = _archive_root(tmp_path / "src")
|
|
115
|
+
|
|
116
|
+
result = AgenticStarterSyncResult(
|
|
117
|
+
repo_path=repo,
|
|
118
|
+
source=f"https://github.com/{owner}/{repo_name}",
|
|
119
|
+
requested_ref=ref,
|
|
120
|
+
resolved_ref=resolved_ref,
|
|
121
|
+
dry_run=dry_run,
|
|
122
|
+
)
|
|
123
|
+
_copy_paths(source_root, repo, paths, result, force=force, dry_run=dry_run)
|
|
124
|
+
_write_lock(result, paths, force=force, dry_run=dry_run)
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _copy_paths(
|
|
129
|
+
source_root: Path,
|
|
130
|
+
repo: Path,
|
|
131
|
+
paths: tuple[str, ...],
|
|
132
|
+
result: AgenticStarterSyncResult,
|
|
133
|
+
*,
|
|
134
|
+
force: bool,
|
|
135
|
+
dry_run: bool,
|
|
136
|
+
) -> None:
|
|
137
|
+
for rel in paths:
|
|
138
|
+
source = source_root / rel
|
|
139
|
+
if not source.exists():
|
|
140
|
+
result.missing.append(rel)
|
|
141
|
+
continue
|
|
142
|
+
if source.is_file():
|
|
143
|
+
_copy_file(source, _target(repo, rel), result, force=force, dry_run=dry_run)
|
|
144
|
+
continue
|
|
145
|
+
for child in source.rglob("*"):
|
|
146
|
+
if child.is_file():
|
|
147
|
+
child_rel = child.relative_to(source_root).as_posix()
|
|
148
|
+
_copy_file(child, _target(repo, child_rel), result, force=force, dry_run=dry_run)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _copy_file(
|
|
152
|
+
source: Path,
|
|
153
|
+
target: Path,
|
|
154
|
+
result: AgenticStarterSyncResult,
|
|
155
|
+
*,
|
|
156
|
+
force: bool,
|
|
157
|
+
dry_run: bool,
|
|
158
|
+
) -> None:
|
|
159
|
+
if target.exists() and not force:
|
|
160
|
+
result.skipped.append(target)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
bucket = result.updated if target.exists() else result.created
|
|
164
|
+
bucket.append(target)
|
|
165
|
+
if dry_run:
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
shutil.copy2(source, target)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _target(repo: Path, rel: str) -> Path:
|
|
173
|
+
target = (repo / rel).resolve()
|
|
174
|
+
if target != repo and repo not in target.parents:
|
|
175
|
+
raise ValueError(f"refusing to write outside repo: {rel}")
|
|
176
|
+
return target
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _write_lock(
|
|
180
|
+
result: AgenticStarterSyncResult,
|
|
181
|
+
paths: tuple[str, ...],
|
|
182
|
+
*,
|
|
183
|
+
force: bool,
|
|
184
|
+
dry_run: bool,
|
|
185
|
+
) -> None:
|
|
186
|
+
lock = result.repo_path / AGENTIC_STARTER_LOCK
|
|
187
|
+
data = {
|
|
188
|
+
"source": result.source,
|
|
189
|
+
"requested_ref": result.requested_ref,
|
|
190
|
+
"resolved_ref": result.resolved_ref,
|
|
191
|
+
"synced_at": datetime.now(tz=UTC).isoformat(timespec="seconds"),
|
|
192
|
+
"mode": "force" if force else "missing",
|
|
193
|
+
"managed_paths": list(paths),
|
|
194
|
+
}
|
|
195
|
+
if lock.exists() and not force:
|
|
196
|
+
result.skipped.append(lock)
|
|
197
|
+
return
|
|
198
|
+
if lock.exists():
|
|
199
|
+
result.updated.append(lock)
|
|
200
|
+
else:
|
|
201
|
+
result.created.append(lock)
|
|
202
|
+
if not dry_run:
|
|
203
|
+
lock.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _parse_github_source(source: str) -> tuple[str, str]:
|
|
207
|
+
normalized = source.removesuffix(".git").rstrip("/")
|
|
208
|
+
marker = "github.com/"
|
|
209
|
+
if marker in normalized:
|
|
210
|
+
normalized = normalized.split(marker, 1)[1]
|
|
211
|
+
parts = normalized.split("/")
|
|
212
|
+
if len(parts) >= 2 and parts[0] and parts[1]:
|
|
213
|
+
return parts[0], parts[1]
|
|
214
|
+
raise ValueError("agentic-starter source must be a local path, a GitHub URL, or 'owner/repo'")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _download_github_archive(
|
|
218
|
+
owner: str,
|
|
219
|
+
repo: str,
|
|
220
|
+
ref: str,
|
|
221
|
+
target_dir: Path,
|
|
222
|
+
) -> tuple[Path, str]:
|
|
223
|
+
resolved_ref = _resolve_github_ref(owner, repo, ref)
|
|
224
|
+
archive_url = f"https://api.github.com/repos/{owner}/{repo}/zipball/{resolved_ref}"
|
|
225
|
+
archive = target_dir / "agentic-starter.zip"
|
|
226
|
+
_download(archive_url, archive)
|
|
227
|
+
return archive, resolved_ref
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _resolve_github_ref(owner: str, repo: str, ref: str) -> str:
|
|
231
|
+
if ref != "latest":
|
|
232
|
+
return ref
|
|
233
|
+
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
|
|
234
|
+
try:
|
|
235
|
+
data = json.loads(_read_url(url).decode("utf-8"))
|
|
236
|
+
except urllib.error.HTTPError as exc:
|
|
237
|
+
if exc.code == 404:
|
|
238
|
+
return "main"
|
|
239
|
+
raise
|
|
240
|
+
tag = data.get("tag_name")
|
|
241
|
+
return str(tag) if tag else "main"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _read_url(url: str) -> bytes:
|
|
245
|
+
req = urllib.request.Request(url, headers={"User-Agent": "sendsprint"})
|
|
246
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
247
|
+
return response.read()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _download(url: str, target: Path) -> None:
|
|
251
|
+
data = _read_url(url)
|
|
252
|
+
target.write_bytes(data)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _archive_root(path: Path) -> Path:
|
|
256
|
+
children = [p for p in path.iterdir() if p.is_dir()]
|
|
257
|
+
if len(children) == 1:
|
|
258
|
+
return children[0]
|
|
259
|
+
return path
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def result_to_json(result: AgenticStarterSyncResult) -> dict[str, Any]:
|
|
263
|
+
"""Return a JSON-serializable sync result for CLI output/tests."""
|
|
264
|
+
|
|
265
|
+
repo = result.repo_path
|
|
266
|
+
|
|
267
|
+
def _rel(paths: list[Path]) -> list[str]:
|
|
268
|
+
return [p.relative_to(repo).as_posix() for p in paths]
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
"repo_path": str(repo),
|
|
272
|
+
"source": result.source,
|
|
273
|
+
"requested_ref": result.requested_ref,
|
|
274
|
+
"resolved_ref": result.resolved_ref,
|
|
275
|
+
"created": _rel(result.created),
|
|
276
|
+
"updated": _rel(result.updated),
|
|
277
|
+
"skipped": _rel(result.skipped),
|
|
278
|
+
"missing": result.missing,
|
|
279
|
+
"dry_run": result.dry_run,
|
|
280
|
+
"changed": result.changed,
|
|
281
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Agents: worktree isolation, dev, lint, test, security, PR creation/review."""
|
|
2
|
+
|
|
3
|
+
from .dev import DevAgent
|
|
4
|
+
from .lint_runner import LintRunner
|
|
5
|
+
from .pr_body_builder import PrBodyBuilder
|
|
6
|
+
from .pr_creator import PrCreator
|
|
7
|
+
from .pr_reviewer import PrReviewer
|
|
8
|
+
from .security_reviewer import SecurityReviewer
|
|
9
|
+
from .sprint_importer import SprintImporter
|
|
10
|
+
from .test_runner import TestRunner
|
|
11
|
+
from .worktree import WorktreeManager
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DevAgent",
|
|
15
|
+
"LintRunner",
|
|
16
|
+
"PrBodyBuilder",
|
|
17
|
+
"PrCreator",
|
|
18
|
+
"PrReviewer",
|
|
19
|
+
"SecurityReviewer",
|
|
20
|
+
"SprintImporter",
|
|
21
|
+
"TestRunner",
|
|
22
|
+
"WorktreeManager",
|
|
23
|
+
]
|
sendsprint/agents/dev.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""DevAgent: dispatches development work by tech stack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..models.reports import StepReport
|
|
10
|
+
from ..tech import TechFingerprint
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
INSTALL_COMMANDS: dict[str, list[str]] = {
|
|
15
|
+
"npm": ["npm", "install"],
|
|
16
|
+
"yarn": ["yarn", "install"],
|
|
17
|
+
"pnpm": ["pnpm", "install"],
|
|
18
|
+
"bun": ["bun", "install"],
|
|
19
|
+
"pip": ["pip", "install", "-r", "requirements.txt"],
|
|
20
|
+
"poetry": ["poetry", "install"],
|
|
21
|
+
"uv": ["uv", "sync"],
|
|
22
|
+
"nuget": ["dotnet", "restore"],
|
|
23
|
+
"maven": ["mvn", "install", "-DskipTests"],
|
|
24
|
+
"gradle": ["./gradlew", "assemble"],
|
|
25
|
+
"cargo": ["cargo", "build"],
|
|
26
|
+
"go": ["go", "build", "./..."],
|
|
27
|
+
"pub": ["flutter", "pub", "get"],
|
|
28
|
+
"bundler": ["bundle", "install"],
|
|
29
|
+
"composer": ["composer", "install"],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
BUILD_COMMANDS: dict[str, list[str]] = {
|
|
33
|
+
"angular": ["npx", "ng", "build"],
|
|
34
|
+
"react": ["npm", "run", "build"],
|
|
35
|
+
"nextjs": ["npm", "run", "build"],
|
|
36
|
+
"vue": ["npm", "run", "build"],
|
|
37
|
+
"nestjs": ["npm", "run", "build"],
|
|
38
|
+
"dotnet": ["dotnet", "build"],
|
|
39
|
+
"spring": ["mvn", "package", "-DskipTests"],
|
|
40
|
+
"java": ["mvn", "package", "-DskipTests"],
|
|
41
|
+
"go": ["go", "build", "./..."],
|
|
42
|
+
"rust": ["cargo", "build"],
|
|
43
|
+
"flutter": ["flutter", "build"],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DevAgent:
|
|
48
|
+
"""Runs install + build for a repo based on its tech fingerprint."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, repo_path: str | Path, fingerprint: TechFingerprint) -> None:
|
|
51
|
+
self.repo = Path(repo_path).resolve()
|
|
52
|
+
self.fp = fingerprint
|
|
53
|
+
|
|
54
|
+
def install(self) -> StepReport:
|
|
55
|
+
report = StepReport(step=3, name="install-deps", repo=str(self.repo))
|
|
56
|
+
report.status = "running"
|
|
57
|
+
pm = self.fp.package_managers[0] if self.fp.package_managers else None
|
|
58
|
+
if not pm:
|
|
59
|
+
report.status = "skipped"
|
|
60
|
+
report.message = "no package manager detected"
|
|
61
|
+
return report
|
|
62
|
+
cmd = INSTALL_COMMANDS.get(pm)
|
|
63
|
+
if not cmd:
|
|
64
|
+
report.status = "skipped"
|
|
65
|
+
report.message = f"no install command mapped for {pm}"
|
|
66
|
+
return report
|
|
67
|
+
return self._exec(cmd, report)
|
|
68
|
+
|
|
69
|
+
def build(self, *, custom_command: str | None = None) -> StepReport:
|
|
70
|
+
report = StepReport(step=3, name="build", repo=str(self.repo))
|
|
71
|
+
report.status = "running"
|
|
72
|
+
if custom_command:
|
|
73
|
+
cmd = custom_command.split()
|
|
74
|
+
else:
|
|
75
|
+
tech = self.fp.primary_tech
|
|
76
|
+
cmd_list = BUILD_COMMANDS.get(tech) if tech else None
|
|
77
|
+
if not cmd_list:
|
|
78
|
+
report.status = "skipped"
|
|
79
|
+
report.message = f"no build command for tech={tech}"
|
|
80
|
+
return report
|
|
81
|
+
cmd = list(cmd_list)
|
|
82
|
+
return self._exec(cmd, report)
|
|
83
|
+
|
|
84
|
+
def _exec(self, cmd: list[str], report: StepReport) -> StepReport:
|
|
85
|
+
try:
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
cmd,
|
|
88
|
+
cwd=str(self.repo),
|
|
89
|
+
capture_output=True,
|
|
90
|
+
text=True,
|
|
91
|
+
timeout=300,
|
|
92
|
+
)
|
|
93
|
+
if result.returncode == 0:
|
|
94
|
+
report.status = "ok"
|
|
95
|
+
report.message = f"{' '.join(cmd)} succeeded"
|
|
96
|
+
else:
|
|
97
|
+
report.status = "failed"
|
|
98
|
+
report.message = result.stderr[:2000] or result.stdout[:2000]
|
|
99
|
+
except FileNotFoundError:
|
|
100
|
+
report.status = "failed"
|
|
101
|
+
report.message = f"command not found: {cmd[0]}"
|
|
102
|
+
except subprocess.TimeoutExpired:
|
|
103
|
+
report.status = "failed"
|
|
104
|
+
report.message = f"timeout after 300s: {' '.join(cmd)}"
|
|
105
|
+
return report
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""LintRunner: runs linting per tech stack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import subprocess
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ..models.reports import StepReport
|
|
11
|
+
from ..tech import TechFingerprint
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
LINT_COMMANDS: dict[str, list[str]] = {
|
|
16
|
+
"angular": ["npx", "ng", "lint"],
|
|
17
|
+
"react": ["npx", "eslint", ".", "--max-warnings=0"],
|
|
18
|
+
"nextjs": ["npx", "eslint", ".", "--max-warnings=0"],
|
|
19
|
+
"vue": ["npx", "eslint", ".", "--max-warnings=0"],
|
|
20
|
+
"nestjs": ["npx", "eslint", ".", "--max-warnings=0"],
|
|
21
|
+
"node": ["npx", "eslint", "."],
|
|
22
|
+
"dotnet": ["dotnet", "format", "--verify-no-changes"],
|
|
23
|
+
"spring": ["mvn", "checkstyle:check"],
|
|
24
|
+
"java": ["mvn", "checkstyle:check"],
|
|
25
|
+
"python": ["ruff", "check", "."],
|
|
26
|
+
"django": ["ruff", "check", "."],
|
|
27
|
+
"fastapi": ["ruff", "check", "."],
|
|
28
|
+
"flask": ["ruff", "check", "."],
|
|
29
|
+
"go": ["golangci-lint", "run"],
|
|
30
|
+
"rust": ["cargo", "clippy", "--", "-D", "warnings"],
|
|
31
|
+
"flutter": ["dart", "analyze"],
|
|
32
|
+
"ruby": ["bundle", "exec", "rubocop"],
|
|
33
|
+
"php": ["vendor/bin/phpcs"],
|
|
34
|
+
"laravel": ["vendor/bin/phpcs"],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LintRunner:
|
|
39
|
+
"""Runs linting for a repo based on its tech fingerprint."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
repo_path: str | Path,
|
|
44
|
+
fingerprint: TechFingerprint,
|
|
45
|
+
*,
|
|
46
|
+
custom_command: str | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.repo = Path(repo_path).resolve()
|
|
49
|
+
self.fp = fingerprint
|
|
50
|
+
self.custom_command = custom_command
|
|
51
|
+
|
|
52
|
+
def run(self) -> StepReport:
|
|
53
|
+
report = StepReport(step=4, name="lint", repo=str(self.repo))
|
|
54
|
+
report.started_at = datetime.now(tz=UTC)
|
|
55
|
+
report.status = "running"
|
|
56
|
+
|
|
57
|
+
if self.custom_command:
|
|
58
|
+
cmd = self.custom_command.split()
|
|
59
|
+
else:
|
|
60
|
+
tech = self.fp.primary_tech
|
|
61
|
+
cmd_list = LINT_COMMANDS.get(tech) if tech else None
|
|
62
|
+
if not cmd_list:
|
|
63
|
+
report.status = "skipped"
|
|
64
|
+
report.message = f"no lint command for tech={tech}"
|
|
65
|
+
report.finished_at = datetime.now(tz=UTC)
|
|
66
|
+
return report
|
|
67
|
+
cmd = list(cmd_list)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
result = subprocess.run(
|
|
71
|
+
cmd,
|
|
72
|
+
cwd=str(self.repo),
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
timeout=120,
|
|
76
|
+
)
|
|
77
|
+
if result.returncode == 0:
|
|
78
|
+
report.status = "ok"
|
|
79
|
+
report.message = f"{' '.join(cmd)} passed"
|
|
80
|
+
else:
|
|
81
|
+
report.status = "failed"
|
|
82
|
+
report.message = result.stdout[:2000] or result.stderr[:2000]
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
report.status = "skipped"
|
|
85
|
+
report.message = f"linter not installed: {cmd[0]}"
|
|
86
|
+
except subprocess.TimeoutExpired:
|
|
87
|
+
report.status = "failed"
|
|
88
|
+
report.message = f"timeout after 120s: {' '.join(cmd)}"
|
|
89
|
+
|
|
90
|
+
report.finished_at = datetime.now(tz=UTC)
|
|
91
|
+
return report
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""PrBodyBuilder: composes rich PR body with evidence + AC + DoD checklist."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from sendsprint.models import Sprint, SprintItem
|
|
8
|
+
from sendsprint.models.reports import StepReport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _evidence_for_repo(steps: list[StepReport], repo: str) -> list[str]:
|
|
12
|
+
lines: list[str] = []
|
|
13
|
+
for s in steps:
|
|
14
|
+
if s.repo != repo:
|
|
15
|
+
continue
|
|
16
|
+
for ev in s.evidence:
|
|
17
|
+
mark = "✓" if ev.passed else "✗"
|
|
18
|
+
loc = f" — `{ev.path}`" if ev.path else ""
|
|
19
|
+
lines.append(f"- {mark} [{ev.kind}] {ev.title}{loc}")
|
|
20
|
+
return lines
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _findings_for_repo(steps: list[StepReport], repo: str) -> list[str]:
|
|
24
|
+
lines: list[str] = []
|
|
25
|
+
for s in steps:
|
|
26
|
+
if s.repo != repo:
|
|
27
|
+
continue
|
|
28
|
+
for f in s.findings:
|
|
29
|
+
where = f"{f.file}:{f.line}" if f.file and f.line else (f.file or "—")
|
|
30
|
+
lines.append(f"- [{f.severity}] `{f.rule}` @ {where} — {f.message}")
|
|
31
|
+
return lines
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _step_status_table(steps: list[StepReport], repo: str) -> str:
|
|
35
|
+
rows = [
|
|
36
|
+
f"| {s.step} | {s.name} | {s.status} | {(s.message or '')[:80]} |"
|
|
37
|
+
for s in steps
|
|
38
|
+
if s.repo == repo or s.repo is None
|
|
39
|
+
]
|
|
40
|
+
if not rows:
|
|
41
|
+
return "_(no steps recorded)_"
|
|
42
|
+
header = "| # | Step | Status | Message |\n|---|------|--------|---------|"
|
|
43
|
+
return header + "\n" + "\n".join(rows)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _item_lines(items: list[SprintItem]) -> str:
|
|
47
|
+
if not items:
|
|
48
|
+
return "_(no items in scope)_"
|
|
49
|
+
return "\n".join(
|
|
50
|
+
f"- `{i.key}` — {i.title} ({i.type}, {i.status})"
|
|
51
|
+
+ (f" → {i.source_url}" if i.source_url else "")
|
|
52
|
+
for i in items
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PrBodyBuilder:
|
|
57
|
+
"""Compose PR markdown body with sprint context + evidence + DoD."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, repo_root: Path) -> None:
|
|
60
|
+
self.repo_root = Path(repo_root)
|
|
61
|
+
|
|
62
|
+
def build(
|
|
63
|
+
self,
|
|
64
|
+
sprint: Sprint,
|
|
65
|
+
repo_name: str,
|
|
66
|
+
steps: list[StepReport],
|
|
67
|
+
sprint_slug: str | None = None,
|
|
68
|
+
) -> str:
|
|
69
|
+
evidence = _evidence_for_repo(steps, repo_name)
|
|
70
|
+
findings = _findings_for_repo(steps, repo_name)
|
|
71
|
+
status_table = _step_status_table(steps, repo_name)
|
|
72
|
+
items_block = _item_lines(sprint.items)
|
|
73
|
+
slug = sprint_slug or f"sprint-{sprint.id}"
|
|
74
|
+
evidence_block = "\n".join(evidence) if evidence else "_(no evidence captured)_"
|
|
75
|
+
findings_block = "\n".join(findings) if findings else "_(none)_"
|
|
76
|
+
|
|
77
|
+
return f"""## Summary
|
|
78
|
+
|
|
79
|
+
Automated PR generated by **SendSprint** for sprint `{sprint.name}` ({sprint.source}).
|
|
80
|
+
|
|
81
|
+
- Sprint ID: `{sprint.id}`
|
|
82
|
+
- Source: `{sprint.source}` via transport `{sprint.transport}`
|
|
83
|
+
- Items in scope: {len(sprint.items)}
|
|
84
|
+
- Repo: `{repo_name}`
|
|
85
|
+
|
|
86
|
+
## Sprint items
|
|
87
|
+
|
|
88
|
+
{items_block}
|
|
89
|
+
|
|
90
|
+
## Step report
|
|
91
|
+
|
|
92
|
+
{status_table}
|
|
93
|
+
|
|
94
|
+
## Evidence
|
|
95
|
+
|
|
96
|
+
{evidence_block}
|
|
97
|
+
|
|
98
|
+
## Security findings (flag-only, ADR-005)
|
|
99
|
+
|
|
100
|
+
{findings_block}
|
|
101
|
+
|
|
102
|
+
## Definition of Done
|
|
103
|
+
|
|
104
|
+
- [ ] Sprint specs imported under `.specs/sprints/{slug}/`
|
|
105
|
+
- [ ] Lint green
|
|
106
|
+
- [ ] Unit tests green
|
|
107
|
+
- [ ] E2E Playwright green with screenshots/traces
|
|
108
|
+
- [ ] Coverage diff ≥ 80%
|
|
109
|
+
- [ ] Security scan: zero findings or accepted exceptions documented
|
|
110
|
+
- [ ] CHANGELOG updated if release-relevant
|
|
111
|
+
- [ ] ADR opened if architectural decision
|
|
112
|
+
|
|
113
|
+
## Links
|
|
114
|
+
|
|
115
|
+
- Sprint spec: `.specs/sprints/{slug}/SPRINT.md`
|
|
116
|
+
- DoD workflow: `.github/workflows/dod.yml`
|
|
117
|
+
- Source sprint: {sprint.name}
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
Generated by SendSprint — see `skills/claude/SKILL.md` for orchestration flow.
|
|
122
|
+
"""
|