diff --git a/.github/scripts/enrich-dependabot-pr.py b/.github/scripts/enrich-dependabot-pr.py index 52fa03f..3aee9c0 100755 --- a/.github/scripts/enrich-dependabot-pr.py +++ b/.github/scripts/enrich-dependabot-pr.py @@ -14,14 +14,24 @@ BUMP_TITLE_RE = re.compile( - r"(?:bump|update)\s+([^\s]+)\s+from\s+([^\s]+)\s+to\s+([^\s]+)", + r"(?:bump|update)\s+([^\s]+)(?:\s+requirements?)?\s+from\s+([^\s]+)\s+to\s+([^\s]+)", re.IGNORECASE, ) BUMP_BODY_RE = re.compile( r"Bumps\s+\[(?P[^\]]+)\]\([^)]+\)\s+from\s+(?P[^\s]+)\s+to\s+(?P[^\s]+)", re.IGNORECASE, ) +GROUP_BODY_RE = re.compile( + r"Bumps\s+the\s+(?P[^\s]+)\s+group\s+with\s+(?P\d+)\s+updates?:", + re.IGNORECASE, +) +GROUP_UPDATE_RE = re.compile( + r"^Updates\s+`(?P[^`]+)`\s+from\s+(?P[^\s]+)\s+to\s+(?P[^\s]+)", + re.IGNORECASE | re.MULTILINE, +) MARKER = "" +ORIGINAL_SUMMARY = "Original Dependabot description" +VERSION_PREFIX_RE = re.compile(r"^[<>=!~,\s]+") @dataclass(frozen=True) @@ -29,6 +39,8 @@ class Bump: name: str old_version: str new_version: str + updates: tuple[Bump, ...] = () + group: str | None = None @dataclass(frozen=True) @@ -65,18 +77,90 @@ def _first_url(project_urls: dict[str, str] | None, *keys: str) -> str | None: return None -def _parse_bump(title: str, body: str) -> Bump | None: - for source in (title, body): - match = BUMP_TITLE_RE.search(source) - if match: - return Bump(name=match.group(1), old_version=match.group(2), new_version=match.group(3)) - body_match = BUMP_BODY_RE.search(body) - if body_match: - return Bump( - name=body_match.group("name"), - old_version=body_match.group("old"), - new_version=body_match.group("new"), +def _clean_token(value: str) -> str: + return value.strip().strip("`").split("<", 1)[0].rstrip(".,;:)") + + +def _registry_version(value: str) -> str: + return VERSION_PREFIX_RE.sub("", value) + + +def _bump(name: str, old_version: str, new_version: str) -> Bump: + return Bump( + name=_clean_token(name), + old_version=_clean_token(old_version), + new_version=_clean_token(new_version), + ) + + +def _parse_metadata_updates(payload: str) -> tuple[Bump, ...]: + if not payload: + return () + try: + raw_updates = json.loads(payload) + except json.JSONDecodeError: + return () + if not isinstance(raw_updates, list): + return () + + updates: list[Bump] = [] + for raw_update in raw_updates: + if not isinstance(raw_update, dict): + continue + name = raw_update.get("dependencyName") or raw_update.get("dependency-name") or raw_update.get("name") + old_version = ( + raw_update.get("previousVersion") + or raw_update.get("previous-version") + or raw_update.get("previous_version") ) + new_version = raw_update.get("newVersion") or raw_update.get("new-version") or raw_update.get("new_version") + if isinstance(name, str) and isinstance(old_version, str) and isinstance(new_version, str): + updates.append(_bump(name, old_version, new_version)) + return tuple(updates) + + +def _parse_grouped_bump(body: str, *, dependency_group: str = "", metadata_updates: tuple[Bump, ...] = ()) -> Bump | None: + intro = body.split("
", 1)[0] + group_match = GROUP_BODY_RE.search(intro) + if not group_match and not dependency_group: + return None + + if dependency_group: + group = dependency_group + elif group_match: + group = group_match.group("group") + else: + return None + body_updates = tuple( + _bump(match.group("name"), match.group("old"), match.group("new")) + for match in GROUP_UPDATE_RE.finditer(body) + ) + updates = metadata_updates or body_updates + if len(updates) < 2: + return None + return Bump(name=f"{group} group", old_version="", new_version="", updates=updates, group=group) + + +def _parse_bump( + title: str, + body: str, + *, + dependency_group: str = "", + updated_dependencies_json: str = "", +) -> Bump | None: + metadata_updates = _parse_metadata_updates(updated_dependencies_json) + grouped_bump = _parse_grouped_bump(body, dependency_group=dependency_group, metadata_updates=metadata_updates) + if grouped_bump: + return grouped_bump + + title_match = BUMP_TITLE_RE.search(title) + if title_match: + return _bump(title_match.group(1), title_match.group(2), title_match.group(3)) + + intro = body.split("
", 1)[0] + body_match = BUMP_BODY_RE.search(intro) + if body_match: + return _bump(body_match.group("name"), body_match.group("old"), body_match.group("new")) return None @@ -93,7 +177,7 @@ def _pypi_info(package: str, version: str) -> RegistryInfo | None: changelog=_first_url(project_urls, "Changelog", "Release notes", "Changes"), documentation=info.get("docs_url") or _first_url(project_urls, "Documentation", "Docs"), license=(info.get("license") or "").strip() or None, - published=(payload.get("urls") or [{}])[0].get("upload_time") if payload.get("urls") else None, + published=(payload.get("urls") or [{}])[0].get("upload_time"), ) @@ -128,6 +212,10 @@ def _github_actions_info(action: str) -> RegistryInfo | None: ) +def _is_github_actions(ecosystem: str) -> bool: + return ecosystem in {"github-actions", "github_actions"} + + def _dependency_type_label(raw: str | None) -> str: if not raw: return "unknown" @@ -141,7 +229,13 @@ def _dependency_type_label(raw: str | None) -> str: def _update_type_label(raw: str | None) -> str: if not raw: return "unknown" - return raw.removeprefix("semver_") + if raw.startswith("version-update:semver-"): + return raw.removeprefix("version-update:semver-") + if raw.startswith("semver_"): + return raw.removeprefix("semver_") + if raw.startswith("semver-"): + return raw.removeprefix("semver-") + return raw def _build_title( @@ -152,10 +246,14 @@ def _build_title( update_type: str, summary: str, ) -> str: - short_summary = summary.split(".", 1)[0].strip() if summary else "" - context = short_summary[:72] + ("…" if len(short_summary) > 72 else "") if short_summary else ecosystem dep_tag = "dev" if dependency_type == "development" else "prod" update_tag = _update_type_label(update_type) + if bump.updates: + update_label = f"{len(bump.updates)} {update_tag} updates" if update_tag != "unknown" else f"{len(bump.updates)} updates" + return f"deps({dep_tag}): {bump.name} ({update_label}; {ecosystem})" + + short_summary = summary.split(".", 1)[0].strip() if summary else "" + context = short_summary[:72] + ("…" if len(short_summary) > 72 else "") if short_summary else ecosystem return ( f"deps({dep_tag}): {bump.name} {bump.old_version} → {bump.new_version} " f"({update_tag}; {context})" @@ -165,7 +263,20 @@ def _build_title( def _strip_existing_enrichment(body: str) -> str: if MARKER not in body: return body.strip() - return body.split(MARKER, 1)[0].strip() + summary_index = body.find(ORIGINAL_SUMMARY) + if summary_index == -1: + return body.strip() + original = body[summary_index + len(ORIGINAL_SUMMARY) :].strip() + if original.endswith("
"): + original = original[: -len("
")].strip() + return original + + +def _write_skip_output() -> None: + output_dir = os.environ.get("GITHUB_OUTPUT") + if output_dir: + with open(output_dir, "a", encoding="utf-8") as handle: + handle.write("skip=true\n") def _build_body( @@ -178,6 +289,7 @@ def _build_body( original_body: str, ) -> str: original = _strip_existing_enrichment(original_body) + versions = f"{len(bump.updates)} updates" if bump.updates else f"`{bump.old_version}` → `{bump.new_version}`" lines = [ MARKER, "## Dependency update", @@ -188,14 +300,28 @@ def _build_body( f"| Ecosystem | {ecosystem} |", f"| Dependency type | {_dependency_type_label(dependency_type)} |", f"| Update type | {_update_type_label(update_type)} |", - f"| Versions | `{bump.old_version}` → `{bump.new_version}` |", + f"| Versions | {versions} |", "", ] + if bump.updates: + lines.extend( + [ + "## Dependencies", + "", + "| Package | From | To |", + "| --- | --- | --- |", + *( + f"| `{update.name}` | `{update.old_version}` | `{update.new_version}` |" + for update in bump.updates + ), + "", + ] + ) if info and info.summary: lines.extend(["## About", "", info.summary, ""]) links: list[tuple[str, str]] = [] if ecosystem == "uv": - links.append(("PyPI", f"https://pypi.org/project/{bump.name}/{bump.new_version}/")) + links.append(("PyPI", f"https://pypi.org/project/{bump.name}/{_registry_version(bump.new_version)}/")) elif ecosystem == "cargo": links.append(("crates.io", f"https://crates.io/crates/{bump.name}/{bump.new_version}")) if info: @@ -229,35 +355,35 @@ def main() -> int: parser.add_argument("--ecosystem", default=os.environ.get("DEPENDABOT_ECOSYSTEM", "")) parser.add_argument("--dependency-type", default=os.environ.get("DEPENDABOT_DEPENDENCY_TYPE", "")) parser.add_argument("--update-type", default=os.environ.get("DEPENDABOT_UPDATE_TYPE", "")) + parser.add_argument("--dependency-group", default=os.environ.get("DEPENDABOT_DEPENDENCY_GROUP", "")) + parser.add_argument( + "--updated-dependencies-json", + default=os.environ.get("DEPENDABOT_UPDATED_DEPENDENCIES_JSON", ""), + ) parser.add_argument("--title-out", default="") parser.add_argument("--body-out", default="") args = parser.parse_args() - if MARKER in args.body: - if args.title_out: - with open(args.title_out, "w", encoding="utf-8") as handle: - handle.write(args.title) - if args.body_out: - with open(args.body_out, "w", encoding="utf-8") as handle: - handle.write(args.body) - output_dir = os.environ.get("GITHUB_OUTPUT") - if output_dir: - with open(output_dir, "a", encoding="utf-8") as handle: - handle.write("skip=true\n") - print("PR already enriched; skipping.", file=sys.stderr) - return 0 + original_body = _strip_existing_enrichment(args.body) - bump = _parse_bump(args.title, args.body) + bump = _parse_bump( + args.title, + original_body, + dependency_group=args.dependency_group, + updated_dependencies_json=args.updated_dependencies_json, + ) if bump is None: print("Could not parse dependency bump from PR title/body.", file=sys.stderr) return 1 info: RegistryInfo | None - if args.ecosystem == "uv": - info = _pypi_info(bump.name, bump.new_version) + if bump.updates: + info = None + elif args.ecosystem == "uv": + info = _pypi_info(bump.name, _registry_version(bump.new_version)) elif args.ecosystem == "cargo": info = _crates_info(bump.name, bump.new_version) - elif args.ecosystem == "github-actions": + elif _is_github_actions(args.ecosystem): info = _github_actions_info(bump.name) else: info = None @@ -275,9 +401,14 @@ def main() -> int: dependency_type=args.dependency_type, update_type=args.update_type, info=info, - original_body=args.body, + original_body=original_body, ) + if title == args.title and body == args.body: + _write_skip_output() + print("PR already enriched; skipping.", file=sys.stderr) + return 0 + if args.title_out: with open(args.title_out, "w", encoding="utf-8") as handle: handle.write(title) diff --git a/.github/workflows/dependabot-enrich.yml b/.github/workflows/dependabot-enrich.yml index e3f09aa..40356b1 100644 --- a/.github/workflows/dependabot-enrich.yml +++ b/.github/workflows/dependabot-enrich.yml @@ -33,6 +33,8 @@ jobs: DEPENDABOT_ECOSYSTEM: ${{ steps.metadata.outputs.package-ecosystem }} DEPENDABOT_DEPENDENCY_TYPE: ${{ steps.metadata.outputs.dependency-type }} DEPENDABOT_UPDATE_TYPE: ${{ steps.metadata.outputs.update-type }} + DEPENDABOT_DEPENDENCY_GROUP: ${{ steps.metadata.outputs.dependency-group }} + DEPENDABOT_UPDATED_DEPENDENCIES_JSON: ${{ steps.metadata.outputs.updated-dependencies-json }} PR_BODY: ${{ github.event.pull_request.body }} run: | python .github/scripts/enrich-dependabot-pr.py \