tempest-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,779 @@
1
+ """``tempest pr-prompt`` — build the prompt that makes an AI fill a PR template.
2
+
3
+ Writing a pull-request description by hand is the step everyone skips
4
+ when the branch is finally green, and the result is a PR body that says
5
+ "fix stuff" over 40 changed files. Any assistant can write a good one —
6
+ what it lacks is the two things that live in the repository: the
7
+ **template** the team agreed on, and the **diff** the branch actually
8
+ produced.
9
+
10
+ This module assembles both into a single prompt:
11
+
12
+ 1. the pull-request template — the repository's own
13
+ (``.github/pull_request_template.md`` and friends) when it has one,
14
+ otherwise the bundled PT-BR / EN-US default;
15
+ 2. the rules that stop the model from returning the template with the
16
+ placeholders still in it (no ``Sim/Não`` left undecided, no
17
+ ``_italic hint_``, no section dropped);
18
+ 3. the branch context — commit subjects, the ``--name-status`` file
19
+ list, and a bounded excerpt of each file's patch.
20
+
21
+ The result goes to stdout, so it pipes straight into whichever assistant
22
+ the user runs::
23
+
24
+ tempest pr-prompt | claude -p
25
+ tempest pr-prompt --out pr_prompt.txt
26
+
27
+ Only the excerpts are bounded. The commit subjects and the changed-file
28
+ list always go in whole, so the model always knows *what* changed and
29
+ only *how* it changed is sampled — and everything the sampling drops is
30
+ reported: a file left without a patch by ``--max-files`` and a patch cut
31
+ by ``--max-chars`` are both stated inside the prompt, so a partial
32
+ context reads as partial rather than as the whole change. Passing
33
+ ``None`` for either bound (``--full`` on the command line) lifts it.
34
+
35
+ Diffs use the three-dot range ``base...head`` — the merge-base diff,
36
+ which is what the forge shows on the pull request — while commits use
37
+ ``base..head``. Reading the two-dot diff instead would attribute every
38
+ commit that landed on ``base`` since the branch started to this PR.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import importlib.resources
44
+ import subprocess
45
+ from collections.abc import Sequence
46
+ from dataclasses import dataclass
47
+ from enum import StrEnum
48
+ from pathlib import Path
49
+
50
+ DEFAULT_BASE: str = "main"
51
+ """Branch a pull request is opened against when ``--base`` is omitted."""
52
+
53
+ DEFAULT_MAX_FILES: int = 10
54
+ """How many files contribute a patch excerpt before the rest is summarized."""
55
+
56
+ DEFAULT_MAX_CHARS: int = 1500
57
+ """How many characters of each file's patch are kept in the prompt."""
58
+
59
+ TEMPLATE_CANDIDATES: tuple[str, ...] = (
60
+ ".github/pull_request_template.md",
61
+ ".github/PULL_REQUEST_TEMPLATE.md",
62
+ ".github/PULL_REQUEST_TEMPLATE/pull_request_template.md",
63
+ ".gitlab/merge_request_templates/default.md",
64
+ "docs/pull_request_template.md",
65
+ ".pull_request_template.md",
66
+ "pull_request_template.md",
67
+ )
68
+ """Repository template locations, in the order they are looked up.
69
+
70
+ The forges accept several spellings and a project only ever has one, so
71
+ the first hit wins. A repository that keeps its template somewhere else
72
+ passes ``--template``.
73
+ """
74
+
75
+
76
+ class PromptLanguage(StrEnum):
77
+ """Language of the bundled template and of the prompt's instructions.
78
+
79
+ Only the *bundled* template is translated: a repository template is
80
+ used verbatim in whatever language it was written in, since it is
81
+ that repository's contract.
82
+ """
83
+
84
+ PT_BR = "pt"
85
+ EN_US = "en"
86
+
87
+
88
+ class GitError(RuntimeError):
89
+ """A ``git`` invocation failed, or the repository lacks what was asked.
90
+
91
+ Carries the command's own ``stderr`` so the caller can print the
92
+ reason git gave instead of a generic failure.
93
+ """
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class DiffExcerpt:
98
+ """A single file's patch, possibly cut to the character budget.
99
+
100
+ Attributes:
101
+ path (str): Repository-relative path of the file.
102
+ patch (str): The unified diff, truncated to ``--max-chars``.
103
+ truncated (bool): Whether the patch was cut. Rendered into the
104
+ prompt so the model does not read a partial hunk as the
105
+ complete change.
106
+ """
107
+
108
+ path: str
109
+ patch: str
110
+ truncated: bool
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class ResolvedTemplate:
115
+ """The pull-request template the prompt will carry.
116
+
117
+ Attributes:
118
+ text (str): The template's markdown.
119
+ source (str): Where it came from — a repository-relative path, or
120
+ the bundled file's name. Reported on stderr so the user knows
121
+ which template the model was handed.
122
+ bundled (bool): True when the SDK's own template was used because
123
+ the repository has none.
124
+ """
125
+
126
+ text: str
127
+ source: str
128
+ bundled: bool
129
+
130
+
131
+ @dataclass(frozen=True, slots=True)
132
+ class PullRequestContext:
133
+ """Everything read out of the repository for one branch comparison.
134
+
135
+ Attributes:
136
+ repository (str): The repository directory's name.
137
+ base (str): The resolved base ref (may be ``origin/main`` when
138
+ the local ``main`` does not exist).
139
+ head (str): The branch being described, or a short sha when HEAD
140
+ is detached.
141
+ commits (list[str]): Commit subjects, newest first.
142
+ files (list[str]): ``git diff --name-status`` lines.
143
+ excerpts (list[DiffExcerpt]): Per-file patches, bounded.
144
+ omitted_files (int): Changed files with no excerpt because of
145
+ ``--max-files``.
146
+ """
147
+
148
+ repository: str
149
+ base: str
150
+ head: str
151
+ commits: list[str]
152
+ files: list[str]
153
+ excerpts: list[DiffExcerpt]
154
+ omitted_files: int
155
+
156
+
157
+ _PROMPT_HEADERS: dict[PromptLanguage, dict[str, str]] = {
158
+ PromptLanguage.PT_BR: {
159
+ "role": (
160
+ "Você é um engenheiro experiente escrevendo a descrição de um Pull Request."
161
+ ),
162
+ "rules": "REGRAS OBRIGATÓRIAS — LEIA COM ATENÇÃO",
163
+ "template": "TEMPLATE A SER PREENCHIDO (NÃO ALTERAR)",
164
+ "context": "CONTEXTO DO PR (USE PARA PREENCHER)",
165
+ "repository": "Repositório",
166
+ "branch": "Branch",
167
+ "commits": "Commits",
168
+ "files": "Arquivos alterados",
169
+ "patches": "Trechos do diff",
170
+ "no_commits": "(nenhum commit entre as duas refs)",
171
+ "no_files": "(nenhum arquivo alterado)",
172
+ "no_patches": "(nenhum trecho de diff incluído)",
173
+ "truncated": "trecho cortado — o patch deste arquivo continua",
174
+ "omitted": (
175
+ "Mais {count} arquivo(s) alterado(s) sem trecho de diff aqui: "
176
+ "leia a lista acima e trate o diff como parcial."
177
+ ),
178
+ "closing": "Qualquer violação das regras acima torna a resposta inválida.",
179
+ },
180
+ PromptLanguage.EN_US: {
181
+ "role": (
182
+ "You are an experienced engineer writing the description of a Pull Request."
183
+ ),
184
+ "rules": "MANDATORY RULES — READ CAREFULLY",
185
+ "template": "TEMPLATE TO FILL IN (DO NOT ALTER)",
186
+ "context": "PR CONTEXT (USE IT TO FILL THE TEMPLATE)",
187
+ "repository": "Repository",
188
+ "branch": "Branch",
189
+ "commits": "Commits",
190
+ "files": "Changed files",
191
+ "patches": "Diff excerpts",
192
+ "no_commits": "(no commits between the two refs)",
193
+ "no_files": "(no changed files)",
194
+ "no_patches": "(no diff excerpt included)",
195
+ "truncated": "excerpt cut — this file's patch continues",
196
+ "omitted": (
197
+ "{count} more changed file(s) carry no excerpt here: read the "
198
+ "list above and treat the diff as partial."
199
+ ),
200
+ "closing": "Breaking any rule above makes the answer invalid.",
201
+ },
202
+ }
203
+
204
+ _PROMPT_RULES: dict[PromptLanguage, tuple[str, ...]] = {
205
+ PromptLanguage.PT_BR: (
206
+ "TODOS os campos do template DEVEM ser preenchidos.",
207
+ 'NÃO deixe "Sim/Não". Escolha explicitamente Sim ou Não.',
208
+ "NÃO deixe placeholders: nem texto em itálico de instrução, nem "
209
+ "[insira o screenshot aqui], nem colchetes vazios.",
210
+ 'Se algo NÃO se aplicar, escreva explicitamente "Nenhuma", '
211
+ '"Nenhum" ou "Não se aplica" — nunca apague a seção.',
212
+ "TODA seção do template aparece na resposta, na mesma ordem e com "
213
+ "o mesmo título.",
214
+ "Descreva o que o diff mostra. NÃO invente migrations, variáveis "
215
+ "de ambiente, scripts ou dependências que não aparecem no "
216
+ "contexto.",
217
+ "NÃO adicione comentários, saudações ou explicações fora do template.",
218
+ "A resposta DEVE conter APENAS o markdown válido do template preenchido.",
219
+ ),
220
+ PromptLanguage.EN_US: (
221
+ "EVERY field in the template MUST be filled in.",
222
+ 'Do NOT leave "Yes/No". Pick Yes or No explicitly.',
223
+ "Do NOT leave placeholders: no italic instruction text, no "
224
+ "[insert screenshot here], no empty brackets.",
225
+ 'If something does NOT apply, write "None" or "Not applicable" '
226
+ "explicitly — never drop the section.",
227
+ "EVERY section of the template appears in the answer, in the same "
228
+ "order and under the same heading.",
229
+ "Describe what the diff shows. Do NOT invent migrations, "
230
+ "environment variables, scripts or dependencies that are absent "
231
+ "from the context.",
232
+ "Do NOT add comments, greetings or explanations outside the template.",
233
+ "The answer MUST contain ONLY the valid markdown of the filled template.",
234
+ ),
235
+ }
236
+
237
+
238
+ def _run_git(args: Sequence[str], *, cwd: Path) -> str:
239
+ """Run a ``git`` command and return its stdout.
240
+
241
+ Args:
242
+ args (Sequence[str]): Arguments after the ``git`` executable.
243
+ cwd (Path): Directory the command runs in.
244
+
245
+ Returns:
246
+ str: The command's stdout, stripped of the trailing newline.
247
+
248
+ Raises:
249
+ GitError: When git is missing from PATH or exits non-zero. The
250
+ message carries git's own stderr, which names the actual
251
+ problem (unknown ref, not a repository, ...).
252
+ """
253
+ try:
254
+ completed = subprocess.run(
255
+ ["git", *args],
256
+ cwd=cwd,
257
+ capture_output=True,
258
+ text=True,
259
+ check=False,
260
+ )
261
+ except FileNotFoundError as exc:
262
+ raise GitError("git was not found on PATH.") from exc
263
+ if completed.returncode != 0:
264
+ detail = completed.stderr.strip() or completed.stdout.strip()
265
+ raise GitError(f"git {' '.join(args)} failed: {detail}")
266
+ return completed.stdout.rstrip("\n")
267
+
268
+
269
+ def repository_root(cwd: Path) -> Path:
270
+ """Return the root of the git repository containing ``cwd``.
271
+
272
+ Args:
273
+ cwd (Path): Any directory inside the repository.
274
+
275
+ Returns:
276
+ Path: The absolute repository root.
277
+
278
+ Raises:
279
+ GitError: When ``cwd`` is not inside a git repository.
280
+ """
281
+ return Path(_run_git(["rev-parse", "--show-toplevel"], cwd=cwd))
282
+
283
+
284
+ def repository_name(cwd: Path) -> str:
285
+ """Return the repository's name as the forge knows it.
286
+
287
+ The directory name is the obvious answer and the wrong one inside a
288
+ ``git worktree``, where each checkout lives in its own directory
289
+ named after the task. The ``origin`` remote carries the real name, so
290
+ it is read first and the directory name is only the fallback for a
291
+ repository with no remote.
292
+
293
+ Args:
294
+ cwd (Path): A directory inside the repository.
295
+
296
+ Returns:
297
+ str: The repository name.
298
+
299
+ Raises:
300
+ GitError: When git fails resolving the repository root.
301
+ """
302
+ root = repository_root(cwd)
303
+ try:
304
+ url = _run_git(["remote", "get-url", "origin"], cwd=root)
305
+ except GitError:
306
+ return root.name
307
+ cleaned = url.rstrip("/").removesuffix(".git").replace(":", "/")
308
+ return cleaned.rpartition("/")[2] or root.name
309
+
310
+
311
+ def current_branch(cwd: Path) -> str:
312
+ """Return the checked-out branch name.
313
+
314
+ A detached HEAD has no branch name — git answers the literal
315
+ ``HEAD`` — so the short commit sha is returned instead, which is what
316
+ identifies the work in that state.
317
+
318
+ Args:
319
+ cwd (Path): A directory inside the repository.
320
+
321
+ Returns:
322
+ str: The branch name, or the short sha when HEAD is detached.
323
+
324
+ Raises:
325
+ GitError: When git fails.
326
+ """
327
+ branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
328
+ if branch != "HEAD":
329
+ return branch
330
+ return _run_git(["rev-parse", "--short", "HEAD"], cwd=cwd)
331
+
332
+
333
+ def resolve_base(base: str, cwd: Path) -> str:
334
+ """Resolve the base ref, falling back to its remote-tracking form.
335
+
336
+ A fresh clone often has no local ``main`` — only ``origin/main`` —
337
+ and asking for a diff against a ref that does not exist is the most
338
+ common way this command fails. When ``base`` does not resolve,
339
+ ``origin/<base>`` is tried before giving up.
340
+
341
+ Args:
342
+ base (str): The base ref as the user typed it.
343
+ cwd (Path): A directory inside the repository.
344
+
345
+ Returns:
346
+ str: A ref that resolves to a commit.
347
+
348
+ Raises:
349
+ GitError: When neither ``base`` nor ``origin/<base>`` exists.
350
+ """
351
+ for candidate in (base, f"origin/{base}"):
352
+ try:
353
+ _run_git(
354
+ ["rev-parse", "--verify", "--quiet", f"{candidate}^{{commit}}"], cwd=cwd
355
+ )
356
+ except GitError:
357
+ continue
358
+ return candidate
359
+ raise GitError(
360
+ f"base ref {base!r} does not exist (nor does origin/{base}). "
361
+ "Pass an existing branch, tag or commit."
362
+ )
363
+
364
+
365
+ def commit_subjects(base: str, head: str, cwd: Path) -> list[str]:
366
+ """Return the subjects of the commits ``head`` has and ``base`` lacks.
367
+
368
+ Args:
369
+ base (str): The base ref.
370
+ head (str): The branch being described.
371
+ cwd (Path): A directory inside the repository.
372
+
373
+ Returns:
374
+ list[str]: Commit subjects, newest first. Empty when the branch
375
+ adds no commits.
376
+
377
+ Raises:
378
+ GitError: When git fails.
379
+ """
380
+ output = _run_git(
381
+ ["log", "--no-merges", "--pretty=format:%s", f"{base}..{head}"],
382
+ cwd=cwd,
383
+ )
384
+ return [line for line in output.splitlines() if line.strip()]
385
+
386
+
387
+ def changed_files(base: str, head: str, cwd: Path) -> list[str]:
388
+ """Return the ``--name-status`` lines of the merge-base diff.
389
+
390
+ Args:
391
+ base (str): The base ref.
392
+ head (str): The branch being described.
393
+ cwd (Path): A directory inside the repository.
394
+
395
+ Returns:
396
+ list[str]: Lines such as ``"M\\tsrc/api/app.py"``.
397
+
398
+ Raises:
399
+ GitError: When git fails.
400
+ """
401
+ output = _run_git(["diff", "--name-status", f"{base}...{head}"], cwd=cwd)
402
+ return [line for line in output.splitlines() if line.strip()]
403
+
404
+
405
+ def files_by_churn(base: str, head: str, cwd: Path) -> list[str]:
406
+ """Return the changed text files, most changed lines first.
407
+
408
+ Ranking matters because only the first ``max_files`` files get a
409
+ patch: taking them in git's alphabetical order spends the budget on
410
+ ``.github/`` and ``CHANGELOG.md`` while the file the pull request is
411
+ actually about never reaches the model. Binary files are dropped —
412
+ their patch says ``Binary files differ`` and nothing else.
413
+
414
+ Args:
415
+ base (str): The base ref.
416
+ head (str): The branch being described.
417
+ cwd (Path): A directory inside the repository.
418
+
419
+ Returns:
420
+ list[str]: Paths ordered by added+deleted lines, descending.
421
+
422
+ Raises:
423
+ GitError: When git fails.
424
+ """
425
+ ranked: list[tuple[int, int, str]] = []
426
+ output = _run_git(
427
+ ["diff", "--numstat", "--no-renames", f"{base}...{head}"], cwd=cwd
428
+ )
429
+ for position, line in enumerate(output.splitlines()):
430
+ added, _, rest = line.partition("\t")
431
+ deleted, _, path = rest.partition("\t")
432
+ if not path.strip() or added == "-" or deleted == "-":
433
+ continue
434
+ ranked.append((int(added) + int(deleted), position, path))
435
+ ranked.sort(key=lambda entry: (-entry[0], entry[1]))
436
+ return [path for _, _, path in ranked]
437
+
438
+
439
+ def diff_excerpts(
440
+ base: str,
441
+ head: str,
442
+ cwd: Path,
443
+ *,
444
+ max_files: int | None = DEFAULT_MAX_FILES,
445
+ max_chars: int | None = DEFAULT_MAX_CHARS,
446
+ ) -> tuple[list[DiffExcerpt], int]:
447
+ """Collect a bounded patch excerpt per changed file.
448
+
449
+ The whole diff of a large branch is bigger than most context windows
450
+ and mostly noise, so by default only the ``max_files`` most-changed
451
+ files contribute a patch and each is cut at ``max_chars``. Both
452
+ bounds are reported back to the caller rather than applied silently,
453
+ and ``None`` lifts either one.
454
+
455
+ Args:
456
+ base (str): The base ref.
457
+ head (str): The branch being described.
458
+ cwd (Path): A directory inside the repository.
459
+ max_files (int | None): How many files get an excerpt. ``0``
460
+ disables excerpts entirely, ``None`` excerpts every file.
461
+ max_chars (int | None): Characters kept per patch, or ``None``
462
+ for the whole patch.
463
+
464
+ Returns:
465
+ tuple[list[DiffExcerpt], int]: The excerpts and how many changed
466
+ files were left without one.
467
+
468
+ Raises:
469
+ GitError: When git fails.
470
+ """
471
+ names = files_by_churn(base, head, cwd)
472
+ if max_files is not None and max_files <= 0:
473
+ return [], len(names)
474
+
475
+ selected = names if max_files is None else names[:max_files]
476
+ excerpts: list[DiffExcerpt] = []
477
+ for name in selected:
478
+ patch = _run_git(["diff", f"{base}...{head}", "--", name], cwd=cwd)
479
+ if not patch.strip():
480
+ continue
481
+ if max_chars is not None and max_chars < len(patch):
482
+ excerpts.append(
483
+ DiffExcerpt(path=name, patch=_cut(patch, max_chars), truncated=True)
484
+ )
485
+ else:
486
+ excerpts.append(DiffExcerpt(path=name, patch=patch, truncated=False))
487
+ return excerpts, len(names) - len(selected)
488
+
489
+
490
+ def _cut(patch: str, max_chars: int) -> str:
491
+ """Trim a patch to ``max_chars`` without leaving half a diff line.
492
+
493
+ A hard slice ends mid-token, and a diff line that starts with ``-``
494
+ or ``+`` but stops in the middle of an expression reads as code that
495
+ does not exist. Cutting back to the last newline costs a few
496
+ characters and keeps every line in the excerpt a real one.
497
+
498
+ Args:
499
+ patch (str): The full patch.
500
+ max_chars (int): The character budget.
501
+
502
+ Returns:
503
+ str: The trimmed patch.
504
+ """
505
+ head = patch[:max_chars]
506
+ boundary = head.rfind("\n")
507
+ return head[:boundary] if boundary > 0 else head
508
+
509
+
510
+ def bundled_template(language: PromptLanguage) -> str:
511
+ """Return the SDK's own pull-request template for a language.
512
+
513
+ Args:
514
+ language (PromptLanguage): Which translation to load.
515
+
516
+ Returns:
517
+ str: The template's markdown.
518
+ """
519
+ filename = {
520
+ PromptLanguage.PT_BR: "pull_request_template.pt-BR.md",
521
+ PromptLanguage.EN_US: "pull_request_template.en-US.md",
522
+ }[language]
523
+ resource = importlib.resources.files("tempest_cli") / "_templates" / filename
524
+ return resource.read_text(encoding="utf-8")
525
+
526
+
527
+ def resolve_template(
528
+ root: Path,
529
+ *,
530
+ template: Path | None = None,
531
+ language: PromptLanguage = PromptLanguage.PT_BR,
532
+ ) -> ResolvedTemplate:
533
+ """Pick the pull-request template to hand the model.
534
+
535
+ The repository's own template always wins: it is the contract that
536
+ repository's reviewers read, and a generated description that ignores
537
+ it is a description someone has to rewrite. The bundled default only
538
+ covers the repository that has none.
539
+
540
+ Args:
541
+ root (Path): The repository root, where the candidates are looked
542
+ up.
543
+ template (Path | None): An explicit template path, which wins
544
+ over both the repository's and the bundled one.
545
+ language (PromptLanguage): Language of the bundled fallback.
546
+
547
+ Returns:
548
+ ResolvedTemplate: The template text and where it came from.
549
+
550
+ Raises:
551
+ GitError: When an explicit ``--template`` path does not exist.
552
+ """
553
+ if template is not None:
554
+ path = template.expanduser()
555
+ if not path.is_file():
556
+ raise GitError(f"template file not found: {path}")
557
+ return ResolvedTemplate(
558
+ text=path.read_text(encoding="utf-8"),
559
+ source=str(path),
560
+ bundled=False,
561
+ )
562
+
563
+ for candidate in TEMPLATE_CANDIDATES:
564
+ path = root / candidate
565
+ if path.is_file():
566
+ return ResolvedTemplate(
567
+ text=path.read_text(encoding="utf-8"),
568
+ source=candidate,
569
+ bundled=False,
570
+ )
571
+
572
+ return ResolvedTemplate(
573
+ text=bundled_template(language),
574
+ source=f"bundled ({language.value})",
575
+ bundled=True,
576
+ )
577
+
578
+
579
+ def collect_context(
580
+ *,
581
+ base: str = DEFAULT_BASE,
582
+ head: str | None = None,
583
+ cwd: Path | None = None,
584
+ max_files: int | None = DEFAULT_MAX_FILES,
585
+ max_chars: int | None = DEFAULT_MAX_CHARS,
586
+ ) -> PullRequestContext:
587
+ """Read one branch comparison out of the repository.
588
+
589
+ Commits and the changed-file list are always complete; only the patch
590
+ excerpts are bounded.
591
+
592
+ Args:
593
+ base (str): The base ref the pull request targets.
594
+ head (str | None): The branch being described. Defaults to the
595
+ checked-out one.
596
+ cwd (Path | None): Any directory inside the repository. Defaults
597
+ to the current working directory.
598
+ max_files (int | None): How many files contribute a patch
599
+ excerpt. ``0`` drops every patch, ``None`` excerpts them all.
600
+ max_chars (int | None): Characters kept per patch, or ``None``
601
+ for the whole patch.
602
+
603
+ Returns:
604
+ PullRequestContext: Commits, changed files and bounded excerpts.
605
+
606
+ Raises:
607
+ GitError: When the directory is not a repository, the base ref
608
+ does not resolve, or git fails.
609
+ """
610
+ working_dir = (cwd or Path.cwd()).expanduser().resolve()
611
+ root = repository_root(working_dir)
612
+ resolved_base = resolve_base(base, root)
613
+ resolved_head = head or current_branch(root)
614
+ excerpts, omitted = diff_excerpts(
615
+ resolved_base,
616
+ resolved_head,
617
+ root,
618
+ max_files=max_files,
619
+ max_chars=max_chars,
620
+ )
621
+ return PullRequestContext(
622
+ repository=repository_name(root),
623
+ base=resolved_base,
624
+ head=resolved_head,
625
+ commits=commit_subjects(resolved_base, resolved_head, root),
626
+ files=changed_files(resolved_base, resolved_head, root),
627
+ excerpts=excerpts,
628
+ omitted_files=omitted,
629
+ )
630
+
631
+
632
+ def build_prompt(
633
+ context: PullRequestContext,
634
+ template: ResolvedTemplate,
635
+ *,
636
+ language: PromptLanguage = PromptLanguage.PT_BR,
637
+ ) -> str:
638
+ """Render the final prompt from a context and a template.
639
+
640
+ Args:
641
+ context (PullRequestContext): What the branch changed.
642
+ template (ResolvedTemplate): The template to be filled in.
643
+ language (PromptLanguage): Language of the instructions around
644
+ the template.
645
+
646
+ Returns:
647
+ str: The complete prompt, ready to be piped into an assistant.
648
+ """
649
+ labels = _PROMPT_HEADERS[language]
650
+ rules = "\n".join(
651
+ f"{index}. {rule}" for index, rule in enumerate(_PROMPT_RULES[language], 1)
652
+ )
653
+ commits = (
654
+ "\n".join(f"- {subject}" for subject in context.commits)
655
+ or (labels["no_commits"])
656
+ )
657
+ files = "\n".join(context.files) or labels["no_files"]
658
+
659
+ patches: list[str] = []
660
+ for excerpt in context.excerpts:
661
+ note = f"\n{labels['truncated']}" if excerpt.truncated else ""
662
+ patches.append(
663
+ f"#### {excerpt.path}\n```diff\n{excerpt.patch}\n```{note}",
664
+ )
665
+ if context.omitted_files:
666
+ patches.append(labels["omitted"].format(count=context.omitted_files))
667
+ patch_block = "\n\n".join(patches) or labels["no_patches"]
668
+
669
+ return "\n".join(
670
+ (
671
+ labels["role"],
672
+ "",
673
+ f"## {labels['rules']}",
674
+ "",
675
+ rules,
676
+ "",
677
+ "---",
678
+ "",
679
+ f"## {labels['template']}",
680
+ "",
681
+ template.text.strip(),
682
+ "",
683
+ "---",
684
+ "",
685
+ f"## {labels['context']}",
686
+ "",
687
+ f"{labels['repository']}: {context.repository}",
688
+ f"{labels['branch']}: `{context.head}` <- `{context.base}`",
689
+ "",
690
+ f"### {labels['commits']}",
691
+ "",
692
+ commits,
693
+ "",
694
+ f"### {labels['files']}",
695
+ "",
696
+ files,
697
+ "",
698
+ f"### {labels['patches']}",
699
+ "",
700
+ patch_block,
701
+ "",
702
+ "---",
703
+ "",
704
+ labels["closing"],
705
+ "",
706
+ )
707
+ )
708
+
709
+
710
+ def generate_pr_prompt(
711
+ *,
712
+ base: str = DEFAULT_BASE,
713
+ head: str | None = None,
714
+ cwd: Path | None = None,
715
+ template: Path | None = None,
716
+ language: PromptLanguage = PromptLanguage.PT_BR,
717
+ max_files: int | None = DEFAULT_MAX_FILES,
718
+ max_chars: int | None = DEFAULT_MAX_CHARS,
719
+ ) -> tuple[str, PullRequestContext, ResolvedTemplate]:
720
+ """Read the repository and render the prompt in one call.
721
+
722
+ Args:
723
+ base (str): The base ref the pull request targets.
724
+ head (str | None): The branch being described. Defaults to the
725
+ checked-out one.
726
+ cwd (Path | None): Any directory inside the repository.
727
+ template (Path | None): An explicit template path.
728
+ language (PromptLanguage): Language of the instructions and of
729
+ the bundled fallback template.
730
+ max_files (int | None): How many files contribute a patch
731
+ excerpt. ``0`` drops every patch, ``None`` excerpts them all.
732
+ max_chars (int | None): Characters kept per patch, or ``None``
733
+ for the whole patch.
734
+
735
+ Returns:
736
+ tuple[str, PullRequestContext, ResolvedTemplate]: The prompt plus
737
+ the context and template it was built from, so a caller can
738
+ report what was read and what was dropped.
739
+
740
+ Raises:
741
+ GitError: When the repository, the base ref or the template path
742
+ cannot be resolved.
743
+ """
744
+ context = collect_context(
745
+ base=base,
746
+ head=head,
747
+ cwd=cwd,
748
+ max_files=max_files,
749
+ max_chars=max_chars,
750
+ )
751
+ root = repository_root((cwd or Path.cwd()).expanduser().resolve())
752
+ resolved_template = resolve_template(root, template=template, language=language)
753
+ prompt = build_prompt(context, resolved_template, language=language)
754
+ return prompt, context, resolved_template
755
+
756
+
757
+ __all__: list[str] = [
758
+ "DEFAULT_BASE",
759
+ "DEFAULT_MAX_CHARS",
760
+ "DEFAULT_MAX_FILES",
761
+ "TEMPLATE_CANDIDATES",
762
+ "DiffExcerpt",
763
+ "GitError",
764
+ "PromptLanguage",
765
+ "PullRequestContext",
766
+ "ResolvedTemplate",
767
+ "build_prompt",
768
+ "bundled_template",
769
+ "changed_files",
770
+ "collect_context",
771
+ "commit_subjects",
772
+ "current_branch",
773
+ "diff_excerpts",
774
+ "generate_pr_prompt",
775
+ "repository_name",
776
+ "repository_root",
777
+ "resolve_base",
778
+ "resolve_template",
779
+ ]