Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,25 @@ jobs:
run: python -m pip install --quiet --upgrade pip pytest pyyaml

- name: Run action-contract tests
run: python -m pytest tests/ -q
run: python -m pytest tests/ -q --ignore=tests/test_herald_renderer.py

# The Herald renderer is behavioral, not just structural: it shells the real
# `rac decisions-for --json` (published rac-core — the same contract the
# wrapper consumes in the field, ADR-063) and must render byte-identical
# comments for the same corpus and diff. Kept out of the structural job so
# that tier stays engine-free.
herald-renderer:
name: herald renderer (published engine)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: actions/setup-python@v6
with:
python-version: "3.12"

- name: Install test deps and the published engine
run: python -m pip install --quiet --upgrade pip pytest rac-core

- name: Run the renderer battery
run: python -m pytest tests/test_herald_renderer.py -q
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ published `rac-core` from PyPI (pin with the `rac-version` input).
| Watchkeeper | [`watchkeeper/github/`](watchkeeper/github/) | `rac watchkeeper` (PR knowledge review) | `uses: itsthelore/rac-ci/watchkeeper/github@<ref>` |
| Gatekeeper | [`gatekeeper/github/`](gatekeeper/github/) | `rac gate --sarif` (required merge gate) | `uses: itsthelore/rac-ci/gatekeeper/github@<ref>` |
| Registrar | [`registrar/github/`](registrar/github/) | `rac validate --sarif` (well-formedness, ADR-058) | `uses: itsthelore/rac-ci/registrar/github@<ref>` |
| Herald | [`herald/github/`](herald/github/) | `rac decisions-for --json` (advisory governing-decisions comment on PRs) | `uses: itsthelore/rac-ci/herald/github@<ref>` |
| Recordkeeper | [`recordkeeper/`](recordkeeper/) | read-access audit recorder (ADR-084) | *placeholder — not yet shipped* |

A reusable Watchkeeper workflow is also published at
Expand Down
144 changes: 144 additions & 0 deletions herald/github/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Lore Herald composite action (pr-decision-surfacing design; ADR-034 /
# ADR-063 / ADR-067).
#
# Proclaims the recorded decisions that govern a pull request's changed paths
# as ONE advisory comment: compute the merge-base diff, run
# `rac decisions-for --json` per changed path (the live-decisions lookup the
# CLI and MCP serve, `decision-to-code-proximity`), and post a terse,
# deduplicated comment that re-runs update in place. Facts, never verdicts
# (ADR-034): the comment names what governs and recommends review; it never
# gates the merge — the action succeeds whatever it finds, and only
# operational breakage fails the step. A thin consumer (ADR-063): matching
# and liveness live entirely in the engine; nothing is re-derived here. Named
# a `lore-*` surface per the design (ADR-068) — Herald announces, the
# Gatekeeper enforces.
#
# Siblings: Watchkeeper at `watchkeeper/github/`, the gate at
# `gatekeeper/github/`, validate at `registrar/github/`. This surface is
# referenced as `uses: itsthelore/rac-ci/herald/github@<ref>`.
#
# Requirements: a pull_request-triggered workflow, `fetch-depth: 0` on the
# checkout (the diff needs the merge base, like Watchkeeper), and
# `pull-requests: write` on the job for the comment. On forks, where the
# token is read-only, the comment falls back to the step summary instead of
# failing the check.
name: "Lore decisions on PR"
description: >-
Comment the recorded decisions that govern a pull request's changed paths —
id, title, and the declared scope that matched — as one advisory,
update-in-place comment. A thin wrapper over `rac decisions-for`; facts,
never a merge gate.
author: "Tom Ballard"
branding:
icon: "book-open"
color: "purple"
inputs:
path:
description: "The RAC corpus directory (passed to `rac decisions-for`)."
required: false
default: "rac"
max-inline:
description: >-
Governing decisions listed in full before the rest collapse into a
details expander.
required: false
default: "5"
rac-version:
description: >-
Exact rac-core version to install from PyPI. Empty installs the
latest release.
required: false
default: ""
runs:
using: "composite"
steps:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install RAC
shell: bash
env:
RAC_VERSION: ${{ inputs.rac-version }}
run: |
python -m pip install --quiet --upgrade pip
if [ -n "$RAC_VERSION" ]; then
python -m pip install --quiet "rac-core==$RAC_VERSION"
else
python -m pip install --quiet rac-core
fi
# The PR's changed paths, from the merge base (triple-dot): what the PR
# itself changes, correct even when the base branch has moved ahead.
# Requires fetch-depth: 0 on the checkout.
- name: Compute changed paths
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ -z "$BASE_SHA" ] || [ -z "$HEAD_SHA" ]; then
echo "::error::Lore decisions on PR runs on pull_request events only (no base/head sha in this event)."
exit 1
fi
git diff --name-only "$BASE_SHA...$HEAD_SHA" > lore-changed-paths.txt
echo "$(wc -l < lore-changed-paths.txt) changed path(s)"
# One deterministic comment body: `rac decisions-for --json` per changed
# path, merged by decision id, sorted, no wall-clock input — the same
# bytes for the same corpus and diff.
- name: Render governing decisions
id: render
shell: bash
env:
INPUT_PATH: ${{ inputs.path }}
MAX_INLINE: ${{ inputs.max-inline }}
LINK_BASE: ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.event.pull_request.head.sha }}
run: |
python "$GITHUB_ACTION_PATH/render.py" \
--corpus "$INPUT_PATH" \
--paths-file lore-changed-paths.txt \
--link-base "$LINK_BASE" \
--max-inline "$MAX_INLINE" \
--out lore-decisions-comment.md
# One comment per PR, updated in place on re-runs (found by its marker).
# No comment is created when nothing governs; an existing comment is
# updated even to the empty state, so it never goes stale. A read-only
# token (forks) degrades to the step summary — advisory surfaces never
# fail the check.
- name: Post or update the comment
uses: actions/github-script@v7
env:
HAS_DECISIONS: ${{ steps.render.outputs.has_decisions }}
with:
script: |
const fs = require('fs');
const marker = '<!-- lore-decisions-on-pr -->';
const body = fs.readFileSync('lore-decisions-comment.md', 'utf8');
const hasDecisions = process.env.HAS_DECISIONS === 'true';
try {
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else if (hasDecisions) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
} else {
core.info('No governing decisions and no existing comment — nothing to post.');
}
} catch (error) {
core.warning(`Could not post the decisions comment (${error.message}); writing it to the step summary instead.`);
await core.summary.addRaw(body).write();
}
164 changes: 164 additions & 0 deletions herald/github/render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Render the decisions-on-PR comment from `rac decisions-for --json`.

The thin-client half of the `pr-decision-surfacing` design (ADR-063): run the
engine's live-decisions lookup for each changed path, merge by decision id,
and emit one deterministic Markdown comment — facts, never verdicts (ADR-034).
Everything semantic (scope matching, liveness) is the engine's; this script
only joins, sorts, and formats. No wall-clock input: the same corpus and diff
render the same bytes.

Exit code is 0 whether or not decisions govern (advisory surfaces never fail
a check); only operational breakage — the `rac` binary missing outright —
exits non-zero. A lookup failure on one path is skipped with a warning so a
single odd path cannot take down the whole comment.
"""

from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys

MARKER = "<!-- lore-decisions-on-pr -->"

EMPTY_BODY = (
f"{MARKER}\n"
"### Decisions governing this change\n\n"
"No recorded decisions govern the paths changed by this pull request.\n"
)

# Changed paths listed per decision before collapsing to a count.
_PATHS_SHOWN = 3


def _lookup(rac_bin: str, path: str, corpus: str) -> dict | None:
"""One `rac decisions-for <path> <corpus> --json` call, parsed, or None."""
try:
proc = subprocess.run(
[rac_bin, "decisions-for", path, corpus, "--json"],
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError:
print(f"error: rac binary not found: {rac_bin!r}", file=sys.stderr)
raise SystemExit(1) from None
if proc.returncode != 0:
print(
f"warning: decisions-for skipped {path!r} (exit {proc.returncode})",
file=sys.stderr,
)
return None
try:
return json.loads(proc.stdout)
except json.JSONDecodeError:
print(f"warning: decisions-for emitted non-JSON for {path!r}", file=sys.stderr)
return None


def collect(rac_bin: str, corpus: str, changed_paths: list[str]) -> list[dict]:
"""Governing decisions merged across paths, sorted by decision id.

Each entry: id, title, status, path (the artifact file), matching entries
and changed paths as sorted lists. The engine already scopes the answer to
live decisions with a matching declared `## Applies To`.
"""
merged: dict[str, dict] = {}
for changed in changed_paths:
result = _lookup(rac_bin, changed, corpus)
if not result:
continue
for decision in result.get("decisions", []):
entry = merged.setdefault(
decision["id"],
{
"id": decision["id"],
"title": decision["title"],
"status": decision["status"],
"path": decision["path"],
"matching_entries": set(),
"changed_paths": set(),
},
)
entry["matching_entries"].add(decision["matching_entry"])
entry["changed_paths"].add(changed)
decisions = []
for entry in sorted(merged.values(), key=lambda e: e["id"]):
entry["matching_entries"] = sorted(entry["matching_entries"])
entry["changed_paths"] = sorted(entry["changed_paths"])
decisions.append(entry)
return decisions


def _bullet(decision: dict, link_base: str) -> str:
scopes = ", ".join(f"`{s}`" for s in decision["matching_entries"])
shown = decision["changed_paths"][:_PATHS_SHOWN]
changed = ", ".join(f"`{p}`" for p in shown)
more = len(decision["changed_paths"]) - len(shown)
if more > 0:
changed += f" +{more} more"
link = f"{link_base}/{decision['path']}" if link_base else decision["path"]
return (
f"- **[{decision['id']} — {decision['title']}]({link})**"
f" ({decision['status']}) — applies to {scopes} — changed: {changed}"
)


def render(decisions: list[dict], link_base: str, max_inline: int) -> str:
"""The comment body: marker, terse header, one bullet per decision."""
if not decisions:
return EMPTY_BODY
count = len(decisions)
plural = "" if count == 1 else "s"
lines = [
MARKER,
"### Decisions governing this change",
"",
f"This pull request touches paths governed by {count} recorded "
f"decision{plural} — review recommended.",
"",
]
inline = decisions[:max_inline] if max_inline > 0 else decisions
rest = decisions[len(inline) :]
lines.extend(_bullet(d, link_base) for d in inline)
if rest:
lines.append("")
lines.append(f"<details><summary>{len(rest)} more governing decision"
f"{'' if len(rest) == 1 else 's'}</summary>")
lines.append("")
lines.extend(_bullet(d, link_base) for d in rest)
lines.append("")
lines.append("</details>")
lines.append("")
return "\n".join(lines)


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--corpus", required=True)
parser.add_argument("--paths-file", required=True)
parser.add_argument("--link-base", default="")
parser.add_argument("--max-inline", type=int, default=5)
parser.add_argument("--out", required=True)
parser.add_argument("--rac-bin", default="rac")
args = parser.parse_args(argv)
with open(args.paths_file, encoding="utf-8") as fh:
changed_paths = sorted({line.strip() for line in fh if line.strip()})
decisions = collect(args.rac_bin, args.corpus, changed_paths)
body = render(decisions, args.link_base.rstrip("/"), args.max_inline)
with open(args.out, "w", encoding="utf-8") as fh:
fh.write(body)
has_decisions = "true" if decisions else "false"
output_path = os.environ.get("GITHUB_OUTPUT")
if output_path:
with open(output_path, "a", encoding="utf-8") as fh:
fh.write(f"has_decisions={has_decisions}\n")
print(f"{len(decisions)} governing decision(s); has_decisions={has_decisions}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading