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"

Reply via email to