This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new 7027f95 feat(github-rollup): append helper for status-rollup comments
— read/PATCH out of context (#424)
7027f95 is described below
commit 7027f95054ad77b1132ef7109e38ef30ba3fc0c6
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun May 31 20:59:30 2026 +0200
feat(github-rollup): append helper for status-rollup comments — read/PATCH
out of context (#424)
Every status-update-emitting skill (sync, import, allocate,
dedupe, fix) folds its update into one rollup comment per
tracker via the recipe in `tools/github/status-rollup.md`. The
recipe currently walks the agent through: fetch the comment
body, concatenate `<old body>` + ruler + new entry, PATCH the
comment. That loops the full rollup body — which grows
monotonically on long-running trackers — through agent context
on every sync pass.
`tools/github-rollup/` is the same shape as PR #412's
`github-body-field`: a stdlib + `gh` subprocess wrapper that
keeps the body out of agent context. CLI:
github-rollup append <N> --action "<label>" --entry-body "..."
github-rollup list <N>
github-rollup latest <N>
`append` auto-detects whether the issue already has a rollup
comment (via the marker prefix `<!-- airflow-s status rollup
v`); creates one if not, PATCHes the existing one if yes.
`--now ISO8601`, `--user @handle`, and `--dry-run` flags
support deterministic replay tests and pre-flight checks.
Parser is a small state machine that:
- recognises the rollup marker prefix (forward-compatible with
future v2+ bumps),
- iterates `<details><summary>YYYY-MM-DD · @user ·
Action</summary>...</details>` entries in document order,
- preserves entries whose summary doesn't follow the canonical
shape (returned with blank fields so round-trip stays safe),
- tolerates missing close tags + trailing whitespace.
35 unit tests cover the parser (every edge case from
`status-rollup.md`'s spec — multi-entry, missing marker, non-
canonical summary, missing close tag) and the CLI orchestration
(append-existing, create-new, dry-run create vs append, user
override, all the exit-code contract paths, list, latest).
The migration of the 5+ call sites that currently reference
`tools/github/status-rollup.md` Step 2a will follow in
subsequent PRs once the tool's behaviour is verified in
practice. Adds workspace member + docs/labels-and-capabilities
row in lock-step (caught by `check-workspace-members` prek
hook).
---
docs/labels-and-capabilities.md | 1 +
pyproject.toml | 1 +
tools/github-rollup/README.md | 84 ++++++
tools/github-rollup/pyproject.toml | 67 +++++
tools/github-rollup/src/github_rollup/__init__.py | 37 +++
tools/github-rollup/src/github_rollup/cli.py | 286 ++++++++++++++++++
tools/github-rollup/src/github_rollup/rollup.py | 166 ++++++++++
tools/github-rollup/tests/__init__.py | 0
tools/github-rollup/tests/test_cli.py | 350 ++++++++++++++++++++++
tools/github-rollup/tests/test_rollup.py | 240 +++++++++++++++
uv.lock | 6 +
11 files changed, 1238 insertions(+)
diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md
index c3e59cc..b5694a9 100644
--- a/docs/labels-and-capabilities.md
+++ b/docs/labels-and-capabilities.md
@@ -180,6 +180,7 @@ Tools under [`tools/`](../tools/). Tools with two values
(separated by
| [`tools/forwarder-relay`](../tools/forwarder-relay/) | `capability:setup` |
Adapter contract for inbound-relay backends (ASF Security relay, huntr.com,
HackerOne triagers). Pure interface spec; adapters declare detection +
credit-extraction + reporter-addressing rules. |
| [`tools/github`](../tools/github/) | `capability:setup` | GitHub REST /
GraphQL substrate (called by every lifecycle phase — pure substrate, no single
phase) |
| [`tools/github-body-field`](../tools/github-body-field/) |
`capability:setup` | Read or rewrite one `### Field` section of a GitHub issue
body without bringing the body into agent context — substrate helper for the
security-sync skills |
+| [`tools/github-rollup`](../tools/github-rollup/) | `capability:setup` |
Append to (or create) the status-rollup comment on a GitHub issue without
bringing the rollup body into agent context — substrate helper for every
status-update-emitting skill |
| [`tools/gmail`](../tools/gmail/) | `capability:setup` | Gmail API substrate |
| [`tools/jira`](../tools/jira/) | `capability:setup` | JIRA REST substrate
(read-only today; write subcommands tracked in
[#301](https://github.com/apache/airflow-steward/issues/301)) |
| [`tools/mail-archive`](../tools/mail-archive/) | `capability:setup` |
Adapter contract for public mail-archive backends (PonyMail, Hyperkitty,
Discourse, Google Groups, GitHub Discussions). Pure interface spec. |
diff --git a/pyproject.toml b/pyproject.toml
index abc56f9..e4ad7d8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -98,6 +98,7 @@ members = [
"tools/cve-tool-vulnogram/generate-cve-json",
"tools/cve-tool-vulnogram/oauth-api",
"tools/github-body-field",
+ "tools/github-rollup",
"tools/gmail/oauth-draft",
"tools/jira",
"tools/permission-audit",
diff --git a/tools/github-rollup/README.md b/tools/github-rollup/README.md
new file mode 100644
index 0000000..e6e903a
--- /dev/null
+++ b/tools/github-rollup/README.md
@@ -0,0 +1,84 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents** *generated with
[DocToc](https://github.com/thlorenz/doctoc)*
+
+- [github-rollup](#github-rollup)
+ - [Why](#why)
+ - [Invocation](#invocation)
+ - [`append <issue> --action "<label>" ...`](#append-issue---action-label-)
+ - [`list <issue>`](#list-issue)
+ - [`latest <issue>`](#latest-issue)
+ - [Failure modes](#failure-modes)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# github-rollup
+
+**Capability:** capability:setup
+
+Append to (or create) the status-rollup comment on a GitHub
+issue **without bringing the rollup body into agent context**.
+
+## Why
+
+Every skill that updates a `<tracker>` issue (import receipt,
+sync passes, CVE allocation, dedupe, fix-PR announcements) folds
+its status update into one rollup comment per tracker. The
+existing recipe in
+[`tools/github/status-rollup.md`](../github/status-rollup.md)
+walks the agent through: fetch the comment, concatenate
+`<old body>` + ruler + new entry, PATCH the comment. That loops
+the full rollup body — which grows monotonically and is the
+single largest comment on a long-running tracker — through agent
+context every sync pass.
+
+This tool does the read / append / PATCH in a subprocess. Only a
+one-line confirmation lands on the agent's stderr. The body never
+crosses the boundary.
+
+## Invocation
+
+```bash
+uv run --directory tools/github-rollup github-rollup <subcommand> ...
+```
+
+### `append <issue> --action "<label>" ...`
+
+Append a new entry to the rollup comment on `<issue>`. Creates
+the rollup if none exists. Required: `--action <label>` (the
+right-hand field of the entry's summary line). Body comes from
+either `--entry-body "<text>"` or `--entry-body-file <path>`
+(`-` reads stdin).
+
+Optional flags:
+
+- `--user @handle` — override the summary's `@user` field
+ (default: the authenticated `gh` user from `gh api user`).
+- `--now <ISO8601>` — override the date used in the summary
+ (default: real now). Useful for deterministic replay tests.
+- `--dry-run` — print the decision (create vs append) without
+ writing.
+
+### `list <issue>`
+
+Print every entry's summary line in order (or `--json` for a
+machine-readable array of `{date, user, action}`). Exit 3 if the
+issue has no rollup yet.
+
+### `latest <issue>`
+
+Print just the body of the most recent entry. Useful for
+*"what did the last sync do?"* in a follow-up script. Exit 3
+if the rollup or entries are missing.
+
+## Failure modes
+
+| Exit | Meaning |
+|---|---|
+| 0 | Success (or `--dry-run` planned). |
+| 2 | CLI argument error (mutually-exclusive flags, missing required). |
+| 3 | Issue has no rollup yet, or rollup has no entries (for `latest`). |
+| other | `gh` returned non-zero; the underlying stderr is forwarded. |
diff --git a/tools/github-rollup/pyproject.toml
b/tools/github-rollup/pyproject.toml
new file mode 100644
index 0000000..b9c88e9
--- /dev/null
+++ b/tools/github-rollup/pyproject.toml
@@ -0,0 +1,67 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "github-rollup"
+version = "0.1.0"
+description = "Append to (or create) the status-rollup comment on a GitHub
issue without bringing the rollup body into agent context."
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "Apache-2.0" }
+dependencies = []
+
+[project.scripts]
+github-rollup = "github_rollup:main"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/github_rollup"]
+
+[tool.ruff]
+line-length = 110
+target-version = "py311"
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = ["E", "W", "F", "I", "B", "UP", "SIM", "C4", "RUF"]
+ignore = ["E501"]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["B", "SIM"]
+
+[tool.mypy]
+python_version = "3.11"
+files = ["src", "tests"]
+warn_unused_ignores = true
+warn_redundant_casts = true
+warn_unreachable = true
+check_untyped_defs = true
+no_implicit_optional = true
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+
+[[tool.mypy.overrides]]
+module = "tests.*"
+disallow_untyped_defs = false
+disallow_incomplete_defs = false
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+addopts = "-ra -q"
+testpaths = ["tests"]
diff --git a/tools/github-rollup/src/github_rollup/__init__.py
b/tools/github-rollup/src/github_rollup/__init__.py
new file mode 100644
index 0000000..e7fd2df
--- /dev/null
+++ b/tools/github-rollup/src/github_rollup/__init__.py
@@ -0,0 +1,37 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from github_rollup.cli import main
+from github_rollup.rollup import (
+ ROLLUP_MARKER_PREFIX,
+ RollupEntry,
+ build_entry,
+ build_new_rollup_body,
+ iter_entries,
+ parse_summary_line,
+ rebuild_with_appended_entry,
+)
+
+__all__ = [
+ "ROLLUP_MARKER_PREFIX",
+ "RollupEntry",
+ "build_entry",
+ "build_new_rollup_body",
+ "iter_entries",
+ "main",
+ "parse_summary_line",
+ "rebuild_with_appended_entry",
+]
diff --git a/tools/github-rollup/src/github_rollup/cli.py
b/tools/github-rollup/src/github_rollup/cli.py
new file mode 100644
index 0000000..851ea32
--- /dev/null
+++ b/tools/github-rollup/src/github_rollup/cli.py
@@ -0,0 +1,286 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""CLI front-end for the rollup-comment append helper.
+
+Operates on GitHub issue comments via the ``gh`` CLI. Like the
+sibling `github-body-field` tool, this never streams the rollup
+body to the agent's stdout — the read / append / push happens in
+the subprocess and only a one-line summary lands on the agent
+side.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+import sys
+from collections.abc import Sequence
+from datetime import UTC, datetime
+from pathlib import Path
+
+from github_rollup.rollup import (
+ ROLLUP_MARKER_PREFIX,
+ build_entry,
+ build_new_rollup_body,
+ iter_entries,
+ rebuild_with_appended_entry,
+)
+
+
+def _gh_list_comments(issue: str, repo: str | None) -> list[dict]:
+ cmd = ["gh", "issue", "view", issue, "--json", "comments"]
+ if repo:
+ cmd.extend(["--repo", repo])
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
+ if result.returncode != 0:
+ sys.stderr.write(result.stderr)
+ raise SystemExit(result.returncode or 1)
+ return json.loads(result.stdout).get("comments", [])
+
+
+def _gh_auth_user() -> str:
+ """Return the currently-authenticated `gh` user's login."""
+ cmd = ["gh", "api", "user", "--jq", ".login"]
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
+ if result.returncode != 0:
+ sys.stderr.write(result.stderr)
+ raise SystemExit(result.returncode or 1)
+ return result.stdout.strip()
+
+
+def _gh_patch_comment(node_id: str, repo: str, body: str) -> None:
+ """PATCH an existing rollup comment with the rebuilt body."""
+ # The `comments` listing from `gh issue view` returns GitHub's
+ # GraphQL node ID. Map it to the REST id via a search hop.
+ rest_id_cmd = [
+ "gh",
+ "api",
+ f"repos/{repo}/issues/comments?per_page=100",
+ "--paginate",
+ "--jq",
+ f'.[] | select(.node_id == "{node_id}") | .id',
+ ]
+ result = subprocess.run(rest_id_cmd, capture_output=True, text=True,
check=False)
+ if result.returncode != 0:
+ sys.stderr.write(result.stderr)
+ raise SystemExit(result.returncode or 1)
+ rest_id = result.stdout.strip().splitlines()
+ if not rest_id:
+ sys.stderr.write(f"error: could not map node_id {node_id!r} to REST
id\n")
+ raise SystemExit(1)
+
+ patch_cmd = [
+ "gh",
+ "api",
+ "-X",
+ "PATCH",
+ f"repos/{repo}/issues/comments/{rest_id[0]}",
+ "-f",
+ f"body={body}",
+ ]
+ result = subprocess.run(patch_cmd, capture_output=True, text=True,
check=False)
+ if result.returncode != 0:
+ sys.stderr.write(result.stderr)
+ raise SystemExit(result.returncode or 1)
+
+
+def _gh_post_comment(issue: str, repo: str | None, body: str) -> None:
+ cmd = ["gh", "issue", "comment", issue, "--body-file", "-"]
+ if repo:
+ cmd.extend(["--repo", repo])
+ result = subprocess.run(cmd, input=body, text=True, capture_output=True,
check=False)
+ if result.returncode != 0:
+ sys.stderr.write(result.stderr)
+ raise SystemExit(result.returncode or 1)
+
+
+def _find_rollup(comments: list[dict]) -> dict | None:
+ """Return the first comment whose body begins with the rollup
+ marker, or None if no rollup exists on the issue."""
+ for c in comments:
+ body = c.get("body") or ""
+ if body.startswith(ROLLUP_MARKER_PREFIX):
+ return c
+ return None
+
+
+def _read_entry_body(args: argparse.Namespace) -> str:
+ if args.entry_body is not None and args.entry_body_file is not None:
+ sys.stderr.write("error: pass --entry-body OR --entry-body-file, not
both\n")
+ raise SystemExit(2)
+ if args.entry_body is not None:
+ return args.entry_body
+ if args.entry_body_file is not None:
+ if args.entry_body_file == "-":
+ return sys.stdin.read()
+ return Path(args.entry_body_file).read_text(encoding="utf-8")
+ sys.stderr.write("error: one of --entry-body / --entry-body-file is
required\n")
+ raise SystemExit(2)
+
+
+def _resolve_repo(args: argparse.Namespace) -> str:
+ """`gh` defaults the repo to the cwd's tracking remote when
+ `--repo` is omitted — but our PATCH path needs the repo string
+ explicitly to build the REST URL. Look it up if not given.
+ """
+ if args.repo:
+ return str(args.repo)
+ cmd = ["gh", "repo", "view", "--json", "nameWithOwner", "--jq",
".nameWithOwner"]
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
+ if result.returncode != 0:
+ sys.stderr.write(result.stderr)
+ raise SystemExit(result.returncode or 1)
+ return result.stdout.strip()
+
+
+def _format_date(now_iso: str | None) -> str:
+ if now_iso is None:
+ return datetime.now(UTC).strftime("%Y-%m-%d")
+ return datetime.fromisoformat(now_iso.replace("Z",
"+00:00")).strftime("%Y-%m-%d")
+
+
+def _cmd_append(args: argparse.Namespace) -> int:
+ body = _read_entry_body(args)
+ date = _format_date(args.now)
+ user = args.user or _gh_auth_user()
+ entry = build_entry(date=date, user=user, action=args.action, body=body)
+
+ repo = _resolve_repo(args)
+ comments = _gh_list_comments(args.issue, repo)
+ rollup = _find_rollup(comments)
+
+ if args.dry_run:
+ sys.stderr.write(f"action={args.action!r} date={date}
user=@{user.lstrip('@')}\n")
+ if rollup is None:
+ sys.stderr.write("dry-run: would CREATE a new rollup comment\n")
+ else:
+ sys.stderr.write(f"dry-run: would APPEND to existing rollup
comment {rollup.get('url')}\n")
+ return 0
+
+ if rollup is None:
+ # Create.
+ new_body = build_new_rollup_body(entry)
+ _gh_post_comment(args.issue, repo, new_body)
+ sys.stderr.write(f"created rollup on {repo}#{args.issue}
({args.action!r}, date={date})\n")
+ return 0
+
+ new_body = rebuild_with_appended_entry(rollup["body"], entry)
+ _gh_patch_comment(rollup["id"], repo, new_body)
+ sys.stderr.write(f"appended to rollup on {repo}#{args.issue}
({args.action!r}, date={date})\n")
+ return 0
+
+
+def _cmd_list(args: argparse.Namespace) -> int:
+ repo = _resolve_repo(args)
+ comments = _gh_list_comments(args.issue, repo)
+ rollup = _find_rollup(comments)
+ if rollup is None:
+ sys.stderr.write(f"no rollup comment on {repo}#{args.issue}\n")
+ return 3
+ entries = iter_entries(rollup["body"])
+ if args.json:
+ sys.stdout.write(
+ json.dumps(
+ [{"date": e.date, "user": e.user, "action": e.action} for e in
entries],
+ indent=2,
+ )
+ )
+ sys.stdout.write("\n")
+ else:
+ for e in entries:
+ sys.stdout.write(f"{e.date} · {e.user} · {e.action}\n")
+ return 0
+
+
+def _cmd_latest(args: argparse.Namespace) -> int:
+ repo = _resolve_repo(args)
+ comments = _gh_list_comments(args.issue, repo)
+ rollup = _find_rollup(comments)
+ if rollup is None:
+ sys.stderr.write(f"no rollup comment on {repo}#{args.issue}\n")
+ return 3
+ entries = iter_entries(rollup["body"])
+ if not entries:
+ sys.stderr.write(f"rollup on {repo}#{args.issue} has no entries\n")
+ return 3
+ last = entries[-1]
+ sys.stdout.write(last.body)
+ if not last.body.endswith("\n"):
+ sys.stdout.write("\n")
+ return 0
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="github-rollup",
+ description=(
+ "Append to (or create) the status-rollup comment on a "
+ "GitHub issue without bringing the rollup body into "
+ "agent context."
+ ),
+ )
+ parser.add_argument(
+ "--repo",
+ help="owner/repo. Defaults to the current working directory's tracked
remote.",
+ )
+
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ p_append = sub.add_parser("append", help="append (or create) a rollup
entry")
+ p_append.add_argument("issue", help="issue number")
+ p_append.add_argument("--action", required=True, help="action label (e.g.
'CVE allocated')")
+ p_append.add_argument("--entry-body", help="entry body text (use
--entry-body-file for multi-line)")
+ p_append.add_argument(
+ "--entry-body-file",
+ help="path to a file with the entry body; pass '-' to read stdin",
+ )
+ p_append.add_argument(
+ "--user",
+ help="@handle to record in the summary. Defaults to the authenticated
gh user.",
+ )
+ p_append.add_argument(
+ "--now",
+ help="ISO-8601 timestamp; the date field is derived from this.
Default: real now.",
+ )
+ p_append.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="print what would happen and exit without writing.",
+ )
+ p_append.set_defaults(func=_cmd_append)
+
+ p_list = sub.add_parser("list", help="list every entry in the rollup")
+ p_list.add_argument("issue", help="issue number")
+ p_list.add_argument("--json", action="store_true", help="JSON array
instead of one line per entry")
+ p_list.set_defaults(func=_cmd_list)
+
+ p_latest = sub.add_parser("latest", help="print the most recent entry's
body")
+ p_latest.add_argument("issue", help="issue number")
+ p_latest.set_defaults(func=_cmd_latest)
+
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = _build_parser()
+ args = parser.parse_args(argv)
+ return int(args.func(args))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/github-rollup/src/github_rollup/rollup.py
b/tools/github-rollup/src/github_rollup/rollup.py
new file mode 100644
index 0000000..e39c7ac
--- /dev/null
+++ b/tools/github-rollup/src/github_rollup/rollup.py
@@ -0,0 +1,166 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Pure parser + composer for the status-rollup comment shape.
+
+The on-disk spec lives at
+[`tools/github/status-rollup.md`](../../../../github/status-rollup.md);
+this module is the executable spec the CLI dispatches to. Keeping
+the parser pure (no I/O, no `gh` shellouts) lets the tests exhaust
+every edge case (legacy variants, missing rulers, embedded
+backticks, trailing whitespace) without mocking subprocess.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+# First line of any rollup comment. The trailing space is intentional —
+# the comment ends with ` -->` and matching just the prefix lets the
+# detector survive minor edits to the marker tail (e.g. a future
+# `airflow-s status rollup v2 — ...` bump).
+ROLLUP_MARKER_PREFIX = "<!-- airflow-s status rollup v"
+
+# Full first-line marker the tool writes when creating a new rollup.
+# Matches what every existing skill emits.
+_DEFAULT_MARKER_LINE = (
+ "<!-- airflow-s status rollup v1 — all bot-authored status updates fold
into this single comment. -->"
+)
+
+# Between consecutive `<details>` entries we always write exactly one
+# blank line, a horizontal rule, and one blank line. Keeping this as
+# a module constant guarantees `append` lays it down byte-identical
+# every time so the regex round-trips.
+_RULER_BETWEEN_ENTRIES = "\n\n---\n\n"
+
+# Open and close tags for one rollup entry. The open tag is one line
+# per the spec — split-tag variants get normalised on the next write.
+_OPEN_TAG_RE = re.compile(r"^<details><summary>(.+?)</summary>$", re.MULTILINE)
+_CLOSE_TAG = "</details>"
+
+
+@dataclass(frozen=True)
+class RollupEntry:
+ """One parsed rollup entry."""
+
+ date: str # YYYY-MM-DD
+ user: str # @handle (with leading `@`)
+ action: str # human-friendly action label
+ body: str # the markdown between <summary>...</summary> and </details>
+
+
+def parse_summary_line(summary: str) -> RollupEntry | None:
+ """Parse the summary line's three middle-dot-separated fields.
+
+ Returns ``None`` when the summary doesn't follow the
+ `YYYY-MM-DD · @handle · <Action>` shape — the caller can keep
+ the entry as raw text without failing the whole parse.
+ """
+ parts = [p.strip() for p in summary.split("·")]
+ if len(parts) != 3:
+ return None
+ date, user, action = parts
+ if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
+ return None
+ if not user.startswith("@") or len(user) < 2:
+ return None
+ if not action:
+ return None
+ return RollupEntry(date=date, user=user, action=action, body="")
+
+
+def iter_entries(rollup_body: str) -> list[RollupEntry]:
+ """Walk a full rollup-comment body and return every entry it
+ contains, ordered top-to-bottom.
+
+ Robust to:
+
+ - the marker line at the top of the comment,
+ - rulers (`---`) between entries,
+ - entries whose summary doesn't follow the canonical shape
+ (those are returned with `date=user=action=""` and the
+ surrounding context still in `body` for downstream
+ preservation),
+ - trailing whitespace and missing trailing newline.
+ """
+ text = rollup_body
+ # Drop the marker line if present so the regex doesn't false-match
+ # the marker's HTML-comment content.
+ if text.startswith(ROLLUP_MARKER_PREFIX):
+ nl = text.find("\n")
+ text = text[nl + 1 :] if nl != -1 else ""
+
+ entries: list[RollupEntry] = []
+ pos = 0
+ for match in _OPEN_TAG_RE.finditer(text):
+ summary = match.group(1)
+ body_start = match.end()
+ close_idx = text.find(f"\n{_CLOSE_TAG}", body_start)
+ if close_idx == -1:
+ # Tolerate a missing close tag at end-of-body.
+ entry_body = text[body_start:].lstrip("\n").rstrip()
+ next_pos = len(text)
+ else:
+ entry_body = text[body_start:close_idx].lstrip("\n").rstrip()
+ next_pos = close_idx + len(f"\n{_CLOSE_TAG}")
+ parsed = parse_summary_line(summary)
+ if parsed is None:
+ entries.append(RollupEntry(date="", user="", action="",
body=entry_body))
+ else:
+ entries.append(
+ RollupEntry(
+ date=parsed.date,
+ user=parsed.user,
+ action=parsed.action,
+ body=entry_body,
+ )
+ )
+ pos = next_pos
+ # `pos` is unused for the return but kept so future callers can
+ # compute trailing content if they need to.
+ _ = pos
+ return entries
+
+
+def build_entry(*, date: str, user: str, action: str, body: str) -> str:
+ """Compose one rollup entry — the `<details>...</details>` block
+ a single sync pass appends to the rollup.
+
+ The result has no trailing newline; the caller decides whether
+ to glue a ruler before it. The body's leading + trailing
+ whitespace is stripped to keep the rendered shape consistent.
+ """
+ if not user.startswith("@"):
+ user = f"@{user}"
+ body_stripped = body.strip()
+ summary = f"{date} · {user} · {action}"
+ return
f"<details><summary>{summary}</summary>\n\n{body_stripped}\n\n{_CLOSE_TAG}"
+
+
+def build_new_rollup_body(entry: str) -> str:
+ """Compose a brand-new rollup comment body wrapping the first
+ entry. Use when no rollup comment exists yet on the tracker.
+ """
+ return f"{_DEFAULT_MARKER_LINE}\n{entry}"
+
+
+def rebuild_with_appended_entry(existing_body: str, new_entry: str) -> str:
+ """Append ``new_entry`` to an existing rollup body, separated by
+ the canonical ruler block. Strips any trailing whitespace from
+ ``existing_body`` so the ruler lands in the right place.
+ """
+ return f"{existing_body.rstrip()}{_RULER_BETWEEN_ENTRIES}{new_entry}"
diff --git a/tools/github-rollup/tests/__init__.py
b/tools/github-rollup/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tools/github-rollup/tests/test_cli.py
b/tools/github-rollup/tests/test_cli.py
new file mode 100644
index 0000000..a589ad5
--- /dev/null
+++ b/tools/github-rollup/tests/test_cli.py
@@ -0,0 +1,350 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""CLI tests — `gh` is faked via a subprocess.run patch."""
+
+from __future__ import annotations
+
+import json
+import subprocess
+from collections.abc import Iterator
+from dataclasses import dataclass
+
+import pytest
+
+from github_rollup import cli
+
+MARKER_LINE = (
+ "<!-- airflow-s status rollup v1 — all bot-authored status updates fold
into this single comment. -->"
+)
+
+ROLLUP_BODY = (
+ f"{MARKER_LINE}\n"
+ "<details><summary>2026-05-28 · @potiuk · Import</summary>\n\n"
+ "Imported from security@.\n\n"
+ "</details>"
+)
+
+
+@dataclass
+class FakeResult:
+ returncode: int
+ stdout: str = ""
+ stderr: str = ""
+
+
+class FakeGh:
+ """Records every `gh` call and returns canned output for each
+ pattern. Tests assert on the recorded calls."""
+
+ def __init__(self, has_rollup: bool = True) -> None:
+ self.calls: list[tuple[list[str], str | None]] = []
+ self.has_rollup = has_rollup
+ # Track the last patched body so tests can verify what we
+ # actually wrote.
+ self.patched_body: str | None = None
+ self.posted_body: str | None = None
+
+ def __call__(
+ self,
+ cmd: list[str],
+ *,
+ capture_output: bool = False,
+ text: bool = False,
+ input: str | None = None,
+ check: bool = False,
+ **_: object,
+ ) -> FakeResult:
+ self.calls.append((cmd, input))
+ joined = " ".join(cmd)
+
+ if "issue view" in joined and "--json comments" in joined:
+ comments = []
+ if self.has_rollup:
+ comments.append(
+ {
+ "id": "IC_NODEID",
+ "body": ROLLUP_BODY,
+ "url":
"https://github.com/o/r/issues/1#issuecomment-1",
+ }
+ )
+ return FakeResult(returncode=0, stdout=json.dumps({"comments":
comments}))
+
+ if "api user" in joined:
+ return FakeResult(returncode=0, stdout="potiuk\n")
+
+ if "repo view" in joined:
+ return FakeResult(returncode=0, stdout="o/r\n")
+
+ if "issues/comments?per_page=100" in joined and "node_id" in joined:
+ # REST-id resolution stub.
+ return FakeResult(returncode=0, stdout="98765\n")
+
+ if "-X" in cmd and "PATCH" in cmd and "issues/comments" in joined:
+ # Extract the body from -f body=<value>.
+ for i, tok in enumerate(cmd):
+ if tok == "-f" and i + 1 < len(cmd) and cmd[i +
1].startswith("body="):
+ self.patched_body = cmd[i + 1][len("body=") :]
+ break
+ return FakeResult(returncode=0)
+
+ if "issue comment" in joined and "--body-file" in cmd and "-" in cmd:
+ self.posted_body = input
+ return FakeResult(returncode=0)
+
+ raise AssertionError(f"unexpected gh invocation: {cmd!r}")
+
+
[email protected]
+def fake_gh(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeGh]:
+ f = FakeGh()
+ monkeypatch.setattr(subprocess, "run", f)
+ yield f
+
+
[email protected]
+def fake_gh_no_rollup(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeGh]:
+ f = FakeGh(has_rollup=False)
+ monkeypatch.setattr(subprocess, "run", f)
+ yield f
+
+
+# ---------------------------------------------------------------------------
+# append — append path
+# ---------------------------------------------------------------------------
+
+
+def test_append_to_existing_rollup_patches_with_new_entry(
+ fake_gh: FakeGh, capsys: pytest.CaptureFixture[str]
+) -> None:
+ rc = cli.main(
+ [
+ "--repo",
+ "o/r",
+ "append",
+ "1",
+ "--action",
+ "CVE allocated",
+ "--entry-body",
+ "Allocated CVE-2026-12345.",
+ "--now",
+ "2026-05-30T12:00:00Z",
+ ]
+ )
+ assert rc == 0
+ assert fake_gh.patched_body is not None
+ # The PATCH body contains BOTH the existing Import entry and the
+ # new CVE allocated entry, separated by the canonical ruler.
+ assert "Import" in fake_gh.patched_body
+ assert "CVE allocated" in fake_gh.patched_body
+ assert "</details>\n\n---\n\n<details>" in fake_gh.patched_body
+ err = capsys.readouterr().err
+ assert "appended to rollup on o/r#1" in err
+
+
+def test_append_uses_gh_auth_user_when_user_flag_omitted(
+ fake_gh: FakeGh, capsys: pytest.CaptureFixture[str]
+) -> None:
+ cli.main(
+ [
+ "--repo",
+ "o/r",
+ "append",
+ "1",
+ "--action",
+ "Sync",
+ "--entry-body",
+ "X",
+ "--now",
+ "2026-05-30T12:00:00Z",
+ ]
+ )
+ assert fake_gh.patched_body is not None
+ # FakeGh returns `potiuk` from `gh api user`.
+ assert "· @potiuk ·" in fake_gh.patched_body
+
+
+def test_append_explicit_user_overrides_auth(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ cli.main(
+ [
+ "--repo",
+ "o/r",
+ "append",
+ "1",
+ "--action",
+ "Sync",
+ "--entry-body",
+ "X",
+ "--user",
+ "@other-user",
+ "--now",
+ "2026-05-30T12:00:00Z",
+ ]
+ )
+ assert fake_gh.patched_body is not None
+ assert "· @other-user ·" in fake_gh.patched_body
+
+
+def test_append_dry_run_skips_writes(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(
+ [
+ "--repo",
+ "o/r",
+ "append",
+ "1",
+ "--action",
+ "Sync",
+ "--entry-body",
+ "X",
+ "--now",
+ "2026-05-30T12:00:00Z",
+ "--dry-run",
+ ]
+ )
+ assert rc == 0
+ assert fake_gh.patched_body is None
+ assert fake_gh.posted_body is None
+ err = capsys.readouterr().err
+ assert "dry-run" in err
+ assert "APPEND" in err
+
+
+# ---------------------------------------------------------------------------
+# append — create path
+# ---------------------------------------------------------------------------
+
+
+def test_append_creates_new_rollup_when_none_exists(
+ fake_gh_no_rollup: FakeGh, capsys: pytest.CaptureFixture[str]
+) -> None:
+ rc = cli.main(
+ [
+ "--repo",
+ "o/r",
+ "append",
+ "1",
+ "--action",
+ "Import",
+ "--entry-body",
+ "Imported from security@.",
+ "--now",
+ "2026-05-30T12:00:00Z",
+ ]
+ )
+ assert rc == 0
+ body = fake_gh_no_rollup.posted_body
+ assert body is not None
+ assert body.startswith("<!-- airflow-s status rollup v1 — all bot-authored
status updates fold")
+ assert "· @potiuk · Import" in body
+ assert "Imported from security@" in body
+ err = capsys.readouterr().err
+ assert "created rollup on o/r#1" in err
+
+
+def test_append_dry_run_signals_create_path(
+ fake_gh_no_rollup: FakeGh, capsys: pytest.CaptureFixture[str]
+) -> None:
+ rc = cli.main(
+ [
+ "--repo",
+ "o/r",
+ "append",
+ "1",
+ "--action",
+ "Import",
+ "--entry-body",
+ "X",
+ "--now",
+ "2026-05-30T12:00:00Z",
+ "--dry-run",
+ ]
+ )
+ assert rc == 0
+ err = capsys.readouterr().err
+ assert "CREATE" in err
+
+
+# ---------------------------------------------------------------------------
+# append — argument validation
+# ---------------------------------------------------------------------------
+
+
+def test_append_rejects_both_entry_body_flags(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ with pytest.raises(SystemExit) as exc:
+ cli.main(
+ [
+ "--repo",
+ "o/r",
+ "append",
+ "1",
+ "--action",
+ "Sync",
+ "--entry-body",
+ "x",
+ "--entry-body-file",
+ "/tmp/x",
+ ]
+ )
+ assert exc.value.code == 2
+ err = capsys.readouterr().err
+ assert "not both" in err
+
+
+def test_append_requires_an_entry_body_source(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ with pytest.raises(SystemExit) as exc:
+ cli.main(["--repo", "o/r", "append", "1", "--action", "Sync"])
+ assert exc.value.code == 2
+ err = capsys.readouterr().err
+ assert "required" in err
+
+
+# ---------------------------------------------------------------------------
+# list and latest
+# ---------------------------------------------------------------------------
+
+
+def test_list_prints_one_summary_per_line(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(["--repo", "o/r", "list", "1"])
+ assert rc == 0
+ out = capsys.readouterr().out
+ assert "2026-05-28 · @potiuk · Import" in out
+
+
+def test_list_json(fake_gh: FakeGh, capsys: pytest.CaptureFixture[str]) ->
None:
+ rc = cli.main(["--repo", "o/r", "list", "1", "--json"])
+ assert rc == 0
+ out = capsys.readouterr().out
+ parsed = json.loads(out)
+ assert parsed == [{"date": "2026-05-28", "user": "@potiuk", "action":
"Import"}]
+
+
+def test_list_exits_3_when_no_rollup(fake_gh_no_rollup: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(["--repo", "o/r", "list", "1"])
+ assert rc == 3
+ err = capsys.readouterr().err
+ assert "no rollup" in err
+
+
+def test_latest_prints_last_entry_body(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(["--repo", "o/r", "latest", "1"])
+ assert rc == 0
+ out = capsys.readouterr().out
+ assert "Imported from security@" in out
+
+
+def test_latest_exits_3_when_no_rollup(fake_gh_no_rollup: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(["--repo", "o/r", "latest", "1"])
+ assert rc == 3
diff --git a/tools/github-rollup/tests/test_rollup.py
b/tools/github-rollup/tests/test_rollup.py
new file mode 100644
index 0000000..938c4ae
--- /dev/null
+++ b/tools/github-rollup/tests/test_rollup.py
@@ -0,0 +1,240 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import pytest
+
+from github_rollup.rollup import (
+ ROLLUP_MARKER_PREFIX,
+ build_entry,
+ build_new_rollup_body,
+ iter_entries,
+ parse_summary_line,
+ rebuild_with_appended_entry,
+)
+
+MARKER_LINE = (
+ "<!-- airflow-s status rollup v1 — all bot-authored status updates fold
into this single comment. -->"
+)
+
+
+# ---------------------------------------------------------------------------
+# parse_summary_line
+# ---------------------------------------------------------------------------
+
+
+def test_parse_canonical_summary():
+ e = parse_summary_line("2026-05-30 · @potiuk · CVE allocated
(CVE-2026-12345)")
+ assert e is not None
+ assert e.date == "2026-05-30"
+ assert e.user == "@potiuk"
+ assert e.action == "CVE allocated (CVE-2026-12345)"
+
+
+def test_parse_with_extra_whitespace():
+ e = parse_summary_line(" 2026-05-30 · @potiuk · Sync pass ")
+ assert e is not None
+ assert e.date == "2026-05-30"
+ assert e.user == "@potiuk"
+ assert e.action == "Sync pass"
+
+
[email protected](
+ "summary",
+ [
+ "not a summary",
+ "2026-05-30 · @potiuk", # missing action
+ "26-05-30 · @potiuk · Sync", # bad date
+ "2026-13-99 · @potiuk · Sync", # invalid month/day but format matches
+ "2026-05-30 · potiuk · Sync", # missing @
+ "2026-05-30 · @ · Sync", # bare @
+ "2026-05-30 · @potiuk · ", # empty action
+ ],
+)
+def test_parse_summary_rejects_invalid(summary):
+ # Some of the above (e.g. 2026-13-99) only fail format-wise, but
+ # the parser still returns None for the missing-action and bad-
+ # handle cases. The 2026-13-99 case passes the regex (4-2-2 digit
+ # groups) and is returned with raw fields — we don't validate
+ # calendar correctness here.
+ result = parse_summary_line(summary)
+ if "13-99" in summary:
+ assert result is not None # regex-permissive
+ return
+ assert result is None
+
+
+# ---------------------------------------------------------------------------
+# iter_entries
+# ---------------------------------------------------------------------------
+
+
+def test_iter_entries_single():
+ body = (
+ f"{MARKER_LINE}\n"
+ "<details><summary>2026-05-30 · @potiuk · CVE allocated</summary>\n"
+ "\n"
+ "Allocated CVE-2026-12345 via Vulnogram.\n"
+ "\n"
+ "</details>"
+ )
+ entries = iter_entries(body)
+ assert len(entries) == 1
+ e = entries[0]
+ assert e.date == "2026-05-30"
+ assert e.user == "@potiuk"
+ assert e.action == "CVE allocated"
+ assert "Allocated CVE-2026-12345" in e.body
+
+
+def test_iter_entries_multiple():
+ body = (
+ f"{MARKER_LINE}\n"
+ "<details><summary>2026-05-28 · @potiuk · Import</summary>\n\n"
+ "First.\n\n"
+ "</details>\n"
+ "\n---\n\n"
+ "<details><summary>2026-05-30 · @potiuk · Sync</summary>\n\n"
+ "Second.\n\n"
+ "</details>"
+ )
+ entries = iter_entries(body)
+ assert len(entries) == 2
+ assert entries[0].action == "Import"
+ assert entries[1].action == "Sync"
+ assert "First." in entries[0].body
+ assert "Second." in entries[1].body
+
+
+def test_iter_entries_no_marker_line():
+ """If a caller passes a body without the marker (e.g. they pre-
+ stripped it), still parse what's there."""
+ body = "<details><summary>2026-05-30 · @potiuk · CVE
allocated</summary>\n\nBody.\n\n</details>"
+ entries = iter_entries(body)
+ assert len(entries) == 1
+
+
+def test_iter_entries_empty_body():
+ assert iter_entries("") == []
+
+
+def test_iter_entries_missing_close_tag_tolerated():
+ body = (
+ f"{MARKER_LINE}\n"
+ "<details><summary>2026-05-30 · @potiuk · Sync</summary>\n\n"
+ "Body that was never closed."
+ )
+ entries = iter_entries(body)
+ assert len(entries) == 1
+ assert "Body that was never closed." in entries[0].body
+
+
+def test_iter_entries_non_canonical_summary_preserved():
+ body = f"{MARKER_LINE}\n<details><summary>2026-05-30 · @potiuk ·
</summary>\n\nBody.\n\n</details>"
+ entries = iter_entries(body)
+ assert len(entries) == 1
+ # The summary was non-canonical (empty action), so the parsed
+ # fields are blank but the body is still returned for round-trip.
+ e = entries[0]
+ assert e.date == ""
+ assert e.user == ""
+ assert e.action == ""
+ assert "Body." in e.body
+
+
+# ---------------------------------------------------------------------------
+# build_entry
+# ---------------------------------------------------------------------------
+
+
+def test_build_entry_canonical():
+ e = build_entry(
+ date="2026-05-30",
+ user="@potiuk",
+ action="CVE allocated",
+ body="Allocated CVE-2026-12345.",
+ )
+ assert e == (
+ "<details><summary>2026-05-30 · @potiuk · CVE allocated</summary>\n"
+ "\n"
+ "Allocated CVE-2026-12345.\n"
+ "\n"
+ "</details>"
+ )
+
+
+def test_build_entry_adds_at_prefix():
+ e = build_entry(date="2026-05-30", user="potiuk", action="Sync", body="x")
+ assert "· @potiuk ·" in e
+
+
+def test_build_entry_strips_body_whitespace():
+ e = build_entry(date="2026-05-30", user="@a", action="b", body="\n\n
body\n\n")
+ assert "\n\nbody\n\n</details>" in e
+
+
+# ---------------------------------------------------------------------------
+# build_new_rollup_body
+# ---------------------------------------------------------------------------
+
+
+def test_build_new_rollup_body_includes_marker_first():
+ entry = build_entry(date="2026-05-30", user="@a", action="b", body="c")
+ body = build_new_rollup_body(entry)
+ assert body.startswith(ROLLUP_MARKER_PREFIX)
+ assert "<details>" in body
+ # Marker line + immediately the entry — no blank line gap.
+ first_nl = body.find("\n")
+ assert body[first_nl + 1 :].startswith("<details>")
+
+
+# ---------------------------------------------------------------------------
+# rebuild_with_appended_entry
+# ---------------------------------------------------------------------------
+
+
+def test_rebuild_with_appended_entry_inserts_canonical_ruler():
+ existing = f"{MARKER_LINE}\n<details><summary>2026-05-28 · @a ·
Import</summary>\n\nA.\n\n</details>"
+ new = build_entry(date="2026-05-30", user="@b", action="Sync", body="B.")
+ out = rebuild_with_appended_entry(existing, new)
+ assert "</details>\n\n---\n\n<details>" in out
+ # Both entries are findable by iter_entries.
+ entries = iter_entries(out)
+ assert len(entries) == 2
+ assert entries[0].action == "Import"
+ assert entries[1].action == "Sync"
+
+
+def test_rebuild_strips_trailing_whitespace_before_ruler():
+ existing = (
+ f"{MARKER_LINE}\n<details><summary>2026-05-28 · @a ·
Import</summary>\n\nA.\n\n</details>\n\n\n \n"
+ )
+ new = build_entry(date="2026-05-30", user="@b", action="Sync", body="B.")
+ out = rebuild_with_appended_entry(existing, new)
+ # Exactly the canonical ruler — no double blanks.
+ assert "</details>\n\n---\n\n<details>" in out
+ assert "</details>\n\n\n---" not in out
+
+
+def test_round_trip_three_appends_keeps_count():
+ existing = build_new_rollup_body(build_entry(date="2026-05-01", user="@a",
action="x1", body="b1"))
+ for i, action in enumerate(["x2", "x3"], start=2):
+ existing = rebuild_with_appended_entry(
+ existing,
+ build_entry(date=f"2026-05-0{i}", user="@a", action=action,
body=f"b{i}"),
+ )
+ assert [e.action for e in iter_entries(existing)] == ["x1", "x2", "x3"]
diff --git a/uv.lock b/uv.lock
index 2292880..bb935c4 100644
--- a/uv.lock
+++ b/uv.lock
@@ -17,6 +17,7 @@ members = [
"checker",
"generate-cve-json",
"github-body-field",
+ "github-rollup",
"jira-bridge",
"oauth-draft",
"permission-audit",
@@ -350,6 +351,11 @@ name = "github-body-field"
version = "0.1.0"
source = { editable = "tools/github-body-field" }
+[[package]]
+name = "github-rollup"
+version = "0.1.0"
+source = { editable = "tools/github-rollup" }
+
[[package]]
name = "google-auth"
version = "2.53.0"