mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(cli): add auto bm github ci
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
# Basic Memory CI
|
||||
|
||||
Basic Memory CI turns meaningful GitHub delivery moments into durable
|
||||
`project_update` notes in Basic Memory.
|
||||
|
||||
GitHub records the mechanics: pull requests, workflow runs, SHAs, URLs, labels,
|
||||
and timestamps. The agent reads those facts and writes a short synthesis of what
|
||||
changed and why it matters. The Basic Memory CLI owns authentication, schema
|
||||
guidance, idempotency, and publishing.
|
||||
|
||||
The product voice is:
|
||||
|
||||
> GitHub records the mechanics. Basic Memory remembers what changed and why.
|
||||
|
||||
## Flow
|
||||
|
||||
```text
|
||||
GitHub event
|
||||
-> workflow eligibility filter
|
||||
-> bm ci collect
|
||||
-> Codex Action reads ProjectUpdateContext + memory-ci-capture prompt
|
||||
-> Codex writes AgentSynthesis JSON
|
||||
-> bm ci publish
|
||||
-> Basic Memory project_update note
|
||||
```
|
||||
|
||||
The v0 workflow is GitHub-only and uses `openai/codex-action@v1` as the
|
||||
synthesis runner. The Codex step runs in read-only mode and does not receive the
|
||||
Basic Memory API key.
|
||||
|
||||
## Setup CI/CD
|
||||
|
||||
Use `bm ci setup` from the GitHub repository root. The command installs the
|
||||
workflow/config/prompt files and seeds the Basic Memory schema notes.
|
||||
|
||||
For the common cloud path:
|
||||
|
||||
```bash
|
||||
bm cloud api-key save bmc_...
|
||||
bm ci setup --project <project-name> --workspace <workspace-slug> --cloud --yes
|
||||
```
|
||||
|
||||
Prefer `--project-id <external_id>` when the same project name exists in more
|
||||
than one workspace:
|
||||
|
||||
```bash
|
||||
bm ci setup --project <project-name> --project-id <project-external-id> --cloud --yes
|
||||
```
|
||||
|
||||
Then review and commit the generated files:
|
||||
|
||||
```text
|
||||
.github/workflows/basic-memory.yml
|
||||
.github/basic-memory/config.yml
|
||||
.github/basic-memory/memory-ci-capture.md
|
||||
```
|
||||
|
||||
Add these GitHub repository secrets:
|
||||
|
||||
- `OPENAI_API_KEY`: used only by `openai/codex-action`.
|
||||
- `BASIC_MEMORY_API_KEY`: mapped to `BASIC_MEMORY_CLOUD_API_KEY` only for
|
||||
`bm ci publish`.
|
||||
|
||||
Add this optional GitHub repository variable only when using a non-default cloud
|
||||
host:
|
||||
|
||||
- `BASIC_MEMORY_CLOUD_HOST`
|
||||
|
||||
Configure production deploy capture in `.github/basic-memory/config.yml`:
|
||||
|
||||
```yaml
|
||||
project: <project-name>
|
||||
workspace: <workspace-slug>
|
||||
deploy_workflows:
|
||||
- Deploy Production
|
||||
production_environments:
|
||||
- production
|
||||
```
|
||||
|
||||
The generated workflow also uses `deploy_workflows` to populate
|
||||
`on.workflow_run.workflows`. If you change deploy workflow names later, rerun
|
||||
`bm ci setup --force` or update both `.github/basic-memory/config.yml` and
|
||||
`.github/workflows/basic-memory.yml` together.
|
||||
|
||||
The generated workflow installs `basic-memory` from PyPI. When dogfooding an
|
||||
unreleased `bm ci` change, temporarily edit the workflow install step to install
|
||||
the branch or commit that contains the CI commands.
|
||||
|
||||
After the files and secrets are in place, verify the first run by merging a test
|
||||
PR or completing a configured production deploy workflow. The Auto BM workflow
|
||||
should create or update a `project_update` note under:
|
||||
|
||||
```text
|
||||
project-updates/github/<owner>/<repo>/
|
||||
```
|
||||
|
||||
Failures in the Auto BM workflow fail only the project-update capture workflow;
|
||||
they do not roll back the original merge or deploy.
|
||||
|
||||
## Commands
|
||||
|
||||
`bm ci setup`
|
||||
|
||||
Installs the repository automation files:
|
||||
|
||||
- `.github/workflows/basic-memory.yml`
|
||||
- `.github/basic-memory/config.yml`
|
||||
- `.github/basic-memory/memory-ci-capture.md`
|
||||
|
||||
It also seeds the canonical Basic Memory schema notes when they do not already
|
||||
exist:
|
||||
|
||||
- `ProjectUpdate`
|
||||
- `GitHubPullRequestUpdate`
|
||||
- `GitHubProductionDeployUpdate`
|
||||
|
||||
`bm ci collect`
|
||||
|
||||
Reads the current GitHub event payload and normalizes it into
|
||||
`ProjectUpdateContext`. This command decides whether the event is eligible.
|
||||
Merged pull requests and configured successful production deploy workflow runs
|
||||
are eligible. Routine CI runs, failed deploys, and unmerged PR closures are
|
||||
no-ops. In v0, collection is intentionally limited to the GitHub event payload;
|
||||
GitHub API enrichment for file lists, checks, reviews, or commit lists can be
|
||||
added later without changing the publishing boundary.
|
||||
|
||||
`bm ci agent-schema`
|
||||
|
||||
Writes the optional `AgentSynthesis` JSON schema used by the generated workflow
|
||||
as a CI guardrail. This schema is not a Basic Memory domain schema and is not
|
||||
committed by setup.
|
||||
|
||||
`bm ci publish`
|
||||
|
||||
Combines deterministic GitHub facts with the agent synthesis and upserts a
|
||||
Basic Memory `project_update` note. Agent-supplied identity fields are ignored;
|
||||
source identity comes from `ProjectUpdateContext`.
|
||||
|
||||
## Auth Boundary
|
||||
|
||||
The generated workflow needs these secrets:
|
||||
|
||||
- `OPENAI_API_KEY`
|
||||
- `BASIC_MEMORY_API_KEY`
|
||||
|
||||
`OPENAI_API_KEY` is passed only to the Codex Action. `BASIC_MEMORY_API_KEY` is
|
||||
mapped to `BASIC_MEMORY_CLOUD_API_KEY` only for the publish step, where the
|
||||
Basic Memory client uses it as `cloud_api_key`. The publish step also passes
|
||||
`--cloud` so CI writes to the configured cloud project.
|
||||
|
||||
When `.github/basic-memory/config.yml` includes `workspace` and no `project_id`,
|
||||
CI routes the project as `<workspace>/<project>`. Use a workspace slug there, or
|
||||
prefer `project_id` when project names collide across workspaces.
|
||||
|
||||
## Idempotency
|
||||
|
||||
Project updates are upserted by stable GitHub identity:
|
||||
|
||||
- PR merge:
|
||||
`github:<owner>/<repo>:pull_request_merged:<pr_number>`
|
||||
- Production deploy:
|
||||
`github:<owner>/<repo>:production_deploy_succeeded:<environment>:<workflow_run_id>`
|
||||
|
||||
The default note folder is:
|
||||
|
||||
```text
|
||||
project-updates/github/<owner>/<repo>/
|
||||
```
|
||||
|
||||
## Module Responsibilities
|
||||
|
||||
`project_updates.py` contains the v0 domain model and rendering helpers:
|
||||
|
||||
- `ProjectUpdateConfig`: non-secret repo configuration.
|
||||
- `ProjectUpdateContext`: normalized immutable GitHub facts.
|
||||
- `AgentSynthesis`: agent-authored summary fields.
|
||||
- `ProjectUpdateNote`: final Basic Memory note payload.
|
||||
- workflow, prompt, and schema-note seed rendering.
|
||||
|
||||
The CLI command group lives in `basic_memory.cli.commands.ci` and performs file
|
||||
installation, event collection, schema seeding, and publish orchestration.
|
||||
@@ -0,0 +1 @@
|
||||
"""CI helpers for Basic Memory project update capture."""
|
||||
@@ -0,0 +1,645 @@
|
||||
"""Project update capture helpers for GitHub Actions.
|
||||
|
||||
Auto BM treats GitHub as the immutable source layer, the agent as the synthesis
|
||||
layer, and Basic Memory as the durable project-memory layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
PULL_REQUEST_MERGED = "pull_request_merged"
|
||||
PRODUCTION_DEPLOY_SUCCEEDED = "production_deploy_succeeded"
|
||||
DEFAULT_NOTE_FOLDER_TEMPLATE = "project-updates/github/{owner}/{repo}"
|
||||
DEFAULT_CONFIG_PATH = ".github/basic-memory/config.yml"
|
||||
DEFAULT_WORKFLOW_PATH = ".github/workflows/basic-memory.yml"
|
||||
DEFAULT_PROMPT_PATH = ".github/basic-memory/memory-ci-capture.md"
|
||||
DEFAULT_CONTEXT_PATH = ".github/basic-memory/project-update-context.json"
|
||||
|
||||
|
||||
class ProjectUpdateConfig(BaseModel):
|
||||
"""Non-secret Auto BM repository configuration."""
|
||||
|
||||
project: str | None = None
|
||||
project_id: str | None = None
|
||||
workspace: str | None = None
|
||||
deploy_workflows: list[str] = Field(default_factory=lambda: ["Deploy Production"])
|
||||
production_environments: list[str] = Field(default_factory=lambda: ["production"])
|
||||
note_folder: str = DEFAULT_NOTE_FOLDER_TEMPLATE
|
||||
codex_model: str | None = None
|
||||
codex_effort: str | None = None
|
||||
|
||||
@field_validator("deploy_workflows", "production_environments")
|
||||
@classmethod
|
||||
def _non_empty_list(cls, value: list[str]) -> list[str]:
|
||||
cleaned = [item.strip() for item in value if item.strip()]
|
||||
if not cleaned:
|
||||
raise ValueError("must contain at least one non-empty value")
|
||||
return cleaned
|
||||
|
||||
|
||||
class ProjectUpdateContext(BaseModel):
|
||||
"""Normalized facts collected from a GitHub event payload."""
|
||||
|
||||
eligible: bool
|
||||
source: Literal["github"] = "github"
|
||||
source_event: str | None = None
|
||||
skip_reason: str | None = None
|
||||
repo: str | None = None
|
||||
repo_url: str | None = None
|
||||
source_url: str | None = None
|
||||
occurred_at: str | None = None
|
||||
idempotency_key: str | None = None
|
||||
sha: str | None = None
|
||||
pr_number: int | None = None
|
||||
workflow_run_id: str | None = None
|
||||
environment: str | None = None
|
||||
title: str | None = None
|
||||
body: str | None = None
|
||||
author: str | None = None
|
||||
labels: list[str] = Field(default_factory=list)
|
||||
linked_issues: list[str] = Field(default_factory=list)
|
||||
changed_files_count: int | None = None
|
||||
|
||||
|
||||
class AgentSynthesis(BaseModel):
|
||||
"""Agent-authored synthesis for a project update."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
summary: str
|
||||
why_it_matters: str
|
||||
user_facing_changes: list[str] = Field(default_factory=list)
|
||||
internal_changes: list[str] = Field(default_factory=list)
|
||||
verification: list[str] = Field(default_factory=list)
|
||||
follow_ups: list[str] = Field(default_factory=list)
|
||||
decision_candidates: list[str] = Field(default_factory=list)
|
||||
task_candidates: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator(
|
||||
"summary",
|
||||
"why_it_matters",
|
||||
)
|
||||
@classmethod
|
||||
def _required_text(cls, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("must not be empty")
|
||||
return stripped
|
||||
|
||||
|
||||
class ProjectUpdateNote(BaseModel):
|
||||
"""Final note payload for the Basic Memory writer."""
|
||||
|
||||
title: str
|
||||
directory: str
|
||||
content: str
|
||||
metadata: dict[str, Any]
|
||||
tags: list[str]
|
||||
|
||||
|
||||
class SchemaSeedSpec(BaseModel):
|
||||
"""Basic Memory schema note seed."""
|
||||
|
||||
title: str
|
||||
entity: str
|
||||
content: str
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
def parse_github_remote(remote_url: str) -> tuple[str, str]:
|
||||
"""Parse an HTTPS or SSH GitHub remote into owner/repo."""
|
||||
patterns = (
|
||||
r"^https://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$",
|
||||
r"^git@github\.com:(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$",
|
||||
r"^ssh://git@github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$",
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = re.match(pattern, remote_url.strip())
|
||||
if match:
|
||||
return match.group("owner"), match.group("repo")
|
||||
raise ValueError(f"Expected a GitHub remote, got: {remote_url}")
|
||||
|
||||
|
||||
def detect_github_repo(cwd: Path) -> tuple[str, str]:
|
||||
"""Detect the GitHub origin for a repository checkout."""
|
||||
result = subprocess.run(
|
||||
["git", "config", "--get", "remote.origin.url"],
|
||||
cwd=cwd,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
raise ValueError("No remote.origin.url found; run this from a GitHub repository checkout")
|
||||
return parse_github_remote(result.stdout.strip())
|
||||
|
||||
|
||||
def load_project_update_config(path: Path) -> ProjectUpdateConfig:
|
||||
"""Load Auto BM repository config from YAML."""
|
||||
if not path.exists():
|
||||
return ProjectUpdateConfig()
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{path} must contain a YAML object")
|
||||
return ProjectUpdateConfig.model_validate(raw)
|
||||
|
||||
|
||||
def write_project_update_config(path: Path, config: ProjectUpdateConfig) -> None:
|
||||
"""Write repository config YAML."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = config.model_dump(exclude_none=True)
|
||||
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
||||
|
||||
|
||||
def _load_event_payload(event_path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(event_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise ValueError(f"GitHub event payload not found: {event_path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"GitHub event payload is not valid JSON: {event_path}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("GitHub event payload must be a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
def collect_project_update_context(
|
||||
*,
|
||||
event_name: str,
|
||||
event_path: Path,
|
||||
config: ProjectUpdateConfig,
|
||||
) -> ProjectUpdateContext:
|
||||
"""Normalize a GitHub Actions event into a project update context."""
|
||||
payload = _load_event_payload(event_path)
|
||||
if event_name == "pull_request":
|
||||
return _collect_pull_request_context(payload)
|
||||
if event_name == "workflow_run":
|
||||
return _collect_workflow_run_context(payload, config)
|
||||
return ProjectUpdateContext(
|
||||
eligible=False,
|
||||
skip_reason=f"unsupported GitHub event: {event_name}",
|
||||
)
|
||||
|
||||
|
||||
def _repo_fields(payload: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||
repo = payload.get("repository")
|
||||
if not isinstance(repo, dict):
|
||||
return None, None
|
||||
full_name = repo.get("full_name")
|
||||
html_url = repo.get("html_url")
|
||||
return (
|
||||
full_name if isinstance(full_name, str) else None,
|
||||
html_url if isinstance(html_url, str) else None,
|
||||
)
|
||||
|
||||
|
||||
def _label_names(labels: Any) -> list[str]:
|
||||
if not isinstance(labels, list):
|
||||
return []
|
||||
names: list[str] = []
|
||||
for label in labels:
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str):
|
||||
names.append(label["name"])
|
||||
return names
|
||||
|
||||
|
||||
def _linked_issues(*texts: str | None) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
issues: list[str] = []
|
||||
for text in texts:
|
||||
if not text:
|
||||
continue
|
||||
for match in re.finditer(r"(?<![\w/])#(?P<number>\d+)\b", text):
|
||||
issue = f"#{match.group('number')}"
|
||||
if issue not in seen:
|
||||
seen.add(issue)
|
||||
issues.append(issue)
|
||||
return issues
|
||||
|
||||
|
||||
def _collect_pull_request_context(payload: dict[str, Any]) -> ProjectUpdateContext:
|
||||
pr = payload.get("pull_request")
|
||||
if not isinstance(pr, dict):
|
||||
return ProjectUpdateContext(eligible=False, skip_reason="pull request payload missing")
|
||||
|
||||
if payload.get("action") != "closed":
|
||||
return ProjectUpdateContext(
|
||||
eligible=False, skip_reason="pull request action was not closed"
|
||||
)
|
||||
|
||||
if pr.get("merged") is not True:
|
||||
return ProjectUpdateContext(
|
||||
eligible=False,
|
||||
skip_reason="pull request was closed without merging",
|
||||
)
|
||||
|
||||
repo, repo_url = _repo_fields(payload)
|
||||
number = pr.get("number")
|
||||
title = pr.get("title") if isinstance(pr.get("title"), str) else None
|
||||
body = pr.get("body") if isinstance(pr.get("body"), str) else None
|
||||
source_url = pr.get("html_url") if isinstance(pr.get("html_url"), str) else None
|
||||
sha = pr.get("merge_commit_sha") if isinstance(pr.get("merge_commit_sha"), str) else None
|
||||
occurred_at = pr.get("merged_at") if isinstance(pr.get("merged_at"), str) else None
|
||||
author = None
|
||||
user = pr.get("user")
|
||||
if isinstance(user, dict) and isinstance(user.get("login"), str):
|
||||
author = user["login"]
|
||||
|
||||
idempotency_key = None
|
||||
if repo and isinstance(number, int):
|
||||
idempotency_key = f"github:{repo}:{PULL_REQUEST_MERGED}:{number}"
|
||||
|
||||
return ProjectUpdateContext(
|
||||
eligible=True,
|
||||
source_event=PULL_REQUEST_MERGED,
|
||||
repo=repo,
|
||||
repo_url=repo_url,
|
||||
source_url=source_url,
|
||||
occurred_at=occurred_at,
|
||||
idempotency_key=idempotency_key,
|
||||
sha=sha,
|
||||
pr_number=number if isinstance(number, int) else None,
|
||||
title=title,
|
||||
body=body,
|
||||
author=author,
|
||||
labels=_label_names(pr.get("labels")),
|
||||
linked_issues=_linked_issues(title, body),
|
||||
changed_files_count=(
|
||||
pr["changed_files"] if isinstance(pr.get("changed_files"), int) else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _collect_workflow_run_context(
|
||||
payload: dict[str, Any],
|
||||
config: ProjectUpdateConfig,
|
||||
) -> ProjectUpdateContext:
|
||||
run = payload.get("workflow_run")
|
||||
if not isinstance(run, dict):
|
||||
return ProjectUpdateContext(eligible=False, skip_reason="workflow run payload missing")
|
||||
|
||||
conclusion = run.get("conclusion")
|
||||
if conclusion != "success":
|
||||
return ProjectUpdateContext(
|
||||
eligible=False,
|
||||
skip_reason=f"workflow conclusion was {conclusion or 'missing'}",
|
||||
)
|
||||
|
||||
workflow_name = run.get("name")
|
||||
if workflow_name not in config.deploy_workflows:
|
||||
return ProjectUpdateContext(
|
||||
eligible=False,
|
||||
skip_reason=f"workflow '{workflow_name}' is not configured for project updates",
|
||||
)
|
||||
|
||||
repo, repo_url = _repo_fields(payload)
|
||||
run_id = run.get("id")
|
||||
workflow_run_id = str(run_id) if run_id is not None else None
|
||||
environment = config.production_environments[0]
|
||||
source_url = run.get("html_url") if isinstance(run.get("html_url"), str) else None
|
||||
sha = run.get("head_sha") if isinstance(run.get("head_sha"), str) else None
|
||||
occurred_at = run.get("updated_at") if isinstance(run.get("updated_at"), str) else None
|
||||
|
||||
idempotency_key = None
|
||||
if repo and workflow_run_id:
|
||||
idempotency_key = (
|
||||
f"github:{repo}:{PRODUCTION_DEPLOY_SUCCEEDED}:{environment}:{workflow_run_id}"
|
||||
)
|
||||
|
||||
return ProjectUpdateContext(
|
||||
eligible=True,
|
||||
source_event=PRODUCTION_DEPLOY_SUCCEEDED,
|
||||
repo=repo,
|
||||
repo_url=repo_url,
|
||||
source_url=source_url,
|
||||
occurred_at=occurred_at,
|
||||
idempotency_key=idempotency_key,
|
||||
sha=sha,
|
||||
workflow_run_id=workflow_run_id,
|
||||
environment=environment,
|
||||
title=str(workflow_name) if workflow_name is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _owner_repo(repo: str) -> tuple[str, str]:
|
||||
if "/" not in repo:
|
||||
raise ValueError(f"Repository must be owner/repo, got: {repo}")
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
return owner, repo_name
|
||||
|
||||
|
||||
def _note_directory(context: ProjectUpdateContext, config: ProjectUpdateConfig | None) -> str:
|
||||
if not context.repo:
|
||||
raise ValueError("Project update context is missing repo")
|
||||
owner, repo_name = _owner_repo(context.repo)
|
||||
template = config.note_folder if config else DEFAULT_NOTE_FOLDER_TEMPLATE
|
||||
return template.format(owner=owner, repo=repo_name, full_repo=context.repo)
|
||||
|
||||
|
||||
def _note_title(context: ProjectUpdateContext) -> str:
|
||||
if context.source_event == PULL_REQUEST_MERGED and context.pr_number:
|
||||
title = context.title or "Merged pull request"
|
||||
return f"PR #{context.pr_number}: {title}"
|
||||
if context.source_event == PRODUCTION_DEPLOY_SUCCEEDED:
|
||||
environment = context.environment or "production"
|
||||
when = (context.occurred_at or "").split("T", 1)[0] or "unknown date"
|
||||
return f"{environment.title()} deploy: {when}"
|
||||
return "Project update"
|
||||
|
||||
|
||||
def build_project_update_note(
|
||||
*,
|
||||
context: ProjectUpdateContext,
|
||||
synthesis: AgentSynthesis,
|
||||
config: ProjectUpdateConfig | None = None,
|
||||
) -> ProjectUpdateNote:
|
||||
"""Build the final Basic Memory project update note."""
|
||||
if not context.eligible:
|
||||
raise ValueError(f"Cannot build a note for an ineligible context: {context.skip_reason}")
|
||||
if not context.source_event or not context.repo or not context.idempotency_key:
|
||||
raise ValueError("Project update context is missing deterministic identity fields")
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"source": "github",
|
||||
"source_event": context.source_event,
|
||||
"repo": context.repo,
|
||||
"source_url": context.source_url,
|
||||
"occurred_at": context.occurred_at,
|
||||
"idempotency_key": context.idempotency_key,
|
||||
"sha": context.sha,
|
||||
"pr_number": context.pr_number,
|
||||
"workflow_run_id": context.workflow_run_id,
|
||||
"environment": context.environment,
|
||||
}
|
||||
metadata = {key: value for key, value in metadata.items() if value is not None}
|
||||
|
||||
sections = [
|
||||
f"# {_note_title(context)}",
|
||||
"## Summary",
|
||||
synthesis.summary,
|
||||
"## Why It Matters",
|
||||
synthesis.why_it_matters,
|
||||
]
|
||||
|
||||
_extend_list_section(sections, "User-Facing Changes", synthesis.user_facing_changes)
|
||||
_extend_list_section(sections, "Internal Changes", synthesis.internal_changes)
|
||||
_extend_list_section(sections, "Verification", synthesis.verification)
|
||||
_extend_list_section(sections, "Follow-Ups", synthesis.follow_ups)
|
||||
_extend_list_section(sections, "Decision Candidates", synthesis.decision_candidates)
|
||||
_extend_list_section(sections, "Task Candidates", synthesis.task_candidates)
|
||||
|
||||
source_links = []
|
||||
if context.source_url:
|
||||
source_links.append(f"- Source: {context.source_url}")
|
||||
if context.repo_url:
|
||||
source_links.append(f"- Repository: {context.repo_url}")
|
||||
if context.linked_issues:
|
||||
source_links.append(f"- Linked issues: {', '.join(context.linked_issues)}")
|
||||
if source_links:
|
||||
sections.extend(["## Source Links", *source_links])
|
||||
|
||||
observations = [
|
||||
f"- [summary] {synthesis.summary}",
|
||||
f"- [source] GitHub {context.source_event} in {context.repo}",
|
||||
]
|
||||
sections.extend(["## Observations", *observations])
|
||||
|
||||
return ProjectUpdateNote(
|
||||
title=_note_title(context),
|
||||
directory=_note_directory(context, config),
|
||||
content="\n\n".join(sections).strip() + "\n",
|
||||
metadata=metadata,
|
||||
tags=["github", "project-update", context.source_event],
|
||||
)
|
||||
|
||||
|
||||
def _extend_list_section(sections: list[str], title: str, values: list[str]) -> None:
|
||||
cleaned = [value.strip() for value in values if value.strip()]
|
||||
if cleaned:
|
||||
sections.extend([f"## {title}", *[f"- {value}" for value in cleaned]])
|
||||
|
||||
|
||||
def render_agent_synthesis_schema() -> str:
|
||||
"""Render the optional Codex structured-output schema guardrail."""
|
||||
schema = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "AgentSynthesis",
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"required": ["summary", "why_it_matters"],
|
||||
"properties": {
|
||||
"summary": {"type": "string", "minLength": 1},
|
||||
"why_it_matters": {"type": "string", "minLength": 1},
|
||||
"user_facing_changes": {"type": "array", "items": {"type": "string"}},
|
||||
"internal_changes": {"type": "array", "items": {"type": "string"}},
|
||||
"verification": {"type": "array", "items": {"type": "string"}},
|
||||
"follow_ups": {"type": "array", "items": {"type": "string"}},
|
||||
"decision_candidates": {"type": "array", "items": {"type": "string"}},
|
||||
"task_candidates": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
return json.dumps(schema, indent=2, sort_keys=True) + "\n"
|
||||
|
||||
|
||||
def render_capture_prompt() -> str:
|
||||
"""Render the prompt contract used by the generated workflow."""
|
||||
return """# Memory CI Capture
|
||||
|
||||
You turn GitHub delivery context into a concise project update synthesis for
|
||||
Basic Memory. GitHub records the mechanics. Basic Memory remembers what changed
|
||||
and why.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Read `.github/basic-memory/project-update-context.json`.
|
||||
- Treat GitHub payload fields as immutable facts.
|
||||
- Do not invent tests, deployment status, issues, or user impact.
|
||||
|
||||
## Output
|
||||
|
||||
Return only JSON that matches the provided AgentSynthesis schema:
|
||||
|
||||
- `summary`: what changed.
|
||||
- `why_it_matters`: why this project update matters for future humans and agents.
|
||||
- `user_facing_changes`: visible behavior or product changes.
|
||||
- `internal_changes`: implementation, infrastructure, or operational changes.
|
||||
- `verification`: checks, tests, deploy evidence, or explicit unknowns.
|
||||
- `follow_ups`: concrete remaining work only.
|
||||
- `decision_candidates`: explicit product or architecture decisions only.
|
||||
- `task_candidates`: concrete future tasks only.
|
||||
|
||||
Prefer source links and grounded phrasing. This is project memory, not marketing
|
||||
copy and not a commit-by-commit changelog.
|
||||
"""
|
||||
|
||||
|
||||
def render_workflow(config: ProjectUpdateConfig) -> str:
|
||||
"""Render the generated GitHub Actions workflow."""
|
||||
workflow_names = json.dumps(config.deploy_workflows)
|
||||
model_line = f" model: {config.codex_model}\n" if config.codex_model else ""
|
||||
effort_line = f" effort: {config.codex_effort}\n" if config.codex_effort else ""
|
||||
return f"""name: Basic Memory Project Updates
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
workflow_run:
|
||||
workflows: {workflow_names}
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
project-update:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Basic Memory
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install basic-memory
|
||||
|
||||
- name: Collect project update context
|
||||
id: collect
|
||||
run: |
|
||||
bm ci collect \\
|
||||
--config {DEFAULT_CONFIG_PATH} \\
|
||||
--output {DEFAULT_CONTEXT_PATH}
|
||||
|
||||
- name: Stop when event is not eligible
|
||||
if: steps.collect.outputs.eligible != 'true'
|
||||
run: echo "Auto BM skipped: ${{{{ steps.collect.outputs.skip_reason }}}}"
|
||||
|
||||
- name: Write Codex output schema
|
||||
if: steps.collect.outputs.eligible == 'true'
|
||||
run: |
|
||||
bm ci agent-schema --output "${{{{ runner.temp }}}}/agent-synthesis.schema.json"
|
||||
|
||||
- name: Synthesize project update with Codex
|
||||
if: steps.collect.outputs.eligible == 'true'
|
||||
uses: openai/codex-action@v1
|
||||
with:
|
||||
openai-api-key: ${{{{ secrets.OPENAI_API_KEY }}}}
|
||||
prompt-file: {DEFAULT_PROMPT_PATH}
|
||||
output-file: ${{{{ runner.temp }}}}/agent-synthesis.json
|
||||
output-schema-file: ${{{{ runner.temp }}}}/agent-synthesis.schema.json
|
||||
sandbox: read-only
|
||||
safety-strategy: drop-sudo
|
||||
{model_line}{effort_line}
|
||||
- name: Publish project update
|
||||
if: steps.collect.outputs.eligible == 'true'
|
||||
env:
|
||||
BASIC_MEMORY_CLOUD_API_KEY: ${{{{ secrets.BASIC_MEMORY_API_KEY }}}}
|
||||
BASIC_MEMORY_CI_CLOUD_HOST: ${{{{ vars.BASIC_MEMORY_CLOUD_HOST }}}}
|
||||
run: |
|
||||
if [ -n "$BASIC_MEMORY_CI_CLOUD_HOST" ]; then
|
||||
export BASIC_MEMORY_CLOUD_HOST="$BASIC_MEMORY_CI_CLOUD_HOST"
|
||||
fi
|
||||
bm ci publish \\
|
||||
--cloud \\
|
||||
--config {DEFAULT_CONFIG_PATH} \\
|
||||
--context {DEFAULT_CONTEXT_PATH} \\
|
||||
--synthesis "${{{{ runner.temp }}}}/agent-synthesis.json"
|
||||
"""
|
||||
|
||||
|
||||
def schema_seed_specs() -> list[SchemaSeedSpec]:
|
||||
"""Return Basic Memory schema note seeds for Auto BM project updates."""
|
||||
return [
|
||||
_schema_seed(
|
||||
title="ProjectUpdate",
|
||||
entity="ProjectUpdate",
|
||||
schema={
|
||||
"summary": "string, concise account of what changed",
|
||||
"why_it_matters": "string, why this update matters",
|
||||
"source": "string, source system such as github",
|
||||
"source_event": ("string, pull_request_merged or production_deploy_succeeded"),
|
||||
"repo": "string, owner/repository",
|
||||
"source_url": "string, canonical source URL",
|
||||
"occurred_at?": "string, ISO timestamp",
|
||||
"idempotency_key": "string, stable source identity",
|
||||
"sha?": "string, commit SHA",
|
||||
"pr_number?": "integer, pull request number",
|
||||
"workflow_run_id?": "string, GitHub Actions workflow run id",
|
||||
"environment?": "string, deployment environment",
|
||||
},
|
||||
body=(
|
||||
"A ProjectUpdate preserves what changed in a project and why it matters. "
|
||||
"GitHub records mechanics; Basic Memory keeps the durable narrative."
|
||||
),
|
||||
),
|
||||
_schema_seed(
|
||||
title="GitHubPullRequestUpdate",
|
||||
entity="GitHubPullRequestUpdate",
|
||||
schema={
|
||||
"intent": "string, purpose of the merged pull request",
|
||||
"changed_area?(array)": "string, product or implementation areas touched",
|
||||
"linked_issue?(array)": "string, issues closed or advanced",
|
||||
"verification?(array)": "string, checks and tests observed",
|
||||
"follow_up?(array)": "string, concrete remaining work",
|
||||
},
|
||||
body=(
|
||||
"Guidance for pull request project updates: preserve intent, changed "
|
||||
"behavior, review tradeoffs, issue links, and verification. Do not "
|
||||
"summarize commit by commit unless that is the clearest explanation."
|
||||
),
|
||||
),
|
||||
_schema_seed(
|
||||
title="GitHubProductionDeployUpdate",
|
||||
entity="GitHubProductionDeployUpdate",
|
||||
schema={
|
||||
"deployed_sha": "string, deployed commit SHA",
|
||||
"environment": "string, production environment",
|
||||
"workflow_run_id": "string, GitHub Actions workflow run id",
|
||||
"verification?(array)": "string, deploy evidence and smoke checks",
|
||||
"user_impact?(array)": "string, user-facing impact since previous deploy",
|
||||
"rollback_note?": "string, rollback or mitigation note when known",
|
||||
},
|
||||
body=(
|
||||
"Guidance for production deploy project updates: preserve what actually "
|
||||
"reached production, the deployed SHA, environment, workflow run, and "
|
||||
"verification evidence. Do not overclaim beyond the source facts."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _schema_seed(
|
||||
*,
|
||||
title: str,
|
||||
entity: str,
|
||||
schema: dict[str, str],
|
||||
body: str,
|
||||
) -> SchemaSeedSpec:
|
||||
metadata = {
|
||||
"type": "schema",
|
||||
"entity": entity,
|
||||
"version": 1,
|
||||
"schema": schema,
|
||||
"settings": {"validation": "warn"},
|
||||
}
|
||||
return SchemaSeedSpec(
|
||||
title=title,
|
||||
entity=entity,
|
||||
content=f"# {title}\n\n{body}\n",
|
||||
metadata=metadata,
|
||||
)
|
||||
Reference in New Issue
Block a user