Skip to content

Commit e8ce1fe

Browse files
committed
Shorten the release PR's unshippable-dependency banner
A paragraph, a docs link and a four-column table read as chatter in a pull request whose body is otherwise a five-step checklist. The banner is now one line naming each blocked package, linked to the line declaring it at the commit the body was rendered from. The long form stays in the `lint-deps` report, where a reader came for the diagnosis.
1 parent 8221da2 commit e8ce1fe

4 files changed

Lines changed: 151 additions & 25 deletions

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
> [!WARNING]
66
> This version is **not released yet** and is under active development.
77
8+
- The release PR's unshippable-dependency warning is now a single line naming each package, each linked to the line declaring it, instead of a paragraph and a four-column table.
89
- The bundled `[tool.typos]` config now accepts `PNGs`, which typos otherwise splits and rewrites to `ONGs`.
910

1011
## [`7.12.1` (2026-08-15)](https://github.com/kdeldycke/repomatic/compare/v7.12.0...v7.12.1)

repomatic/cli.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1170,11 +1170,16 @@ def _review_step(key: str) -> str:
11701170
# override built-in flag-driven sources so callers can pass any name.
11711171
def _release_readiness() -> str:
11721172
config = get_tool_config()
1173+
# Pinned to the commit the body was rendered from, so the line the
1174+
# banner points at is the line that was read. A branch ref would drift
1175+
# onto whatever `main` holds when the maintainer clicks it.
1176+
blob_url = f"{md.repo_url}/blob/{md.sha}" if md.repo_url and md.sha else None
11731177
return build_release_readiness(
11741178
Path("pyproject.toml"),
11751179
Path("uv.lock"),
11761180
config.minimum_release_age,
11771181
allow=config.lint_deps.allow,
1182+
source_url=blob_url,
11781183
)
11791184

11801185
arg_sources: dict[str, str | None | Callable[[], str | None]] = {

repomatic/dep_sources.py

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@
136136
every other clause byte-for-byte untouched.
137137
"""
138138

139+
TOML_TABLE_HEADER = re.compile(r"\s*\[{1,2}\s*(?P<path>[^]]+?)\s*\]{1,2}\s*$")
140+
"""A `[table]` or `[[array of tables]]` header, capturing its dotted path.
141+
142+
Enough TOML parsing for {func}`declaration_anchor` to tell which table a line
143+
sits in. The parsed document cannot answer that: `tomllib` and `tomlkit` both
144+
return values, and a line number is what a link needs.
145+
"""
146+
139147

140148
@dataclass(frozen=True)
141149
class ReleaseSwap:
@@ -1107,14 +1115,16 @@ def format_blocker_section(
11071115
findings: list[DepFinding],
11081116
*,
11091117
heading: str = "🚧 Unshippable dependencies",
1110-
name_urls: dict[str, str] | None = None,
11111118
) -> str:
11121119
"""Format blocking findings as a markdown section.
11131120
1121+
The long form, for the `lint-deps` report: a reader who opened that report
1122+
came for the diagnosis, so it carries the note and the full table. The
1123+
release PR gets {func}`build_release_readiness` instead, which is the same
1124+
findings at banner length.
1125+
11141126
:param findings: Findings from {func}`scan_project`.
1115-
:param heading: Section heading, emoji included. Pass an empty string to
1116-
emit the note and table alone, for a caller supplying its own title.
1117-
:param name_urls: Optional mapping of names to a URL the name links to.
1127+
:param heading: Section heading, emoji included.
11181128
:return: A markdown string, or an empty string when nothing blocks.
11191129
"""
11201130
blocking = [finding for finding in findings if finding.blocking]
@@ -1126,7 +1136,7 @@ def format_blocker_section(
11261136
("Package", "Source", "Declared in", "Why it cannot ship"),
11271137
[
11281138
(
1129-
link_name(finding.package, name_urls),
1139+
f"`{finding.package}`",
11301140
f"`{finding.kind}`",
11311141
f"`{finding.location}`",
11321142
f"{finding.consequence} {finding.remedy}",
@@ -1136,6 +1146,67 @@ def format_blocker_section(
11361146
)
11371147

11381148

1149+
def declaration_anchor(
1150+
finding: DepFinding,
1151+
pyproject_path: Path,
1152+
lock_path: Path,
1153+
) -> str:
1154+
"""Locate the declaration behind a finding, as a repository-relative link.
1155+
1156+
Only two files can hold one: `uv.lock` for a source the resolver picked,
1157+
`pyproject.toml` for everything the project wrote itself, dependency
1158+
floors included.
1159+
1160+
:param finding: The finding to locate.
1161+
:param pyproject_path: Path to the `pyproject.toml` file.
1162+
:param lock_path: Path to the `uv.lock` file.
1163+
:return: The file name, suffixed with `#L{n}` once the declaring line is
1164+
found. A line that cannot be found degrades to the bare file rather
1165+
than to a guess: an anchor pointing at the wrong line costs the reader
1166+
more than no anchor at all.
1167+
"""
1168+
in_lock = finding.location == "uv.lock"
1169+
path = lock_path if in_lock else pyproject_path
1170+
# A canonical name separates its parts with "-", where the declaration may
1171+
# spell any of "-", "_" or "." and pick its own case.
1172+
stem = "[-_.]+".join(re.escape(part) for part in finding.package.split("-"))
1173+
# `uv.lock` names a package on the `name` key of its `[[package]]` block;
1174+
# `pyproject.toml` writes it inside a requirement string or as a table key,
1175+
# so match it as a bare token there.
1176+
pattern = re.compile(
1177+
rf'name = "{stem}"' if in_lock else rf"(?<![\w.-]){stem}(?![\w-])",
1178+
re.IGNORECASE,
1179+
)
1180+
try:
1181+
lines = path.read_text(encoding="UTF-8").splitlines()
1182+
except OSError:
1183+
return path.name
1184+
1185+
first = 0
1186+
best = 0
1187+
best_depth = -1
1188+
table = ""
1189+
for number, line in enumerate(lines, start=1):
1190+
header = TOML_TABLE_HEADER.match(line)
1191+
if header:
1192+
table = header["path"]
1193+
if not pattern.search(line):
1194+
continue
1195+
if not first:
1196+
first = number
1197+
# A package is named wherever it is required, so a plain first-match
1198+
# search lands on the requirement rather than on the source override
1199+
# that made it unshippable. Prefer the deepest table the finding's own
1200+
# location sits under: that is the declaration to edit, where the
1201+
# others merely mention the package.
1202+
in_scope = finding.location == table or finding.location.startswith(f"{table}.")
1203+
if table and in_scope and len(table) > best_depth:
1204+
best, best_depth = number, len(table)
1205+
1206+
number = best or first
1207+
return f"{path.name}#L{number}" if number else path.name
1208+
1209+
11391210
RELEASE_READY_SENTENCE = "This PR is ready to be merged. "
11401211
"""How the release checklist opens when nothing blocks.
11411212
@@ -1144,11 +1215,25 @@ def format_blocker_section(
11441215
"""
11451216

11461217

1218+
UNSHIPPABLE_BANNER_LEAD = (
1219+
"Do not merge yet: this release would ship dependencies its users cannot install:"
1220+
)
1221+
"""Opening of the blocked form of the release PR's verdict.
1222+
1223+
The banner is a verdict, not a report: it says what is wrong and names what to
1224+
open. Everything else the finding carries (why the source is unshippable, what
1225+
to do about it, the general rule) reads as chatter in a pull request whose
1226+
body is otherwise a five-step checklist, and it is one click away in the
1227+
`lint-deps` report {func}`format_blocker_section` renders.
1228+
"""
1229+
1230+
11471231
def build_release_readiness(
11481232
pyproject_path: Path,
11491233
lock_path: Path,
11501234
window: str,
11511235
allow: dict[str, str] | None = None,
1236+
source_url: str | None = None,
11521237
) -> str:
11531238
"""Build the release PR's opening verdict.
11541239
@@ -1177,20 +1262,34 @@ def build_release_readiness(
11771262
:param lock_path: Path to the `uv.lock` file.
11781263
:param window: Cooldown window, from `[tool.repomatic] minimum-release-age`.
11791264
:param allow: Package name to its `lint-deps.allow` reason.
1180-
:return: {data}`RELEASE_READY_SENTENCE`, or a GitHub-flavored markdown
1181-
`[!CAUTION]` blockquote listing what blocks the release.
1265+
:param source_url: Blob URL the declarations hang off, without a trailing
1266+
slash (like ``{repo_url}/blob/{sha}``). Each package links into it. A
1267+
caller with no commit to point at passes nothing, and the packages
1268+
render with their file and line as plain text instead.
1269+
:return: {data}`RELEASE_READY_SENTENCE`, or a one-line GitHub-flavored
1270+
markdown `[!CAUTION]` blockquote naming what blocks the release.
11821271
"""
1183-
section = format_blocker_section(
1184-
scan_project(pyproject_path, lock_path, window, allow=allow),
1185-
heading="",
1186-
)
1187-
if not section:
1272+
blocking = [
1273+
finding
1274+
for finding in scan_project(pyproject_path, lock_path, window, allow=allow)
1275+
if finding.blocking
1276+
]
1277+
if not blocking:
11881278
return RELEASE_READY_SENTENCE
1189-
quoted = "\n".join(f"> {line}".rstrip() for line in section.splitlines())
1190-
return (
1191-
"> [!CAUTION]\n"
1192-
"> **Do not merge yet: this release would ship dependencies its users"
1193-
" cannot install.**\n"
1194-
">\n"
1195-
f"{quoted}\n\n"
1279+
# One link per package: a floor and a source override on the same package
1280+
# send the reader to the same file, and a name repeated in a one-line
1281+
# banner reads as two separate problems. Findings arrive sorted by package
1282+
# then location, so the first one kept is the one nearest to an edit.
1283+
anchors: dict[str, str] = {}
1284+
for finding in blocking:
1285+
if finding.package not in anchors:
1286+
anchors[finding.package] = declaration_anchor(
1287+
finding, pyproject_path, lock_path
1288+
)
1289+
links = ", ".join(
1290+
f"[`{package}`]({source_url}/{anchor})"
1291+
if source_url
1292+
else f"`{package}` ({anchor})"
1293+
for package, anchor in anchors.items()
11961294
)
1295+
return f"> [!CAUTION]\n> **{UNSHIPPABLE_BANNER_LEAD}** {links}\n\n"

tests/test_dep_sources.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
from repomatic import dep_sources
2626
from repomatic.config import Config
2727
from repomatic.dep_sources import (
28+
BLOCKER_SECTION_NOTE,
2829
RELEASE_READY_SENTENCE,
30+
UNSHIPPABLE_BANNER_LEAD,
2931
ReleaseSwap,
3032
SourceKind,
3133
apply_release_swaps,
@@ -674,13 +676,32 @@ def test_release_readiness_flips_the_pr_opening(tmp_path: Path) -> None:
674676
)
675677
banner = build_release_readiness(pyproject, lock, "1 week")
676678
assert banner.startswith("> [!CAUTION]")
677-
assert "Do not merge yet" in banner
678-
assert "cherry" in banner
679-
# Every line of the table is quoted, or GitHub renders half of it outside
680-
# the admonition.
681-
assert all(
682-
line.startswith(">") for line in banner.strip().splitlines() if line.strip()
679+
assert UNSHIPPABLE_BANNER_LEAD in banner
680+
# A verdict, not a report: the alert marker and one line naming the
681+
# package, with none of the prose or the table `lint-deps` renders.
682+
lines = [line for line in banner.strip().splitlines() if line.strip()]
683+
assert len(lines) == 2
684+
assert BLOCKER_SECTION_NOTE not in banner
685+
assert "| Package |" not in banner
686+
# Both lines stay quoted, or GitHub renders half of it outside the
687+
# admonition.
688+
assert all(line.startswith(">") for line in lines)
689+
690+
# The package points at the line declaring it, linked when the caller
691+
# supplies a commit to hang the blob URL off, plain text otherwise.
692+
declared_line = next(
693+
number
694+
for number, line in enumerate(
695+
pyproject.read_text(encoding="UTF-8").splitlines(), start=1
696+
)
697+
if line.startswith("cherry =")
698+
)
699+
declaration = f"pyproject.toml#L{declared_line}"
700+
assert f"`cherry` ({declaration})" in banner
701+
linked = build_release_readiness(
702+
pyproject, lock, "1 week", source_url="https://x/repo/blob/deadbeef"
683703
)
704+
assert f"[`cherry`](https://x/repo/blob/deadbeef/{declaration})" in linked
684705

685706

686707
def test_project_ships_only_released_dependencies() -> None:

0 commit comments

Comments
 (0)