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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ changes. The summary line says how many commits the series had before and
after, and whether its base is the same, a descendant or an ancestor of the
old one, or diverged from it. A base branch that was itself rewritten, as a
parent in a stack, is recognized from its force pushes in the repository's
activity log. Inputs and outputs are described in [action.yml](action.yml).
activity log. Range-diff comments at least three days old are hidden as
outdated on the next force push; `hide-after-days` changes or disables that.
Inputs and outputs are described in [action.yml](action.yml).

`pull_request_target` gives fork PRs a token that can comment. It is safe here
because nothing from the PR runs: the checkout is the base branch, the action
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ inputs:
max-comment-bytes:
description: "Interdiffs are truncated to keep the comment under this size; GitHub's limit is 65536."
default: "65000"
hide-after-days:
description: "On a force push, hide as outdated the earlier range-diff comments that are at least this many days old; empty keeps them all."
default: "3"
post-comment:
description: Set to `false` to render the comment to the log and job summary without posting it.
default: "true"
Expand All @@ -51,4 +54,5 @@ runs:
CREATION_FACTOR: ${{ inputs.creation-factor }}
MAX_COMMENT_BYTES: ${{ inputs.max-comment-bytes }}
POST_COMMENT: ${{ inputs.post-comment }}
HIDE_AFTER_DAYS: ${{ inputs.hide-after-days }}
run: bash "$GITHUB_ACTION_PATH/post-range-diff-comment.sh"
1 change: 1 addition & 0 deletions nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
mkdir -p "$out"
cp ${../post-range-diff-comment.sh} "$out/post-range-diff-comment.sh"
cp ${../render.py} "$out/render.py"
cp ${../outdated.py} "$out/outdated.py"
'';
in
{
Expand Down
38 changes: 38 additions & 0 deletions outdated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Pick the range-diff comments old enough to hide.

Reads one GraphQL comment node per line and prints the ids to minimize.
"""

from __future__ import annotations

import json
import sys
from datetime import datetime, timedelta, timezone

MARKER = "<!-- post-range-diff-comment "


def main(argv: list[str]) -> int:
comments = [json.loads(line) for line in sys.stdin if line.strip()]
days = float(argv[0])
for node_id in outdated(comments, now=datetime.now(timezone.utc), days=days):
print(node_id)
return 0


def outdated(comments: list[dict], *, now: datetime, days: float) -> list[str]:
cutoff = now - timedelta(days=days)
return [
c["id"]
for c in comments
# Only comments this token wrote, so a person's comment is never hidden.
if c["viewerDidAuthor"]
and c["body"].startswith(MARKER)
and not c["isMinimized"]
and datetime.fromisoformat(c["createdAt"].replace("Z", "+00:00")) <= cutoff
]


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
32 changes: 32 additions & 0 deletions post-range-diff-comment.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
# CREATION_FACTOR optional, passed to --creation-factor
# MAX_COMMENT_BYTES cap on the comment body
# POST_COMMENT "false" to render without posting
# HIDE_AFTER_DAYS hide earlier range-diff comments at least this old
set -euo pipefail

: "${BEFORE:?}" "${AFTER:?}" "${BASE_REF:?}" "${PR_NUMBER:?}"
Expand Down Expand Up @@ -110,6 +111,37 @@ if [[ ${POST_COMMENT:-true} == false ]]; then
fi

: "${GH_TOKEN:?}"
hide_outdated() {
local ids id
# shellcheck disable=SC2016
ids="$(gh api graphql --paginate \
-f owner="${REPO%/*}" -f name="${REPO#*/}" -F number="$PR_NUMBER" -f query='
query($owner: String!, $name: String!, $number: Int!, $endCursor: String) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
comments(first: 100, after: $endCursor) {
pageInfo { hasNextPage endCursor }
nodes { id body createdAt isMinimized viewerDidAuthor }
}
}
}
}' --jq '.data.repository.pullRequest.comments.nodes[]' |
python3 "$HERE/outdated.py" "$HIDE_AFTER_DAYS")" || return
for id in $ids; do
# shellcheck disable=SC2016
gh api graphql -f id="$id" -f query='
mutation($id: ID!) {
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
clientMutationId
}
}' >/dev/null || return
done
}
# Before posting, so the comment for this push is never among them.
if [[ -n ${HIDE_AFTER_DAYS:-} ]] && ! hide_outdated; then
echo "::warning::could not hide the outdated range-diff comments"
fi

if ! url="$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" \
-F body=@"$WORK/comment.md" --jq .html_url)"; then
echo "::error::could not post the comment; the token needs pull-requests: write" \
Expand Down
42 changes: 35 additions & 7 deletions tests/test_action.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import json
import os
import pathlib
import subprocess
import tempfile
import unittest

from test_outdated import comment

ROOT = pathlib.Path(__file__).resolve().parents[1]
PACKAGE = os.environ.get("POST_RANGE_DIFF_EXECUTABLE")
COMMENT_URL = "https://github.com/test/repo/pull/1#issuecomment-1"


class ActionTest(unittest.TestCase):
Expand Down Expand Up @@ -49,13 +53,26 @@ def run_action(
forced=True,
base_ref="main",
rewound=(),
comments=None,
):
# Stands in for the activity log: prints the old tips of the base branch.
# Stands in for GitHub: the old tips of the base branch, the comments
# of the pull request, and a record of the ones it was asked to hide.
stub = self.root / "stub"
stub.mkdir()
(stub / "comments").write_text(
"".join(json.dumps(c) + "\n" for c in comments or ())
)
gh = stub / "gh"
gh.write_text("#!/bin/sh\n" + "".join(f"echo {tip}\n" for tip in rewound))
gh.write_text(f"""#!/bin/sh
case "$*" in
*minimizeComment*) echo "$*" >>"{stub}/hidden" ;;
*graphql*) cat "{stub}/comments" ;;
*/activity*) printf '%s\\n' {" ".join(rewound)} ;;
*) echo {COMMENT_URL} ;;
esac
""")
gh.chmod(0o755)
posting = comments is not None
checkout = self.root / "checkout"
self.git(
"clone",
Expand All @@ -77,7 +94,9 @@ def run_action(
GITHUB_SERVER_URL="https://github.com",
GITHUB_OUTPUT=str(output),
GITHUB_STEP_SUMMARY=str(summary),
POST_COMMENT="false",
POST_COMMENT=str(posting).lower(),
GH_TOKEN="token",
HIDE_AFTER_DAYS="3",
PATH=f"{stub}{os.pathsep}{os.environ['PATH']}",
)
command = (
Expand All @@ -96,14 +115,15 @@ def run_action(
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(output.exists(), result.stderr)
self.assertEqual(output.read_text(), f"forced={str(forced).lower()}\n")
if forced:
posted = f"comment-url={COMMENT_URL}\n" if posting else ""
self.assertEqual(output.read_text(), f"forced={str(forced).lower()}\n{posted}")
if not forced:
self.assertFalse(summary.exists())
elif not posting:
self.assertIn("<b>range-diff</b>", result.stdout)
self.assertIn(self.before, result.stdout)
self.assertIn(self.after, result.stdout)
self.assertTrue(result.stdout.endswith(summary.read_text()))
else:
self.assertFalse(summary.exists())
return result.stdout

def test_force_push_full_checkout(self):
Expand Down Expand Up @@ -150,6 +170,14 @@ def test_series_an_old_base_tip_contained(self):
self.after = self.commit("child again\n", "child", amend=True, path="child")
self.run_action(base_ref="topic", rewound=[self.before])

def test_old_range_diff_comments_are_hidden(self):
old = comment("OLD", "2000-01-01T00:00:00Z")
new = comment("NEW", "2999-01-01T00:00:00Z")
self.run_action(comments=[old, new])
hidden = (self.root / "stub" / "hidden").read_text()
self.assertIn("id=OLD", hidden)
self.assertNotIn("id=NEW", hidden)

def test_force_push_shallow_checkout(self):
self.run_action(shallow=True)

Expand Down
45 changes: 45 additions & 0 deletions tests/test_outdated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import pathlib
import sys
import unittest
from datetime import datetime, timezone

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
import outdated

NOW = datetime(2026, 10, 8, 12, tzinfo=timezone.utc)


def comment(node_id, created, **changed):
return {
"id": node_id,
"createdAt": created,
"isMinimized": False,
"viewerDidAuthor": True,
"body": outdated.MARKER + "before=a after=b -->\n<details>",
} | changed


class OutdatedTest(unittest.TestCase):
def pick(self, *comments):
return outdated.outdated(list(comments), now=NOW, days=3)

def test_age(self):
picked = self.pick(
comment("old", "2026-10-01T00:00:00Z"),
comment("just old enough", "2026-10-05T12:00:00Z"),
comment("recent", "2026-10-05T12:00:01Z"),
)
self.assertEqual(picked, ["old", "just old enough"])

def test_only_its_own_visible_range_diffs(self):
old = "2026-10-01T00:00:00Z"
picked = self.pick(
comment("someone else's", old, viewerDidAuthor=False),
comment("another comment", old, body="looks good"),
comment("already hidden", old, isMinimized=True),
)
self.assertEqual(picked, [])


if __name__ == "__main__":
unittest.main()
Loading