mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user