Status-rollup comment

June 2, 2026 · View on GitHub

Every agent-authored status update on a <tracker> issue (the import receipt, each sync pass, CVE allocation, dedupe merges, fix-PR announcements, etc.) lands in one single rollup comment per tracker. Each pass appends a new entry to that comment instead of posting a fresh one. The result: scrolling a tracker's timeline shows one rollup comment plus the human discussion, not twenty bot comments drowning out the actual conversation.

This file is the canonical shape + upsert recipe. Skills (sync-*, import-*, allocate-*, deduplicate-*, fix-*) reference this file instead of re-specifying the shape.

The rollup comment shape

One comment per tracker, identified by an opening HTML marker on the first line:

<!-- airflow-s status rollup v1 — all bot-authored status updates fold into this single comment. -->
<details><summary>YYYY-MM-DD · @user · <Action></summary>

<entry body>

</details>

---

<details><summary>YYYY-MM-DD · @user · <Action></summary>

<entry body>

</details>

Rules (all load-bearing — breaking any of them breaks GitHub's Markdown rendering):

  • First line is the marker. <!-- airflow-s status rollup v1 — … --> identifies the comment as the rollup. Detection is anchored on airflow-s status rollup v so future v2 bumps remain findable.

  • Every entry is its own <details> block. Including the very first one (the import receipt). There is no always-visible preamble above the first <details>. The reason: every entry — current and historical — is collapsed by default per the user's rule; nothing is promoted above the fold.

  • Open tag is one line. Write <details><summary>…</summary> on a single line. Do not split <details> onto its own line and <summary> onto the next — that variant renders reliably on github.com too, but the one-line form is what the skills emit and detection passes match on.

  • Summary contains three fields, ·-separated, in this order: YYYY-MM-DD · @handle · <Action>. No trailing text, no parenthetical headlines, nothing else. The details-open arrow on github.com already consumes horizontal space; keep the summary scannable at a glance. Optional fourth field in parentheses is allowed only for disambiguation — see Summary — action labels below.

  • Exactly one blank line after <summary>…</summary>. Markdown inside a <details> needs a blank line after the open to render. Two blank lines is fine; zero blank lines silently suppresses all markdown inside.

  • Exactly one blank line before </details>. Same reason, other end.

  • No leading whitespace on any line inside the entry. A leading space or tab turns the line into a preformatted-code block and wrecks the rendering for every subsequent line. When a sync proposal pastes multi-line content into an entry, left-trim every line before writing.

  • Entries are separated by a bare --- on its own line, with one blank line on each side:

    </details>
    
    ---
    
    <details><summary>…</summary>
    

    The ruler is GitHub's <hr>. Skip the blank lines around it and the preceding </details> will stay attached.

  • Chronological order — newest at the bottom. New entries append; the comment grows downward. A reader opens the rollup and scrolls to the latest entry at the end.

Summary — action labels

Each skill emits one of the following <Action> strings so the summary line tells the reader at a glance what the entry represents:

Emitting skill<Action> valueOptional parenthetical
security-issue-importImportclass + reporter, e.g. Import (Report, Jane Doe)
security-issue-sync (ordinary pass)Syncone-phrase headline, e.g. Sync (pr merged → fix released)
security-issue-sync (escalation, Step 4)SyncSync (Step 4 escalation)
security-issue-sync (reformat-only, migrating legacy comments)ReformatReformat (N legacy comments folded)
security-cve-allocateCVE allocatedthe allocated ID, e.g. CVE allocated (CVE-2026-40913)
security-issue-deduplicate, on the kept trackerMerge (kept)dropped side's number, e.g. Merge (kept) (from #305)
security-issue-deduplicate, on the dropped trackerMerge (dropped)kept side's number, e.g. Merge (dropped) (into #244)
security-issue-fixFix PRupstream PR number, e.g. Fix PR (<upstream>#65346)

The parenthetical is optional; include it when it adds information a scroller actually wants (the CVE ID, the dedupe counterpart, the PR number). Do not restate the same fact inside the entry body and in the parenthetical; the body carries the detail, the summary carries the headline.

The entry body

Inside the <details> block, write what the skill used to write in its pre-collapse body — the bold headline, the **Next:** line, the reporter-notification line, the full rationale. The entire body is already inside <details>, so the "keep visible part under six lines" rule from the legacy status-comment shape no longer applies. Write what the auditor needs to reconstruct the decision; the scroller sees the summary only and only expands entries they care about.

That said — brevity still wins. Do not pad. Do not restate the previous entry. Each entry is incremental: what changed in this pass, what comes next, what the reporter now knows. Earlier state lives in earlier entries.

Required elements inside every entry body:

  • Bold headline as the first line — the same bold-first-line rule the old pre-collapse comments used. Example: **Sync 2026-04-21 — pr merged → fix released.**. This is what a reader sees first when they expand the entry.
  • **Next:** line — one sentence on what comes next. Omit only when the entry is terminal (e.g. dedupe on the dropped side, the "all triage continues on #" line replaces **Next:**).
  • Reporter-notification line when applicable — one of the four forms from the legacy spec (see each skill's dedicated section).

Outside that required frame, the content is free-form markdown. Clickable <tracker> references (the Linking tracker issues and PRs rule in AGENTS.md) apply everywhere, same as before.

Upsert recipe — append to an existing rollup, or create one

Every skill that emits a status update runs this recipe. The steps assume the skill has already composed <new-entry> — the full <details>…</details> block for this pass, with no leading/trailing blank lines.

1. Find the existing rollup comment

gh issue view <N> --repo <tracker> \
  --json comments \
  --jq '.comments[] | select(.body | startswith("<!-- airflow-s status rollup v")) | {id: .id, body: .body, url: .url}'

The matching comment is the rollup. If the query returns nothing, there is no rollup yet (expected on a fresh tracker where security-issue-import has not run, or on a legacy tracker that pre-dates this convention).

Use the first match chronologically if the query somehow returns more than one — two rollups is a bug; surface it to the user and let them pick which one to keep.

2a. Append to an existing rollup

Construct the new body by concatenating the old body + a ruler + the new entry, with exactly one blank line on each side of the ruler:

<old body>

---

<new entry>

Write the new body to a temp file and PATCH the comment:

python3 - <<'PY' > /tmp/rollup-body.md
import pathlib, subprocess, json, textwrap

old = subprocess.check_output(
    ["gh", "api", "repos/<tracker>/issues/comments/<comment-id>", "--jq", ".body"],
    text=True,
).rstrip("\n")
new_entry = pathlib.Path("/tmp/new-entry.md").read_text().rstrip("\n")
print(old + "\n\n---\n\n" + new_entry)
PY

jq -Rs '{body: .}' /tmp/rollup-body.md > /tmp/rollup-patch.json
gh api -X PATCH repos/<tracker>/issues/comments/<comment-id> --input /tmp/rollup-patch.json

The -X PATCH repos/<tracker>/issues/comments/<id> form is the only reliable way; gh issue comment --edit-last does not target an arbitrary comment, and the --input flag is needed because --field body=@file URL-encodes the newlines in the body.

2b. Create a new rollup

Only if Step 1 returned no existing rollup. Prepend the marker line and emit the new entry as the rollup's first entry:

<!-- airflow-s status rollup v1 — all bot-authored status updates fold into this single comment. -->
<new entry>

Post as a regular comment via gh issue comment --body-file:

gh issue comment <N> --repo <tracker> --body-file /tmp/rollup-body.md

Capture the returned comment URL + ID so subsequent passes in the same run can append without re-searching.

Migrating legacy comments into a rollup

Trackers created before this convention carry one or more bot-authored status comments as separate top-level comments. Every sync pass runs a fold-legacy sub-step: detect each legacy bot comment, move its content into the rollup as its own entry, and delete (or minimise) the original.

Detecting a legacy bot comment

A comment is a candidate for folding when all of the following hold:

  1. Not already a rollup. Its body does not start with <!-- airflow-s status rollup v.
  2. Author is on the security-team roster. Cross-check .comments[].author.login against the collaborator list (see operations.md) or the project's roster declared in <project-config>/release-trains.md. Human discussion from an external reporter is never folded; their content stays as a top-level comment.
  3. Body matches one of the bot-shape prefixes (case-sensitive, first ~500 characters of the body):
    • **Sync
    • **Status update
    • **Merged
    • **Closing as duplicate
    • **Split for scope clarity
    • **Imported on
    • **Process-step escalation
    • **Allocated CVE / **CVE allocated / **Sync … — CVE
    • Legacy bare-text prefixes (no leading **): Sync status (, Sync YYYY-MM-DD, Status update
    • Content tells when the prefix is idiosyncratic: security-issue-sync skill, re-triage, Reporter notification still pending, Outstanding — Step , a verbatim generate-cve-json embed block.

A comment that matches 1 + 2 + 3 is foldable. A comment that matches only 1 + 2 (team-member comment with no bot-shape prefix) is regular human discussion — leave it alone.

Folding a legacy comment into the rollup

For each foldable legacy comment, in chronological order:

  1. Reconstruct the entry shape. Take the legacy body and wrap it in the rollup's <details> envelope:

    <details><summary><createdAt date> · @<author.login> · <Action></summary>
    
    <legacy body verbatim, left-trimmed>
    
    </details>
    
    • <createdAt date> is the first 10 chars of the legacy comment's createdAt (YYYY-MM-DD).
    • <Action> is derived from the legacy body's prefix via the table in Summary — action labels above; when the prefix does not map cleanly, use Sync and tag the fold as Reformat (N legacy comments folded) on the overall rollup entry the sync is about to write.
    • Left-trim every line before pasting. Legacy comments that were hand-edited sometimes carry stray indentation (see airflow-s#244's 2026-04-20 comment, which had on most lines); leaving that indentation inside a <details> turns the whole entry into a preformatted-code block.
  2. Append the reconstructed entry to the rollup, using the upsert recipe above (Step 2a). Preserve the original order by appending oldest-first.

  3. Delete the legacy comment once the rollup PATCH succeeds:

    gh api -X DELETE repos/<tracker>/issues/comments/<legacy-comment-id>
    

    Only delete after the append lands — if the PATCH fails, the content is still on the tracker via the legacy comment and the fold can be retried on the next pass. Never delete first and hope the append works.

The fold-legacy sub-step is a proposal, not an auto-apply

Like every other skill action, surface each proposed fold as a numbered item in the skill's Step 2 proposal. Show the legacy comment's URL, its first ~3 lines, and the derived <Action> for the summary. The user may accept all, accept some, or reject (for example when a legacy comment has inline discussion from a reporter mixed into a status update — in that case leave it alone, it is not a pure bot comment).

When a tracker has no rollup yet but has many legacy comments

The fold path still works: create the rollup (Step 2b) with the oldest foldable legacy comment as its first entry, then append the rest (Step 2a), then append the current pass's new entry last. The recap reports "created new rollup comment on #, folded N legacy comments into it" and lists the deleted comment IDs so the user can audit the change.

Hard rules

  • Never touch a human-authored comment. Step 2 of the detection (author is on the security-team roster) is not optional. A reporter quoting a status update in their own words is not bot content — it is their message.
  • Never delete a legacy comment before the append succeeds. If the PATCH lands a partial body (e.g. truncated), the only recoverable artefact is the original legacy comment.
  • Never rewrite the content of a folded entry. Copy the legacy body verbatim inside the <details> block (with left-trim applied to fix indentation-induced rendering bugs, but nothing else). Paraphrasing a historical status update rewrites the audit trail.
  • Never promote a newer entry above an older one. The rollup is chronological. A sync pass that appends out of order hides the timeline.
  • Never write markdown with leading spaces inside a <details> block. A single stray indentation breaks rendering for every subsequent line of the entry. Compose entries with all lines flush-left.
  • Never create two rollups on the same tracker. If Step 1 of the upsert finds more than one <!-- airflow-s status rollup v marker, stop and ask the user which to keep — the cheapest recovery is a manual merge, not a silent overwrite.
  • Never name or describe other ASF projects' vulnerabilities in a rollup entry body, even when the reporter or your own signal mining has surfaced them. Cross-project observations belong in the private mail channel they arrived on — not in the tracker. See the "Other ASF projects — never name or describe their vulnerabilities" subsection of AGENTS.md for the full rule, the why, and the grep-list self-check to run before posting. Summarise load-bearing cross-project context in de-identified form ("the reporter has filed similar reports with other ASF projects") rather than naming the project.

Referenced by