136136every 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 )
141149class 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+
11391210RELEASE_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+
11471231def 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 "
0 commit comments