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.
Files changed (61) hide show
  1. sendsprint/__init__.py +4 -0
  2. sendsprint/agentic_starter.py +281 -0
  3. sendsprint/agents/__init__.py +23 -0
  4. sendsprint/agents/dev.py +105 -0
  5. sendsprint/agents/lint_runner.py +91 -0
  6. sendsprint/agents/pr_body_builder.py +122 -0
  7. sendsprint/agents/pr_creator.py +162 -0
  8. sendsprint/agents/pr_reviewer.py +104 -0
  9. sendsprint/agents/security_reviewer.py +246 -0
  10. sendsprint/agents/sprint_importer.py +194 -0
  11. sendsprint/agents/test_runner.py +150 -0
  12. sendsprint/agents/worktree.py +72 -0
  13. sendsprint/api/README.md +106 -0
  14. sendsprint/api/__init__.py +10 -0
  15. sendsprint/api/__main__.py +6 -0
  16. sendsprint/api/assets/__init__.py +6 -0
  17. sendsprint/api/assets/screenshots/coverage.png +0 -0
  18. sendsprint/api/assets/screenshots/dashboard.png +0 -0
  19. sendsprint/api/assets/screenshots/login.png +0 -0
  20. sendsprint/api/assets/screenshots/regression-diff.png +0 -0
  21. sendsprint/api/assets/screenshots/regression-fail.png +0 -0
  22. sendsprint/api/assets/screenshots/regression-pass.png +0 -0
  23. sendsprint/api/routes/__init__.py +1 -0
  24. sendsprint/api/routes/auth.py +86 -0
  25. sendsprint/api/routes/runs.py +72 -0
  26. sendsprint/api/routes/sprints.py +226 -0
  27. sendsprint/api/runs/__init__.py +5 -0
  28. sendsprint/api/runs/bridge.py +354 -0
  29. sendsprint/api/runs/events.py +45 -0
  30. sendsprint/api/runs/manager.py +87 -0
  31. sendsprint/api/schemas.py +120 -0
  32. sendsprint/api/server.py +74 -0
  33. sendsprint/architecture/__init__.py +6 -0
  34. sendsprint/architecture/builder.py +308 -0
  35. sendsprint/architecture/mapper.py +122 -0
  36. sendsprint/cli.py +539 -0
  37. sendsprint/credentials.py +113 -0
  38. sendsprint/flow/__init__.py +5 -0
  39. sendsprint/flow/sprint_flow.py +515 -0
  40. sendsprint/llm/__init__.py +5 -0
  41. sendsprint/llm/client.py +174 -0
  42. sendsprint/models/__init__.py +51 -0
  43. sendsprint/models/reports.py +84 -0
  44. sendsprint/models/sprint.py +112 -0
  45. sendsprint/models/workspace.py +64 -0
  46. sendsprint/operators/__init__.py +13 -0
  47. sendsprint/operators/azure_devops_operator.py +257 -0
  48. sendsprint/operators/base.py +65 -0
  49. sendsprint/operators/jira_operator.py +275 -0
  50. sendsprint/profile.py +108 -0
  51. sendsprint/scaffolder.py +309 -0
  52. sendsprint/scope.py +99 -0
  53. sendsprint/tech/__init__.py +5 -0
  54. sendsprint/tech/detector.py +238 -0
  55. sendsprint/workspace/__init__.py +5 -0
  56. sendsprint/workspace/loader.py +65 -0
  57. sendsprint-0.7.1.dist-info/METADATA +350 -0
  58. sendsprint-0.7.1.dist-info/RECORD +61 -0
  59. sendsprint-0.7.1.dist-info/WHEEL +4 -0
  60. sendsprint-0.7.1.dist-info/entry_points.txt +3 -0
  61. sendsprint-0.7.1.dist-info/licenses/LICENSE +21 -0
sendsprint/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """SendSprint - multi-agent skill that automates sprint delivery."""
2
+
3
+ __version__ = "0.7.1"
4
+ __all__ = ["__version__"]
@@ -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
+ ]
@@ -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
+ """