feat(ci): ground PR images in delivery context, not just title/description

Mirror the ProjectUpdateContext shape the basic-memory.yml capture flow
collects: the image prompt now receives a compact change-shape digest —
labels, linked issues with titles, commit subjects (the PR's narrative
arc), and a churn-ranked changed-files summary with totals — from a
single 'gh pr view --json' call passed as --pr-context-file (replacing
--pr-title/--pr-body-file). The digest is explicitly context, not
captions: the prompt forbids rendering paths, stats, issue numbers, or
commit subjects verbatim in the image.

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
phernandez
2026-06-09 23:11:36 -05:00
parent f9c3baf5b9
commit ef6c47e674
3 changed files with 187 additions and 48 deletions
+6 -4
View File
@@ -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"
+85 -12
View File
@@ -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,
)
+96 -32
View File
@@ -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.",
"<!-- BM_BOSSBOT_SUMMARY:start -->",
"Verdict: approve",
"Summary: review artifact that must not reach the image.",
"<!-- BM_BOSSBOT_SUMMARY:end -->",
"<!-- BM_INFOGRAPHIC_THEME:start -->",
"space exploration and astronomy",
"<!-- BM_INFOGRAPHIC_THEME:end -->",
]
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.",
"<!-- BM_BOSSBOT_SUMMARY:start -->",
"Verdict: approve",
"Summary: review artifact that must not reach the image.",
"<!-- BM_BOSSBOT_SUMMARY:end -->",
"<!-- BM_INFOGRAPHIC_THEME:start -->",
"space exploration and astronomy",
"<!-- BM_INFOGRAPHIC_THEME:end -->",
]
),
"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.",
"<!-- BM_INFOGRAPHIC_THEME:start -->",
"paintings: Rembrandt-inspired merge gate",
"<!-- BM_INFOGRAPHIC_THEME:end -->",
]
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.",
"<!-- BM_INFOGRAPHIC_THEME:start -->",
"paintings: Rembrandt-inspired merge gate",
"<!-- BM_INFOGRAPHIC_THEME:end -->",
]
),
}
),
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",