diff --git a/.github/workflows/bm-bossbot.yml b/.github/workflows/bm-bossbot.yml index 26d358f3..c89f15cf 100644 --- a/.github/workflows/bm-bossbot.yml +++ b/.github/workflows/bm-bossbot.yml @@ -312,12 +312,14 @@ jobs: PR_NUMBER: ${{ needs.review.outputs.pr_number }} run: | set -euo pipefail - gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json body --jq '.body // ""' > "${RUNNER_TEMP}/bm-bossbot-pr-body.md" - pr_title="$(gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json title --jq '.title // ""')" + # One delivery-context fetch (title/body/labels/files/commits/linked issues) + # so the image is grounded in the theme of the whole PR, not one artifact. + gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" \ + --json title,body,labels,files,commits,closingIssuesReferences \ + > "${RUNNER_TEMP}/bm-bossbot-pr-context.json" uv run --script scripts/generate_pr_infographic.py \ --pr-number "${PR_NUMBER}" \ - --pr-title "${pr_title}" \ - --pr-body-file "${RUNNER_TEMP}/bm-bossbot-pr-body.md" \ + --pr-context-file "${RUNNER_TEMP}/bm-bossbot-pr-context.json" \ --provenance-output "${RUNNER_TEMP}/bm-bossbot-image-provenance.md" \ --output "docs/assets/infographics/pr-${PR_NUMBER}.webp" diff --git a/scripts/generate_pr_infographic.py b/scripts/generate_pr_infographic.py index 728fd9a2..a1eac6d6 100755 --- a/scripts/generate_pr_infographic.py +++ b/scripts/generate_pr_infographic.py @@ -13,11 +13,12 @@ from __future__ import annotations import hashlib import html +import json import re from dataclasses import dataclass from enum import StrEnum from pathlib import Path -from typing import Annotated +from typing import Annotated, Any, Mapping import typer @@ -115,6 +116,66 @@ BM_IMAGE_THEME_POOL = ( ) +def build_change_shape( + context: Mapping[str, Any], + *, + max_commits: int = 10, + max_files: int = 10, +) -> str: + """Render a compact factual digest of the PR: labels, linked issues, commits, files. + + Mirrors the delivery context the Basic Memory CI capture flow collects + (ProjectUpdateContext): the goal is to ground the image in the theme of the + WHOLE change — what it touches and why — not just the title/description. + """ + lines: list[str] = [] + + labels = [str(label.get("name", "")) for label in context.get("labels") or []] + labels = [label for label in labels if label] + if labels: + lines.append(f"Labels: {', '.join(labels)}") + + issues = context.get("closingIssuesReferences") or [] + if issues: + lines.append("Linked issues:") + for issue in issues: + number = issue.get("number") + title = str(issue.get("title") or "").strip() + lines.append(f"- #{number}: {title}" if title else f"- #{number}") + + commits = context.get("commits") or [] + subjects = [str(commit.get("messageHeadline") or "").strip() for commit in commits] + # Merge commits carry no thematic signal — they're branch bookkeeping. + subjects = [ + subject + for subject in subjects + if subject and not subject.startswith(("Merge branch ", "Merge pull request ")) + ] + if subjects: + lines.append(f"Commit subjects ({len(subjects)} total):") + lines.extend(f"- {subject}" for subject in subjects[:max_commits]) + if len(subjects) > max_commits: + lines.append(f"- ... and {len(subjects) - max_commits} more") + + files = context.get("files") or [] + if files: + additions = sum(int(item.get("additions") or 0) for item in files) + deletions = sum(int(item.get("deletions") or 0) for item in files) + lines.append(f"Files changed ({len(files)} total, +{additions}/-{deletions}):") + ranked = sorted( + files, + key=lambda item: int(item.get("additions") or 0) + int(item.get("deletions") or 0), + reverse=True, + ) + for item in ranked[:max_files]: + path = str(item.get("path") or "") + lines.append(f"- {path} (+{item.get('additions') or 0}/-{item.get('deletions') or 0})") + if len(files) > max_files: + lines.append(f"- ... and {len(files) - max_files} more files") + + return "\n".join(lines) if lines else "(no additional change context available)" + + def extract_pr_content(pr_body: str) -> str: """Return the author's own PR description with all managed bot blocks removed.""" content = pr_body @@ -210,6 +271,7 @@ def build_infographic_prompt( pr_number: int, pr_title: str, pr_content: str, + change_shape: str, theme: str, theme_source: ThemeSource, ) -> str: @@ -223,8 +285,8 @@ def build_infographic_prompt( Create a polished landscape WebP editorial image for Basic Memory PR #{pr_number}. Your subject is the CONTENT of the pull request — what the change does and why -it matters — described in the title and description below. Express the theme of -the whole change as a visual story. +it matters — described in the title, description, and change shape below. +Express the theme of the whole change as a visual story. This image is decoration for the PR conversation. It is NOT a review artifact: do not depict review verdicts, approval, or process. Never render approval @@ -239,6 +301,12 @@ Pull request title: Pull request description: {pr_content} +Change shape — factual delivery context (labels, linked issues, commit +subjects, changed files). Use it to understand what the whole PR touches and +let that steer the imagery. It is context, NOT captions: never render file +paths, diff stats, issue numbers, or commit subjects verbatim in the image. +{change_shape} + {theme_label}: {theme} @@ -271,18 +339,17 @@ def generate( int, typer.Option("--pr-number", min=1, help="Pull request number."), ], - pr_title: Annotated[ - str, - typer.Option("--pr-title", help="Pull request title (the subject of the image)."), - ], - pr_body_file: Annotated[ + pr_context_file: Annotated[ Path, typer.Option( - "--pr-body-file", + "--pr-context-file", exists=True, dir_okay=False, readable=True, - help="File containing the pull request body.", + help=( + "JSON from `gh pr view --json " + "title,body,labels,files,commits,closingIssuesReferences`." + ), ), ], output: Annotated[Path, typer.Option("--output", help="Output .webp path.")], @@ -311,9 +378,14 @@ def generate( ), ] = False, ) -> None: - """Generate the canonical PR image from the PR's own title and description.""" - pr_body = pr_body_file.read_text(encoding="utf-8") + """Generate the canonical PR image from the PR's title, description, and change shape.""" + context = json.loads(pr_context_file.read_text(encoding="utf-8")) + if not isinstance(context, Mapping): + raise typer.BadParameter("PR context file must contain a JSON object") + pr_title = str(context.get("title") or "") + pr_body = str(context.get("body") or "") pr_content = extract_pr_content(pr_body) + change_shape = build_change_shape(context) theme_selection = select_image_theme( pr_number=pr_number, pr_title=pr_title, @@ -324,6 +396,7 @@ def generate( pr_number=pr_number, pr_title=pr_title, pr_content=pr_content, + change_shape=change_shape, theme=theme_selection.theme, theme_source=theme_selection.source, ) diff --git a/tests/scripts/test_generate_pr_infographic.py b/tests/scripts/test_generate_pr_infographic.py index 4ee496e2..b2212204 100644 --- a/tests/scripts/test_generate_pr_infographic.py +++ b/tests/scripts/test_generate_pr_infographic.py @@ -1,3 +1,4 @@ +import json from pathlib import Path import pytest @@ -25,8 +26,7 @@ def test_generate_pr_infographic_cli_help_exposes_useful_options() -> None: assert result.exit_code == 0 assert "--pr-number" in help_text - assert "--pr-title" in help_text - assert "--pr-body-file" in help_text + assert "--pr-context-file" in help_text assert "--output" in help_text assert "--theme" in help_text assert "--provenance-output" in help_text @@ -68,6 +68,51 @@ def test_extract_pr_content_handles_body_without_managed_blocks() -> None: assert generate_pr_infographic.extract_pr_content("Plain description") == "Plain description" +def test_build_change_shape_digests_delivery_context() -> None: + context = { + "labels": [{"name": "enhancement"}, {"name": "cloud"}], + "closingIssuesReferences": [{"number": 581, "title": "edit_note recovery"}], + "commits": [ + {"messageHeadline": "fix(mcp): recover edit_note"}, + {"messageHeadline": "fix(api): canonicalize sync-file paths"}, + ], + "files": [ + {"path": "src/basic_memory/mcp/tools/edit_note.py", "additions": 120, "deletions": 30}, + {"path": "tests/mcp/test_tool_edit_note.py", "additions": 80, "deletions": 5}, + ], + } + + context["commits"].append({"messageHeadline": "Merge branch 'main' into fix/581"}) + shape = generate_pr_infographic.build_change_shape(context) + + assert "Labels: enhancement, cloud" in shape + assert "#581: edit_note recovery" in shape + assert "Commit subjects (2 total):" in shape + assert "Merge branch" not in shape + assert "fix(mcp): recover edit_note" in shape + assert "Files changed (2 total, +200/-35):" in shape + assert "src/basic_memory/mcp/tools/edit_note.py (+120/-30)" in shape + + +def test_build_change_shape_caps_long_lists_and_handles_empty_context() -> None: + context = { + "commits": [{"messageHeadline": f"commit {i}"} for i in range(15)], + "files": [{"path": f"file-{i}.py", "additions": i, "deletions": 0} for i in range(15)], + } + + shape = generate_pr_infographic.build_change_shape(context) + + assert "Commit subjects (15 total):" in shape + assert "- ... and 5 more" in shape + assert "- ... and 5 more files" in shape + # Files are ranked by churn, so the biggest file leads the list. + assert shape.index("file-14.py") < shape.index("file-5.py") + + assert ( + generate_pr_infographic.build_change_shape({}) == "(no additional change context available)" + ) + + def test_extract_infographic_theme_from_pr_body() -> None: body = "\n".join( [ @@ -129,6 +174,7 @@ def test_build_infographic_prompt_depicts_pr_content_not_review_outcome() -> Non pr_number=42, pr_title="feat(sync): stream large files during cloud sync", pr_content="Streams PDFs in chunks instead of loading them fully into memory.", + change_shape="Labels: sync\nFiles changed (3 total, +90/-20):\n- src/basic_memory/sync/x.py (+80/-15)", theme="WWII propaganda posters with home-front logistics routes", theme_source=generate_pr_infographic.ThemeSource.CLI, ) @@ -152,6 +198,10 @@ def test_build_infographic_prompt_depicts_pr_content_not_review_outcome() -> Non assert "approval" in prompt.lower() assert "stamps" in prompt assert "checkmarks" in prompt + # Change shape grounds the imagery but must not become captions. + assert "Change shape" in prompt + assert "src/basic_memory/sync/x.py" in prompt + assert "never render file" in prompt # The old prompt fed the review summary and named the approval status, # which produced literal "BOSSBOT APPROVED" stamp images. assert "BM Bossbot summary:" not in prompt @@ -229,6 +279,7 @@ def test_build_infographic_prompt_uses_auto_theme_as_visual_direction() -> None: pr_number=42, pr_title="feat(ci): add a merge gate", pr_content="Adds a deterministic merge gate for pull requests.", + change_shape="(no additional change context available)", theme=theme.theme, theme_source=theme.source, ) @@ -256,19 +307,28 @@ def test_generate_pr_infographic_can_print_prompt_without_image_call( monkeypatch: pytest.MonkeyPatch, flag: str, ) -> None: - body_file = tmp_path / "pr-body.md" - body_file.write_text( - "\n".join( - [ - "Adds a deterministic merge gate for pull requests.", - "", - "Verdict: approve", - "Summary: review artifact that must not reach the image.", - "", - "", - "space exploration and astronomy", - "", - ] + context_file = tmp_path / "pr-context.json" + context_file.write_text( + json.dumps( + { + "title": "feat(ci): add a merge gate", + "body": "\n".join( + [ + "Adds a deterministic merge gate for pull requests.", + "", + "Verdict: approve", + "Summary: review artifact that must not reach the image.", + "", + "", + "space exploration and astronomy", + "", + ] + ), + "labels": [{"name": "ci"}], + "commits": [{"messageHeadline": "feat(ci): add a merge gate"}], + "files": [{"path": ".github/workflows/gate.yml", "additions": 40, "deletions": 2}], + "closingIssuesReferences": [{"number": 7, "title": "merge gate"}], + } ), encoding="utf-8", ) @@ -286,10 +346,8 @@ def test_generate_pr_infographic_can_print_prompt_without_image_call( [ "--pr-number", "42", - "--pr-title", - "feat(ci): add a merge gate", - "--pr-body-file", - str(body_file), + "--pr-context-file", + str(context_file), "--output", str(output), flag, @@ -303,6 +361,9 @@ def test_generate_pr_infographic_can_print_prompt_without_image_call( assert "feat(ci): add a merge gate" in result.output assert "Adds a deterministic merge gate" in result.output assert "space exploration and astronomy" in result.output + assert "Labels: ci" in result.output + assert ".github/workflows/gate.yml" in result.output + assert "#7: merge gate" in result.output assert "image-first composition" in result.output assert "Do not render an infographic" in result.output assert "Verdict: approve" not in result.output @@ -314,15 +375,20 @@ def test_generate_pr_infographic_writes_provenance_after_image_generation( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - body_file = tmp_path / "pr-body.md" - body_file.write_text( - "\n".join( - [ - "Adds a merge gate.", - "", - "paintings: Rembrandt-inspired merge gate", - "", - ] + context_file = tmp_path / "pr-context.json" + context_file.write_text( + json.dumps( + { + "title": "feat(ci): add a merge gate", + "body": "\n".join( + [ + "Adds a merge gate.", + "", + "paintings: Rembrandt-inspired merge gate", + "", + ] + ), + } ), encoding="utf-8", ) @@ -348,10 +414,8 @@ def test_generate_pr_infographic_writes_provenance_after_image_generation( [ "--pr-number", "42", - "--pr-title", - "feat(ci): add a merge gate", - "--pr-body-file", - str(body_file), + "--pr-context-file", + str(context_file), "--output", str(output), "--provenance-output",