jdaugherty commented on code in PR #16071:
URL: https://github.com/apache/grails-core/pull/16071#discussion_r3721632692


##########
grails-benchmarks/src/report/groovy/org/apache/grails/benchmarks/report/ReportRenderer.groovy:
##########
@@ -0,0 +1,148 @@
+/*
+ * 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
+ *
+ *     https://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.
+ */
+package org.apache.grails.benchmarks.report
+
+import groovy.transform.CompileStatic
+
+@CompileStatic
+class ReportRenderer {
+
+    static final String MARKER = '<!-- grails-jmh-benchmark -->'
+    static final String DASH = 'โ€”'
+    static final String BENCHMARK_PACKAGE = 'org.apache.grails.benchmarks.'
+
+    static String safe(String value) {
+        value.replaceAll('[`|<>\\[\\]\\(\\)!\\r\\n]', '')
+    }
+
+    static String number(Double value) {
+        if (value == null || !Double.isFinite(value)) {
+            return DASH
+        }
+        String rendered = String.format(Locale.ROOT, '%.3g', value)
+        int exponent = rendered.indexOf('e')
+        String mantissa = exponent >= 0 ? rendered.substring(0, exponent) : 
rendered
+        String suffix = exponent >= 0 ? rendered.substring(exponent) : ''
+        if (mantissa.contains('.')) {
+            mantissa = mantissa.replaceFirst('0+$', '').replaceFirst('\\.$', 
'')
+        }
+        return mantissa + suffix
+    }
+
+    static String speedup(Double value) {
+        value != null && Double.isFinite(value)
+                ? String.format(Locale.ROOT, '%.2fx', value)
+                : DASH
+    }
+
+    static String identity(String value) {
+        safe(removePrefix(value, BENCHMARK_PACKAGE))
+    }
+
+    static String score(Benchmark value) {
+        String rendered = number(value.score)
+        rendered == DASH ? DASH : rendered + ' ' + safe(value.unit)
+    }
+
+    static String allocation(ComparisonRow row) {
+        if (row.allocationDelta == null || row.allocationPercent == null) {
+            return DASH
+        }
+        if (Math.abs(row.allocationDelta) < 1D) {
+            return '~0 B/op'
+        }
+        return String.format(Locale.ROOT, '%+,.0f B/op (%+.1f%%)%s',
+                row.allocationDelta,
+                row.allocationPercent * 100D,
+                row.allocationCandidate ? ' **candidate**' : '')
+    }
+
+    static String render(Comparison comparison) {
+        int regressions = comparison.rows.count { ComparisonRow row -> 
row.verdict == 'REGRESSED' }.intValue()
+        int improvements = comparison.rows.count { ComparisonRow row -> 
row.verdict == 'IMPROVED' }.intValue()
+        List<RulerDeviation> deviations = comparison.rulerDeviations ?: 
comparison.rulerSpeedups.collect { List<Object> item -> new RulerDeviation('', 
(String) item[0], (Double) item[1]) }
+        RulerDeviation worst = deviations ? deviations.max { RulerDeviation 
item -> deviation(item.speedup) } : null
+        String health = comparison.rulerIncomplete
+                ? 'INCOMPLETE/unreliable (missing or non-finite ruler 
measurements)'
+                : worst == null
+                        ? 'not measured'
+                        : "worst ruler deviation: ${String.format(Locale.ROOT, 
'%.1f%%', deviation(worst.speedup) * 100D)}${worst.shard ? ' in ' + 
safe(worst.shard) : ''} (${safe(removePrefix(worst.identity, 
BenchmarkComparator.RULER_PACKAGE))})"
+        List<String> lines = ['### JMH Benchmark Report', '', 
"**Regressions:** ${regressions}".toString(), "**Improvements:** 
${improvements}".toString(), "**Runner health:** ${health}".toString(), 'Ruler 
benchmarks are excluded from verdicts and group summaries; runner health is a 
stability check, not a calibration factor.']
+        if (comparison.missingShards) lines.addAll(['', '> **Warning:** No 
usable base/head pair was produced by ' + comparison.missingShards.collect { 
String item -> safe(item) }.join(', ') + ". This comparison rests on 
${comparison.pairedShards.size()} shard pair(s) instead of the expected 
${comparison.pairedShards.size() + comparison.missingShards.size()}, so the 
alternating measurement order did not fully cancel and the result is weaker 
than a normal run."])
+        if (deviations) {
+            lines.add('**Ruler movements:** ' + deviations.collect { 
RulerDeviation item ->

Review Comment:
   Line 85 emits a `>` blockquote and this line follows it with no blank line, 
so GFM lazy continuation pulls "Ruler movements" *inside* the warning 
blockquote. Confirmed against GitHub's own renderer (`POST /markdown`, `mode: 
gfm`):
   
   ```html
   <blockquote>
   <p><strong>Warning:</strong> No usable base/head pair was produced by 
shard-c.json.<br>
   <strong>Ruler movements:</strong> shard-a.json: CpuRuler.run: 0.89x, ...</p>
   </blockquote>
   ```
   
   This only fires when `missingShards` is non-empty โ€” which is exactly the 
degraded run the warning exists to call out, and the one where the ruler 
numbers matter most. Prefixing the `lines.add` with `''` fixes it.
   
   The two warnings below (lines 93 and 94) already do this correctly, so it's 
just this one.
   
   Worth noting the golden fixtures can't catch this class of bug: they lock 
the Markdown *source* bytes (`expected-shards.md` lines 8-9 encode the current, 
broken layout), not the rendered output.



##########
.github/workflows/benchmark.yml:
##########
@@ -0,0 +1,396 @@
+# 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
+#
+#     https://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.
+
+name: "JMH Benchmark Comparison"
+
+# SECURITY: This workflow deliberately uses pull_request, never 
pull_request_target.
+# Pull requests run untrusted code, and Apache Infra policy forbids exposing 
tokens
+# to that code through a privileged pull_request_target workflow.
+on:
+  pull_request:
+    types: [opened, synchronize, reopened, labeled]
+    paths-ignore:
+      - '**/*.md'
+      - '**/*.adoc'
+      - 'grails-doc/**'
+  workflow_dispatch:
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || 
github.run_id }}
+  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+  contents: read
+
+jobs:
+  # Each shard builds both revisions before measuring either one on the same 
runner.
+  # Building first avoids CPU, IO, thermal, cache, and frequency state biasing 
a measurement.
+  # Shard a measures BASE then HEAD, while shard b measures HEAD then BASE.
+  # Alternating the order cancels first-versus-second ordering bias without 
doubling runtime.
+  # Both shards use the PR merge commit as HEAD, so they measure what would 
actually land.
+  # Results are advisory: a detected regression never fails this workflow.
+  benchmark:
+    name: "Paired JMH benchmarks (${{ matrix.shard }})"
+    if: >-
+      github.event_name == 'workflow_dispatch' ||
+      (contains(github.event.pull_request.labels.*.name, 'performance') &&
+       (github.event.action != 'labeled' || github.event.label.name == 
'performance'))
+    runs-on: ubuntu-24.04
+    strategy:
+      fail-fast: false
+      matrix:
+        shard: [a, b]
+    env:
+      BASE_SHA: ${{ github.event.pull_request.base.sha }}
+      HEAD_SHA: ${{ github.sha }}
+      JMH_INCLUDE: '.*'
+      RESULT_DIR: ${{ github.workspace }}/jmh-results/${{ matrix.shard }}
+      REPORT_DIR: ${{ github.workspace }}/jmh-reports/${{ matrix.shard }}
+      SHARD: ${{ matrix.shard }}
+    steps:
+      - name: "๐Ÿ“ฅ Checkout repository"
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 
v6.0.2
+        with:
+          fetch-depth: 0
+      - name: "โ˜•๏ธ Setup JDK"
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
+        with:
+          distribution: liberica
+          java-version: 21
+      - name: "๐Ÿ˜ Setup Gradle"
+        uses: 
gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+        with:
+          cache-provider: basic # 'basic' uses the MIT-licensed, open-source 
cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle 
commercial Terms of Use)
+          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
+      # Deliberately allowed to fail the job, and placed before the expensive 
work: a broken
+      # comparison tool makes every number produced here untrustworthy. That 
is a tooling
+      # failure rather than a performance finding, so regressions themselves 
stay advisory.
+      - name: "๐Ÿงช Verify JMH comparison tool"
+        run: ./gradlew :grails-benchmarks:test --max-workers=4

Review Comment:
   This gate is placed first, and the comment above it explains why: a broken 
comparison tool should fail in seconds rather than after an hour of building 
and benchmarking two revisions. But `:grails-benchmarks:test` isn't seconds โ€” 
it builds 33 framework projects before it can run a single spec.
   
   The `test` source set inherits `main`'s output, and `main` is where the 
Groovy fixtures live, so the six `implementation project(...)` dependencies 
come along. Measured on this branch:
   
   | gate | tasks | projects |
   | --- | ---: | ---: |
   | `:grails-benchmarks:test` (current) | 222 | 33 |
   | isolated `reportTest` source set | 9 | 1 |
   
   I probed the isolated version โ€” a `reportTest` source set whose classpath is 
`sourceSets.report.output` plus Spock and `groovy-json`, with its own `Test` 
task โ€” and every spec passes unchanged in 19s including compilation. No spec 
touches the fixtures, so nothing is lost by cutting `main` out of the classpath.
   
   `README.adoc` already states this as the design intent: "The `report` source 
set depends on Groovy alone and on no Grails project." The *test* source set is 
what breaks that isolation.
   
   One thing to watch if you split it: `grails-test-report` collects test tasks 
with `tasks.withType(Test).matching { it.name == phase }`, so a task named 
anything other than `test` drops out of the aggregate report and would need 
adding there.



##########
grails-benchmarks/src/jmh/java/org/apache/grails/benchmarks/gsp/GroovyPageParserBenchmark.java:
##########
@@ -0,0 +1,83 @@
+/*
+ *  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
+ *
+ *    https://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.
+ */
+package org.apache.grails.benchmarks.gsp;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+import org.grails.gsp.compiler.GroovyPageParser;
+
+/**
+ * Measures GSP source parsing into generated Groovy source, the work 
performed when GSP templates
+ * are prepared. Parser regressions slow application startup and template 
reloads.
+ */
+@State(Scope.Benchmark)
+@Threads(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 2, jvmArgsAppend = {"-Xms1g", "-Xmx1g", "-XX:+UseG1GC"})
+public class GroovyPageParserBenchmark {

Review Comment:
   This is the one benchmark left without a fixture guard โ€” it has no `@Setup` 
at all. `UrlMappingsBenchmark`, `UrlMappingMatcherBenchmark`, 
`SimpleDataBinderBenchmark` and `ViewTemplateRenderingBenchmark` all now fail 
the run rather than publish a silent no-op.
   
   The same failure mode applies here, and arguably more directly than anywhere 
else: the measured work *is* the volume of generated source. If `parse()` ever 
returned an empty or near-empty stream, both methods would keep producing 
plausible, self-consistent, meaningless numbers with no error anywhere.
   
   A `@Setup` that parses both templates once and asserts the generated source 
is non-trivial (and, for `TAGGED_TEMPLATE`, that it actually contains the tag 
output) would close the last gap in the fail-loud story.



##########
.github/workflows/benchmark.yml:
##########
@@ -0,0 +1,396 @@
+# 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
+#
+#     https://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.
+
+name: "JMH Benchmark Comparison"
+
+# SECURITY: This workflow deliberately uses pull_request, never 
pull_request_target.
+# Pull requests run untrusted code, and Apache Infra policy forbids exposing 
tokens
+# to that code through a privileged pull_request_target workflow.
+on:
+  pull_request:
+    types: [opened, synchronize, reopened, labeled]
+    paths-ignore:
+      - '**/*.md'
+      - '**/*.adoc'
+      - 'grails-doc/**'
+  workflow_dispatch:
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || 
github.run_id }}
+  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+  contents: read
+
+jobs:
+  # Each shard builds both revisions before measuring either one on the same 
runner.
+  # Building first avoids CPU, IO, thermal, cache, and frequency state biasing 
a measurement.
+  # Shard a measures BASE then HEAD, while shard b measures HEAD then BASE.
+  # Alternating the order cancels first-versus-second ordering bias without 
doubling runtime.
+  # Both shards use the PR merge commit as HEAD, so they measure what would 
actually land.
+  # Results are advisory: a detected regression never fails this workflow.
+  benchmark:
+    name: "Paired JMH benchmarks (${{ matrix.shard }})"
+    if: >-
+      github.event_name == 'workflow_dispatch' ||
+      (contains(github.event.pull_request.labels.*.name, 'performance') &&
+       (github.event.action != 'labeled' || github.event.label.name == 
'performance'))
+    runs-on: ubuntu-24.04
+    strategy:
+      fail-fast: false
+      matrix:
+        shard: [a, b]
+    env:
+      BASE_SHA: ${{ github.event.pull_request.base.sha }}
+      HEAD_SHA: ${{ github.sha }}
+      JMH_INCLUDE: '.*'
+      RESULT_DIR: ${{ github.workspace }}/jmh-results/${{ matrix.shard }}
+      REPORT_DIR: ${{ github.workspace }}/jmh-reports/${{ matrix.shard }}
+      SHARD: ${{ matrix.shard }}
+    steps:
+      - name: "๐Ÿ“ฅ Checkout repository"
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 
v6.0.2
+        with:
+          fetch-depth: 0
+      - name: "โ˜•๏ธ Setup JDK"
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
+        with:
+          distribution: liberica
+          java-version: 21
+      - name: "๐Ÿ˜ Setup Gradle"
+        uses: 
gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+        with:
+          cache-provider: basic # 'basic' uses the MIT-licensed, open-source 
cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle 
commercial Terms of Use)
+          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
+      # Deliberately allowed to fail the job, and placed before the expensive 
work: a broken
+      # comparison tool makes every number produced here untrustworthy. That 
is a tooling
+      # failure rather than a performance finding, so regressions themselves 
stay advisory.
+      - name: "๐Ÿงช Verify JMH comparison tool"
+        run: ./gradlew :grails-benchmarks:test --max-workers=4
+      - name: "๐ŸŒณ Prepare paired worktrees"
+        run: |
+          WORKTREE_ROOT="$RUNNER_TEMP/jmh-worktrees/$SHARD"
+          mkdir -p "$WORKTREE_ROOT" "$RESULT_DIR" "$REPORT_DIR"
+          if [ -n "$BASE_SHA" ] && git cat-file -e "$BASE_SHA^{commit}" 
2>/dev/null; then
+            RESOLVED_BASE_SHA="$BASE_SHA"
+          elif RESOLVED_BASE_SHA="$(git merge-base "$HEAD_SHA^1" "$HEAD_SHA^2" 
2>/dev/null)"; then
+            echo "Configured base commit is unreachable; using merge-base 
$RESOLVED_BASE_SHA."
+          elif RESOLVED_BASE_SHA="$(git rev-parse "$HEAD_SHA^" 2>/dev/null)"; 
then
+            echo "Configured base commit is unreachable; using HEAD parent 
$RESOLVED_BASE_SHA."
+          else
+            RESOLVED_BASE_SHA=""
+            echo "No base commit could be resolved; comparison will use 
HEAD-only mode."
+          fi
+          git worktree add --detach "$WORKTREE_ROOT/head" "$HEAD_SHA"
+          {
+            printf 'HEAD_DIR=%s\n' "$WORKTREE_ROOT/head"
+            printf 'WORKTREE_ROOT=%s\n' "$WORKTREE_ROOT"
+          } >> "$GITHUB_ENV"
+          if [ -n "$RESOLVED_BASE_SHA" ] && git worktree add --detach 
"$WORKTREE_ROOT/base" "$RESOLVED_BASE_SHA"; then
+            {
+              printf 'BASE_DIR=%s\n' "$WORKTREE_ROOT/base"
+              printf 'RESOLVED_BASE_SHA=%s\n' "$RESOLVED_BASE_SHA"
+            } >> "$GITHUB_ENV"
+            echo "Using base commit $RESOLVED_BASE_SHA."
+          else
+            echo "BASE_BENCHMARKS_AVAILABLE=false" >> "$GITHUB_ENV"
+          fi
+      # Build both JMH jars before measuring either revision. Builds are CPU- 
and IO-heavy,
+      # so building and measuring one revision at a time would bias results 
with different
+      # thermal, cache, and CPU-frequency state on the shared runner.
+      - name: "๐Ÿ”จ Build paired JMH jars"
+        timeout-minutes: 60
+        run: |
+          base_build_ok=false
+          head_build_ok=false
+          if [ "${BASE_BENCHMARKS_AVAILABLE:-true}" = "true" ] && [ -f 
"$BASE_DIR/grails-benchmarks/build.gradle" ]; then
+            if (
+              cd "$BASE_DIR"
+              ./gradlew :grails-benchmarks:jmhJar --max-workers=4
+            ); then
+              base_build_ok=true
+              echo "Built base benchmark at $RESOLVED_BASE_SHA."
+            else
+              echo "BASE benchmark JAR build failed."
+            fi
+          else
+            echo "BASE does not contain grails-benchmarks; comparison will use 
HEAD-only mode."
+          fi
+          if (
+            cd "$HEAD_DIR"
+            ./gradlew :grails-benchmarks:jmhJar --max-workers=4
+          ); then
+            head_build_ok=true
+            echo "Built HEAD benchmark at $HEAD_SHA."
+          else
+            echo "HEAD benchmark JAR build failed."
+          fi
+          {
+            printf 'BASE_BUILD_OK=%s\n' "$base_build_ok"
+            printf 'HEAD_BUILD_OK=%s\n' "$head_build_ok"
+          } >> "$GITHUB_ENV"
+      - name: "๐Ÿ›‘ Stop Gradle daemons before measurement"
+        if: always()
+        run: |
+          ./gradlew --stop || true
+          if [ -n "${BASE_DIR:-}" ] && [ -d "$BASE_DIR" ]; then (cd 
"$BASE_DIR" && ./gradlew --stop) || true; fi
+          if [ -n "${HEAD_DIR:-}" ] && [ -d "$HEAD_DIR" ]; then (cd 
"$HEAD_DIR" && ./gradlew --stop) || true; fi
+      # Two forks, three warmup iterations, and five measurement iterations 
balance PR latency
+      # against confidence. Reversing shard order cancels first-versus-second 
runner-state bias.
+      - name: "๐ŸŒก๏ธ Run paired JMH benchmarks"

Review Comment:
   Minor follow-on to the daemon fix: the `--stop` above clears the daemons 
left by the build phase, but not the one the *first* measurement starts. 
Whichever revision runs second in a shard is measured on a daemon already 
warmed and loaded by the first (Gradle reuses a compatible idle daemon across 
project directories, so it's the same JVM rather than a second one).
   
   The alternating shard order cancels this on average, which is why it's a nit 
rather than a finding. But `--no-daemon` on the two `run_benchmark` invocations 
would make both halves start from identical daemon state and remove a source of 
noise the rulers would otherwise have to absorb.



##########
grails-benchmarks/src/report/groovy/org/apache/grails/benchmarks/report/CommentPoster.groovy:
##########
@@ -0,0 +1,111 @@
+/*
+ * 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
+ *
+ *     https://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.
+ */
+package org.apache.grails.benchmarks.report
+
+import groovy.json.JsonOutput
+import groovy.json.JsonSlurper
+import groovy.transform.CompileStatic
+
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.time.Duration
+
+@CompileStatic
+interface CommentPoster {
+
+    void post(String report, String repo, String prNumber, String token)
+}
+
+@CompileStatic
+class GitHubComments implements CommentPoster {
+
+    static final int MAX_BODY = 65000
+    static final String TRUNCATION = '\n\n_Report truncated. The full report 
is available in the workflow artifacts._\n\n<!-- grails-jmh-benchmark -->'
+    private final HttpClient client = 
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build()
+
+    @Override
+    void post(String report, String repo, String prNumber, String token) {

Review Comment:
   `post` itself is untested. The specs cover `commentBody` and 
`latestMatchingCommentId`, and everything through `JmhCompare` uses 
`Mock(CommentPoster)`, so the paginated GET loop and the create-versus-update 
decision on line 63 never execute in a test.
   
   That's the sticky-comment mechanism โ€” the thing whose failure mode is a 
duplicate report on every push rather than an updated one, which is the 
specific behaviour the marker exists to prevent. A 
`com.sun.net.httpserver.HttpServer` stub would cover the page-boundary walk 
(the `comments.size() < 100` break) and assert PATCH-versus-POST against a 
canned comment list.
   
   Worth flagging that coverage tooling won't catch this for you: the module 
deliberately omits `grails-jacoco`, so the "all modified and coverable lines 
are covered by tests" report on this PR says nothing about `grails-benchmarks`.
   
   Unrelated, while you're in the specs: `JmhCompareSpec`'s first two features 
("speedup respects throughput and latency direction", "verdict requires 
threshold and disjoint intervals") duplicate five features in 
`BenchmarkComparatorSpec` exactly.



##########
grails-benchmarks/build.gradle:
##########
@@ -0,0 +1,270 @@
+/*
+ *  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
+ *
+ *    https://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.
+ */
+
+import org.gradle.api.file.DuplicatesStrategy
+import org.gradle.api.tasks.PathSensitivity
+
+import java.util.Properties
+import java.util.zip.ZipFile
+
+plugins {
+    id 'groovy'
+    id 'java-library'
+    id 'me.champeau.jmh' version '0.7.3'
+    id 'org.apache.grails.buildsrc.properties'
+    id 'org.apache.grails.buildsrc.compile'
+    id 'org.apache.grails.buildsrc.vulnerability-scan'
+    id 'org.apache.grails.gradle.grails-code-style'
+}
+
+version = projectVersion
+group = 'org.apache.grails'
+
+// This build-time-only benchmark harness is never published, so it 
deliberately omits
+// org.apache.grails.buildsrc.publish, org.apache.grails.buildsrc.sbom,
+// org.apache.grails.gradle.grails-jacoco, and 
org.apache.grails.buildsrc.dependency-validator.
+// Its JMH dependency is GPLv2 with the Classpath Exception (Category X) and 
must not be published.
+
+sourceSets {
+    report {
+        groovy.srcDirs = ['src/report/groovy']
+    }
+}
+
+// The Groovy fixtures under src/main/groovy build the framework objects whose 
APIs are
+// closure-based (the URL mappings DSL, the Validateable trait, view 
templates). The JMH

Review Comment:
   `the Validateable trait` is stale โ€” there is no Validateable fixture in the 
module. `README.adoc` line 93 carries the same claim in its Layout section, and 
is then contradicted by its own "Known gap: validation" section 28 lines later, 
which explains that the validation benchmark was written and withdrawn.
   
   Same category as the Python leftovers from the last round: a reader looking 
for the Groovy fixture that needs the trait won't find one. The remaining two 
examples (the URL mappings DSL and view templates) carry the argument on their 
own.



##########
grails-benchmarks/src/report/groovy/org/apache/grails/benchmarks/report/JmhResults.groovy:
##########
@@ -0,0 +1,139 @@
+/*
+ * 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
+ *
+ *     https://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.
+ */
+package org.apache.grails.benchmarks.report
+
+import groovy.json.JsonSlurper
+import groovy.transform.CompileStatic
+
+import java.nio.file.Files
+import java.nio.file.Path
+
+@CompileStatic
+class JmhResults {
+
+    static final String ALLOCATION_METRIC = 'gc.alloc.rate.norm'
+
+    static Double finiteNumber(Object value) {
+        if (value == null) {
+            return null
+        }
+        try {
+            Double number = Double.valueOf(value.toString())
+            return Double.isFinite(number) ? number : null
+        } catch (NumberFormatException ignored) {
+            return null
+        }
+    }
+
+    static List<Double> confidenceInterval(Map<String, Object> metric) {
+        Object value = metric.get('scoreConfidence')
+        if (value instanceof List && ((List<?>) value).size() == 2) {
+            Double lower = finiteNumber(((List<?>) value).get(0))
+            Double upper = finiteNumber(((List<?>) value).get(1))
+            if (lower != null && upper != null) {
+                return [Math.min(lower, upper), Math.max(lower, upper)]
+            }
+        }
+        Double score = finiteNumber(metric.get('score'))
+        Double error = finiteNumber(metric.get('scoreError'))
+        return score != null && error != null && error >= 0D ? [score - error, 
score + error] : null
+    }
+
+    static String identity(String name, Map<String, Object> params) {

Review Comment:
   `identity` keys on benchmark name plus params but not on `mode`, and 
`parseEntries` does a plain `benchmarks.put(benchmarkId, ...)`. A benchmark 
declaring more than one mode โ€” `@BenchmarkMode({Mode.AverageTime, 
Mode.Throughput})` โ€” emits one JSON entry per mode under the same name, so all 
but the last are silently dropped.
   
   Nothing in the suite does this today, and `comparable()` plus 
`poolBenchmarks` correctly refuse to compare across modes, so it can't produce 
a *wrong* verdict. But it can silently shrink the comparison set, surfacing 
only as an anonymous "Malformed comparisons skipped: N" count โ€” the same 
silent-shrink failure that `jmh.failOnError = true` was set to prevent on the 
measurement side.
   
   Adding mode to the identity key would make it a visible `Only in head` / 
`Only in base` entry instead.



##########
.github/workflows/benchmark.yml:
##########
@@ -0,0 +1,396 @@
+# 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
+#
+#     https://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.
+
+name: "JMH Benchmark Comparison"
+
+# SECURITY: This workflow deliberately uses pull_request, never 
pull_request_target.
+# Pull requests run untrusted code, and Apache Infra policy forbids exposing 
tokens
+# to that code through a privileged pull_request_target workflow.
+on:
+  pull_request:
+    types: [opened, synchronize, reopened, labeled]
+    paths-ignore:
+      - '**/*.md'
+      - '**/*.adoc'
+      - 'grails-doc/**'
+  workflow_dispatch:
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || 
github.run_id }}
+  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+  contents: read
+
+jobs:
+  # Each shard builds both revisions before measuring either one on the same 
runner.
+  # Building first avoids CPU, IO, thermal, cache, and frequency state biasing 
a measurement.
+  # Shard a measures BASE then HEAD, while shard b measures HEAD then BASE.
+  # Alternating the order cancels first-versus-second ordering bias without 
doubling runtime.
+  # Both shards use the PR merge commit as HEAD, so they measure what would 
actually land.
+  # Results are advisory: a detected regression never fails this workflow.
+  benchmark:
+    name: "Paired JMH benchmarks (${{ matrix.shard }})"
+    if: >-
+      github.event_name == 'workflow_dispatch' ||
+      (contains(github.event.pull_request.labels.*.name, 'performance') &&
+       (github.event.action != 'labeled' || github.event.label.name == 
'performance'))
+    runs-on: ubuntu-24.04
+    strategy:
+      fail-fast: false
+      matrix:
+        shard: [a, b]
+    env:
+      BASE_SHA: ${{ github.event.pull_request.base.sha }}
+      HEAD_SHA: ${{ github.sha }}
+      JMH_INCLUDE: '.*'
+      RESULT_DIR: ${{ github.workspace }}/jmh-results/${{ matrix.shard }}
+      REPORT_DIR: ${{ github.workspace }}/jmh-reports/${{ matrix.shard }}
+      SHARD: ${{ matrix.shard }}
+    steps:
+      - name: "๐Ÿ“ฅ Checkout repository"
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 
v6.0.2
+        with:
+          fetch-depth: 0
+      - name: "โ˜•๏ธ Setup JDK"
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
+        with:
+          distribution: liberica
+          java-version: 21
+      - name: "๐Ÿ˜ Setup Gradle"
+        uses: 
gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+        with:
+          cache-provider: basic # 'basic' uses the MIT-licensed, open-source 
cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle 
commercial Terms of Use)
+          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
+      # Deliberately allowed to fail the job, and placed before the expensive 
work: a broken
+      # comparison tool makes every number produced here untrustworthy. That 
is a tooling
+      # failure rather than a performance finding, so regressions themselves 
stay advisory.
+      - name: "๐Ÿงช Verify JMH comparison tool"
+        run: ./gradlew :grails-benchmarks:test --max-workers=4
+      - name: "๐ŸŒณ Prepare paired worktrees"
+        run: |
+          WORKTREE_ROOT="$RUNNER_TEMP/jmh-worktrees/$SHARD"
+          mkdir -p "$WORKTREE_ROOT" "$RESULT_DIR" "$REPORT_DIR"
+          if [ -n "$BASE_SHA" ] && git cat-file -e "$BASE_SHA^{commit}" 
2>/dev/null; then
+            RESOLVED_BASE_SHA="$BASE_SHA"
+          elif RESOLVED_BASE_SHA="$(git merge-base "$HEAD_SHA^1" "$HEAD_SHA^2" 
2>/dev/null)"; then
+            echo "Configured base commit is unreachable; using merge-base 
$RESOLVED_BASE_SHA."
+          elif RESOLVED_BASE_SHA="$(git rev-parse "$HEAD_SHA^" 2>/dev/null)"; 
then
+            echo "Configured base commit is unreachable; using HEAD parent 
$RESOLVED_BASE_SHA."
+          else
+            RESOLVED_BASE_SHA=""
+            echo "No base commit could be resolved; comparison will use 
HEAD-only mode."
+          fi
+          git worktree add --detach "$WORKTREE_ROOT/head" "$HEAD_SHA"
+          {
+            printf 'HEAD_DIR=%s\n' "$WORKTREE_ROOT/head"
+            printf 'WORKTREE_ROOT=%s\n' "$WORKTREE_ROOT"
+          } >> "$GITHUB_ENV"
+          if [ -n "$RESOLVED_BASE_SHA" ] && git worktree add --detach 
"$WORKTREE_ROOT/base" "$RESOLVED_BASE_SHA"; then
+            {
+              printf 'BASE_DIR=%s\n' "$WORKTREE_ROOT/base"
+              printf 'RESOLVED_BASE_SHA=%s\n' "$RESOLVED_BASE_SHA"
+            } >> "$GITHUB_ENV"
+            echo "Using base commit $RESOLVED_BASE_SHA."
+          else
+            echo "BASE_BENCHMARKS_AVAILABLE=false" >> "$GITHUB_ENV"
+          fi
+      # Build both JMH jars before measuring either revision. Builds are CPU- 
and IO-heavy,
+      # so building and measuring one revision at a time would bias results 
with different
+      # thermal, cache, and CPU-frequency state on the shared runner.
+      - name: "๐Ÿ”จ Build paired JMH jars"
+        timeout-minutes: 60
+        run: |
+          base_build_ok=false
+          head_build_ok=false
+          if [ "${BASE_BENCHMARKS_AVAILABLE:-true}" = "true" ] && [ -f 
"$BASE_DIR/grails-benchmarks/build.gradle" ]; then
+            if (
+              cd "$BASE_DIR"
+              ./gradlew :grails-benchmarks:jmhJar --max-workers=4
+            ); then
+              base_build_ok=true
+              echo "Built base benchmark at $RESOLVED_BASE_SHA."
+            else
+              echo "BASE benchmark JAR build failed."
+            fi
+          else
+            echo "BASE does not contain grails-benchmarks; comparison will use 
HEAD-only mode."
+          fi
+          if (
+            cd "$HEAD_DIR"
+            ./gradlew :grails-benchmarks:jmhJar --max-workers=4
+          ); then
+            head_build_ok=true
+            echo "Built HEAD benchmark at $HEAD_SHA."
+          else
+            echo "HEAD benchmark JAR build failed."
+          fi
+          {
+            printf 'BASE_BUILD_OK=%s\n' "$base_build_ok"
+            printf 'HEAD_BUILD_OK=%s\n' "$head_build_ok"
+          } >> "$GITHUB_ENV"
+      - name: "๐Ÿ›‘ Stop Gradle daemons before measurement"
+        if: always()
+        run: |
+          ./gradlew --stop || true
+          if [ -n "${BASE_DIR:-}" ] && [ -d "$BASE_DIR" ]; then (cd 
"$BASE_DIR" && ./gradlew --stop) || true; fi
+          if [ -n "${HEAD_DIR:-}" ] && [ -d "$HEAD_DIR" ]; then (cd 
"$HEAD_DIR" && ./gradlew --stop) || true; fi
+      # Two forks, three warmup iterations, and five measurement iterations 
balance PR latency
+      # against confidence. Reversing shard order cancels first-versus-second 
runner-state bias.
+      - name: "๐ŸŒก๏ธ Run paired JMH benchmarks"
+        timeout-minutes: 60
+        run: |
+          # JMH writes results incrementally, so a run that dies partway can 
leave a file that
+          # parses perfectly while describing only some of the benchmarks. The 
report job decides
+          # completeness from which files exist, so a partial file would be 
indistinguishable from
+          # a good one. Write to a staging path and publish it only on 
success, so a failed run
+          # leaves NO file rather than a plausible one.
+          run_benchmark() {
+            local revision_dir="$1"
+            local result_file="$2"
+            local staging_file="$result_file.partial"
+            rm -f "$staging_file" "$result_file"
+            if (
+              cd "$revision_dir" && ./gradlew :grails-benchmarks:jmh \
+                -Pjmh.include="$JMH_INCLUDE" \
+                -Pjmh.forks=2 \
+                -Pjmh.warmupIterations=3 \
+                -Pjmh.iterations=5 \
+                -Pjmh.resultFile="$staging_file" \
+                -Pjmh.profilers=gc \
+                --max-workers=4
+            ) && [ -s "$staging_file" ] && mv "$staging_file" "$result_file"; 
then
+              return 0
+            fi
+            rm -f "$staging_file"
+            return 1
+          }
+
+          base_run_failed=false
+          head_run_failed=false
+          run_base_benchmark() {
+            if [ "${BASE_BUILD_OK:-false}" != "true" ]; then
+              echo "BASE benchmark execution skipped because its JAR was not 
built."
+            elif ! run_benchmark "$BASE_DIR" "$RESULT_DIR/base.json"; then
+              base_run_failed=true
+              echo "BASE benchmark execution failed."
+            fi
+          }
+          run_head_benchmark() {
+            if [ "${HEAD_BUILD_OK:-false}" != "true" ]; then
+              echo "HEAD benchmark execution skipped because its JAR was not 
built."
+            elif ! run_benchmark "$HEAD_DIR" "$RESULT_DIR/head.json"; then
+              head_run_failed=true
+              echo "HEAD benchmark execution failed."
+            fi
+          }
+
+          if [ "$SHARD" = "a" ]; then
+            run_base_benchmark
+            run_head_benchmark
+          else
+            run_head_benchmark
+            run_base_benchmark
+          fi
+          {
+            printf 'BASE_RUN_FAILED=%s\n' "$base_run_failed"
+            printf 'HEAD_RUN_FAILED=%s\n' "$head_run_failed"
+          } >> "$GITHUB_ENV"
+      # The comparison is advisory. The comparison tool exits successfully for 
regressions, and this
+      # step is non-blocking even if results are incomplete because a 
benchmark execution failed.
+      # No --pr-number is passed here on purpose: this job renders the report 
only. The separate
+      # report job owns comment posting, so a two-shard matrix cannot produce 
duplicate comments.
+      - name: "๐Ÿ“Š Compare JMH results"
+        if: always()
+        continue-on-error: true
+        run: |
+          report_file="$REPORT_DIR/comparison.md"
+          {
+            printf '### JMH shard `%s`\n\n' "$SHARD"
+            if [ "${BASE_RUN_FAILED:-false}" = "true" ]; then
+              printf 'BASE benchmark execution failed.\n\n'
+            fi
+            if [ "${BASE_BUILD_OK:-false}" != "true" ]; then
+              printf 'BASE benchmark JAR was not built.\n\n'
+            fi
+            if [ "${HEAD_RUN_FAILED:-false}" = "true" ]; then
+              printf 'HEAD benchmark execution failed.\n\n'
+            fi
+            if [ "${HEAD_BUILD_OK:-false}" != "true" ]; then
+              printf 'HEAD benchmark JAR was not built.\n\n'
+            fi
+          } > "$report_file"
+          comparison_file="$RUNNER_TEMP/jmh-comparison-$SHARD.md"
+          if [ ! -f "$RESULT_DIR/head.json" ]; then
+            printf 'HEAD benchmark result was not produced.\n' >> 
"$report_file"
+          elif [ "${BASE_BUILD_OK:-false}" = "true" ] && [ -f 
"$RESULT_DIR/base.json" ]; then
+            ./gradlew -q --console=plain :grails-benchmarks:jmhCompare 
--args="--head $RESULT_DIR/head.json --base $RESULT_DIR/base.json --output 
$comparison_file"
+            cat "$comparison_file" >> "$report_file"
+          else
+            ./gradlew -q --console=plain :grails-benchmarks:jmhCompare 
--args="--head $RESULT_DIR/head.json --output $comparison_file"
+            cat "$comparison_file" >> "$report_file"
+          fi
+      - name: "๐Ÿ“‹ Publish JMH report in job summary"
+        if: always()
+        run: |
+          if [ -f "$REPORT_DIR/comparison.md" ]; then
+            cat "$REPORT_DIR/comparison.md" >> "$GITHUB_STEP_SUMMARY"
+          else
+            printf '## JMH benchmark comparison\n\nNo comparison report was 
produced.\n' >> "$GITHUB_STEP_SUMMARY"
+          fi
+      - name: "๐Ÿ“ค Upload JMH artifacts"
+        if: always()
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a 
# v7.0.1
+        with:
+          name: jmh-results-${{ matrix.shard }}
+          path: |
+            jmh-results/${{ matrix.shard }}/
+            jmh-reports/${{ matrix.shard }}/
+          if-no-files-found: warn
+      - name: "๐Ÿงน Remove paired worktrees"
+        if: always()
+        run: |
+          WORKTREE_ROOT="$RUNNER_TEMP/jmh-worktrees/$SHARD"
+          git worktree remove --force "$WORKTREE_ROOT/base" || true
+          git worktree remove --force "$WORKTREE_ROOT/head" || true
+
+  report:
+    name: "Publish JMH benchmark comparison"
+    needs: benchmark
+    if: >-
+      always() && github.event_name == 'pull_request' &&

Review Comment:
   `github.event_name == 'pull_request'` here means a `workflow_dispatch` run 
can never reach the reporting job, so it produces artifacts and per-shard job 
summaries but no sticky comment. The PR description says a comment appears for 
`workflow_dispatch` too.
   
   There's a second consequence: on dispatch there's no `pull_request` context, 
so `BASE_SHA` is empty and base resolution in "Prepare paired worktrees" falls 
through the merge-base branch to `git rev-parse "$HEAD_SHA^"`. A manual run 
therefore benchmarks HEAD against its own parent commit, not against a release 
branch.
   
   Both behaviours are defensible, but neither is what the description 
promises. Either document what dispatch actually does, or drop the trigger.



##########
grails-benchmarks/src/report/groovy/org/apache/grails/benchmarks/report/CommentPoster.groovy:
##########
@@ -0,0 +1,111 @@
+/*
+ * 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
+ *
+ *     https://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.
+ */
+package org.apache.grails.benchmarks.report
+
+import groovy.json.JsonOutput
+import groovy.json.JsonSlurper
+import groovy.transform.CompileStatic
+
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.time.Duration
+
+@CompileStatic
+interface CommentPoster {
+
+    void post(String report, String repo, String prNumber, String token)
+}
+
+@CompileStatic
+class GitHubComments implements CommentPoster {
+
+    static final int MAX_BODY = 65000
+    static final String TRUNCATION = '\n\n_Report truncated. The full report 
is available in the workflow artifacts._\n\n<!-- grails-jmh-benchmark -->'
+    private final HttpClient client = 
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build()
+
+    @Override
+    void post(String report, String repo, String prNumber, String token) {
+        String body = commentBody(report)
+        Long commentId = null
+        for (int page = 1; page <= 100; page++) {
+            Object response = request(
+                    
"https://api.github.com/repos/${repo}/issues/${prNumber}/comments?per_page=100&page=${page}";,
+                    token,
+                    'GET',
+                    null)
+            if (!(response instanceof List)) {
+                break
+            }
+            List<?> comments = (List<?>) response
+            Long pageMatch = latestMatchingCommentId(comments)
+            if (pageMatch != null) {
+                commentId = pageMatch
+            }
+            if (comments.size() < 100) {
+                break
+            }
+        }
+        String endpoint = commentId == null
+                ? 
"https://api.github.com/repos/${repo}/issues/${prNumber}/comments";
+                : 
"https://api.github.com/repos/${repo}/issues/comments/${commentId}";
+        request(endpoint, token, commentId == null ? 'POST' : 'PATCH', 
JsonOutput.toJson([body: body]))
+    }
+
+    /**
+     * Keep the last matching comment on a page so duplicate marker comments 
update the
+     * newest bot report (GitHub returns comments oldest-first).
+     */
+    static Long latestMatchingCommentId(List<?> comments) {
+        Long commentId = null
+        for (Object comment : comments) {
+            if (!(comment instanceof Map) || !markerComment((Map<String, 
Object>) comment)) {
+                continue
+            }
+            Object id = ((Map<String, Object>) comment).get('id')
+            if (id instanceof Number) {
+                commentId = ((Number) id).longValue()
+            }
+        }
+        return commentId
+    }
+
+    static String commentBody(String report) {
+        String body = report.contains(ReportRenderer.MARKER)
+                ? report
+                : report + '\n\n' + ReportRenderer.MARKER
+        if (body.length() > MAX_BODY) {
+            body = body.substring(0, MAX_BODY - TRUNCATION.length()) + 
TRUNCATION
+        }
+        return body
+    }
+
+    private static boolean markerComment(Map<String, Object> comment) {
+        Object body = comment.get('body')
+        Object user = comment.get('user')
+        Object login = user instanceof Map ? ((Map<String, Object>) 
user).get('login') : null
+        return body instanceof String && ((String) 
body).contains(ReportRenderer.MARKER) && (!(login instanceof String) || login 
== 'github-actions[bot]')

Review Comment:
   The author check defaults the wrong way: `!(login instanceof String) || 
login == 'github-actions[bot]'` treats a comment whose `user` is absent or 
non-object as ours. If GitHub returns a comment without a usable `user` 
(deleted accounts are the usual way this happens) and that body contains the 
marker, it becomes a PATCH target.
   
   The reporting job holds `pull-requests: write`, which is enough to edit any 
comment in the repository, so the failure mode is overwriting someone else's 
comment rather than a harmless 403.
   
   Requiring the bot instead of assuming it โ€” `login == 'github-actions[bot]'` 
โ€” keeps the intended behaviour and makes the unknown-author case fall through 
to posting a fresh comment, which is the safe direction.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to