We welcome all contributors. This file covers code contributions to the Groovy repository. For ways to contribute that don't involve changing the code — helping on the mailing lists, reporting issues, writing blog posts, or contributing to the reference documentation on the website — see the project contribute page.
JDK 17 or later is required. The canonical build instructions live in
README.adoc. The short form:
./gradlew clean dist # full build
./gradlew test # run tests
./gradlew :<module>:test --tests <TestClassName> # run a single test
Use the Gradle wrapper (./gradlew / gradlew.bat) rather than a
system gradle — the wrapper pins the version the build expects.
Most modern IDEs open the Gradle project directly.
Run ./gradlew test locally before sending a pull request. All tests
should be green.
To exercise a build with your changes applied — running scripts, trying the REPL, or smoke-testing behaviour the test suite doesn't cover — produce a local installation:
./gradlew :groovy-binary:installGroovy
The installation lands under
subprojects/groovy-binary/build/install/. Its bin/ directory
contains the groovy, groovysh, groovyc, and groovyConsole
launchers, so you can invoke
subprojects/groovy-binary/build/install/bin/groovy <script>
to run a script against the build you just produced.
On Unix-like systems, if you have SDKMAN (or any other tool that
sets GROOVY_HOME to a fixed installation), unset GROOVY_HOME
before running the launchers — otherwise they pick up that
environment variable instead of using the local build. Depending
on what GROOVY_HOME points at, the symptom is either that your
changes appear not to take effect (it ran a different Groovy), or
some launchers fail outright with:
Error: Could not find or load main class org.codehaus.groovy.tools.GroovyStarter
Caused by: java.lang.ClassNotFoundException: org.codehaus.groovy.tools.GroovyStarter
This is not a broken build and is not worth a JIRA issue — it is
a stale or mismatched GROOVY_HOME. unset GROOVY_HOME (or point
it at the local install) and re-run.
Three things determine "which Java" is involved, and they are independent:
-
The JDK that builds the code — whatever JVM launched
./gradlew(subject to the minimum stated under Building and testing above). Use an environment manager (SDKMAN,jenv, Mise, asdf) orJAVA_HOMEto pick it; this repository deliberately ships no.sdkmanrc, so the build does not pin a JDK for you. -
The bytecode the artifacts target —
targetJavaVersion(Java sources) andgroovyTargetBytecodeVersion(Groovy sources) ingradle.properties. These are the single source of truth and move with the Groovy version; change them there, never inline. Changing them changes what the produced jars target, not the JDK you build with. SeeCOMPATIBILITY.mdfor the supported range. -
The JDK the tests run on — the build JDK by default. To run tests on a different JDK without changing the build JDK, pass its home directory:
./gradlew :test --tests <FQN> -Ptarget.java.home=/path/to/jdkThe build prints
Using <java> to run testsonce per JVM so you can confirm the override took effect, and it selects a matching Gradle toolchain automatically. Set the property once in~/.gradle/gradle.propertiesif you always test on the same alternate JDK.
The build runs Gradle dependency
verification:
every resolved artifact is checked against
gradle/verification-metadata.xml (PGP signatures where available,
SHA-512 checksums for unsigned artifacts), and the trusted public
keys live in gradle/verification-keyring.keys.
Key-server lookups are disabled (<key-servers enabled="false"/>
in the metadata), so that keyring is the only source of trusted
keys — the build never contacts a key server. This keeps resolution
fast, reproducible, and offline; the trade-off is that a genuinely
new signing key has to be added deliberately (see Adding a new
signing key below).
Adding, bumping, or removing a dependency — including a transitive one — changes the resolved graph, so verification fails until you update the metadata. What you do depends on what actually changed.
This covers a new unsigned artifact, or a signed artifact whose
signer is already trusted. There are two ways to regenerate, and they
end at the same place: a small, reviewed diff to
verification-metadata.xml. Prefer the dry-run approach — it
never edits the real file, so the change stays intentional.
./gradlew --write-verification-metadata pgp,sha512 --dry-run <tasks>
<tasks> must be tasks that actually resolve the dependency you
changed (e.g. build, or a specific subproject task) — not
help, which resolves nothing and would record no entries. This
writes a throwaway gradle/verification-metadata.dryrun.xml instead
of touching the real file. Compare it against
verification-metadata.xml, review the additions, and merge only
the missing entries for your dependency into the real file.
To regenerate in place instead, run the same command with the same
<tasks> but without --dry-run. It rewrites
verification-metadata.xml directly, so inspect the diff carefully
and remove any unrelated or stale entries before committing amd keep.
any comments and other information we already have in that file.
Either way, inspect the diff before committing and keep it
minimal. Changes to verification-metadata.xml should be small and
deliberate — if verification fails, never disable it; follow the
troubleshooting
guide
instead.
A signed dependency only needs its signing key in the keyring, and a trusted key covers all versions — so bumping a signed dependency usually needs no change at all. You only touch the keyring when a dependency introduces a new signer.
Because key-server lookups are disabled, Gradle won't fetch the new key on its own. The standard way to add one is to let Gradle fetch it with key servers turned on just for the regeneration:
-
In
gradle/verification-metadata.xml, temporarily set<key-servers enabled="false"/>toenabled="true"(or comment the element out). -
Run an export against tasks that resolve the new dependency:
./gradlew --write-verification-metadata pgp,sha512 --export-keys <tasks>Gradle fetches the key, verifies the signature, and re-exports the whole keyring. The export merges, so nothing already present is lost, and each key is written with a readable
pub/uidowner header. (helpis fine as<tasks>if you only need to refresh keys already declared, rather than add a new dependency's entries.) -
Set
enabledback tofalse. -
Review the keyring diff — it should be just the new key.
Don't hand-edit the keyring with gpg. Gradle exports minimized keys (user-ID packets stripped), which modern gpg refuses to import ("no valid user IDs") — so
gpg --exportcan't round-trip the file, andgpg --export --armor > …keyringwould clobber the Gradle-managed keyring with whatever is in your personal gpg store. If you must pull a single key out-of-band, append it rather than overwrite, then let the next--export-keysrun re-normalise the format:gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys <KEY_ID> gpg --export --armor <KEY_ID> >> gradle/verification-keyring.keys
For an overall map of the test layout, see
ARCHITECTURE.md. This section covers the bits a
contributor most often needs to know.
Reach for the narrowest run that reproduces what you're working on before falling back to the full suite:
./gradlew :test --tests <FullyQualifiedClassName> # one class, core
./gradlew :test --tests <FQN>.<methodName> # one method
./gradlew :<subproject>:test --tests <FQN> # one class, subproject
./gradlew :<subproject>:test # one whole module
./gradlew --rerun-tasks :test --tests <FQN> # bypass the up-to-date cache
The full ./gradlew test is appropriate as a final check before a PR
but is the wrong feedback loop for development.
New tests use JUnit 5: org.junit.jupiter.api.Test with
org.junit.jupiter.api.Assertions.*. Older tests in the tree use a
mix of JUnit 3 (extends GroovyTestCase) and JUnit 4 — match the
surrounding file when adding a method to an existing test class, but
write new test classes in JUnit 5. Spock is bundled and available,
but the core repo's own tests are not generally Spock-based; reach
for it only when you have a specific reason.
Test method names in new classes drop the test prefix that older
JUnit required — JUnit 5 picks up methods by the @Test annotation,
not by name. So void octalLiteral() is preferred over
void testOctalLiteral(). When adding a method to an existing test
class that uses the older prefixed style, matching the surrounding
file is the better fit.
Static helpers worth knowing:
groovy.test.GroovyAssert.shouldFail(...)— asserts a closure throws.gls.CompilableTestSupport— base class used by spec tests when a test needs to assert a snippet compiles.
Groovy has a first-class testing form that is easy to miss: a
<pre class="...groovyTestCase"> block inside a Javadoc/GroovyDoc
comment is executed as a real test. groovy.test.JavadocAssertionTestSuite
/ JavadocAssertionTestBuilder scan source comments, extract each such
block, and run its assert statements as JUnit tests
(src/test/groovy/MainJavadocAssertionTest.groovy covers src/main;
subprojects have their own *JavadocAssertionTest). This is the
standard way the GDK (DefaultGroovyMethods, ArrayGroovyMethods,
etc.) is tested — the worked examples in a method's Javadoc are its
test suite, doubling as documentation.
Consequences when adding or reviewing a GDK-style method: a change that
adds groovyTestCase blocks covering the new behaviour is adding
tests — there need not be a separate *Test.groovy. Conversely, an
assert in such a block that doesn't hold will fail the build, so keep
the examples runnable and correct.
Every bug fix that has a JIRA needs a test that fails on master
before the fix and passes after. There are two shapes:
Standalone class, when the bug doesn't fit naturally with existing tests:
- Class name:
Groovy<NNNN>for newer tests (e.g.src/test/groovy/bugs/Groovy11955.groovy). Older tests in the tree end inBugorTest; new tests use the unsuffixed form. - Follow-on tests on the same JIRA: append
pt2,pt3(e.g.Groovy10122pt2.groovy). - Location:
src/test/groovy/bugs/for general bugs;src/test/groovy/<package-mirror>/when the bug is scoped to a specific area (the existingorg/codehaus/groovy/tools/stubgenerator/directory shows the pattern). - Subproject bugs go under that subproject's
src/test/.
Method on an existing class, when the regression fits with similar tests already there:
- Add a
@Testmethod on the appropriate existing class. - Place a
// GROOVY-<NNNN>comment on the line immediately above the method so a search for the JIRA still finds it. - The surrounding file's naming style applies — if the existing class
uses
testFoostyle, follow that for the new method; otherwise use the unprefixed style.
In both cases, the test should fail on master before the fix is
applied — that's the proof it actually reproduces the bug.
To find precedent for a similar past fix:
git log --grep='GROOVY-12345' # commits referencing the JIRA
git log --grep='GROOVY-' -- src/test/groovy/bugs/ # all bug-fix commits in core regression tests
For a JIRA-tracked bug, the order of operations is test first, then fix:
- Write the failing regression test (per the shapes above).
- Confirm it fails on
master— run the targeted test and watch the failure. This is the proof the test exercises the bug. - Implement the smallest fix that makes the test pass. Trace
the cause up the call stack; a null-guard at the failure site is
often the symptom, not the cause. Don't add speculative
abstractions, configuration knobs, or "while I'm here" refactors.
The same discipline rules out drive-by reformatting, rewriting
files "for consistency" outside the task, inventing APIs, flags, or
methods (verify they exist), and committing scratch files
(
answers.*, patches, generated reports) — keep the diff to the regression test and the fix. - Run the targeted test again. Green.
- Run the surrounding module's test pass —
./gradlew :<subproject>:testor./gradlew :testwith a package filter — to catch nearby behaviour the fix regressed. - Diff the working tree. Anything outside the regression test and the production change needs a reason. Drive-by reformatting and stray imports should be reverted; they hide real changes in review.
- Commit with a JIRA reference.
GROOVY-NNNNN: <short subject>on the first line — see Submitting a pull request. If the fix touches security-adjacent code, the commit message and PR must not reference the security nature of the change — seeSECURITY.md"Disclosure hygiene for contributors".
A few specific traps:
- "Local IDE green" is not "build green". An IDE test runner
bypasses Gradle's
Testtask configuration, including thejunit.networkexclusion that gatesgroovy/grape/tests. The signal that counts is./gradlew :test --tests <FQN>, not the IDE play button. - Targeted green is necessary but not sufficient. The surrounding module's test pass catches the nearby regression the fix introduced. Skipping that step is the most common source of "my fix broke CI."
- Sometimes the report describes intended behaviour. If your would-be regression test confirms documented behaviour rather than contradicting it, the fix is a doc clarification or a recommendation to close as Not A Bug — not a code change.
- Cross-repo fixes need committer coordination. Some fixes
touch
groovy-website,groovy-eclipse, or another ASF repo. Those have their own conventions and reviewers; flag the cross-repo need rather than auto-cloning and patching.
Examples in the user-facing documentation under src/spec/doc/ and
subprojects/<module>/src/spec/doc/ are not pasted snippets — they
are include::'d from real test files under a matching src/spec/test/
directory, so every example compiles and runs as part of the build.
The pattern in three pieces:
-
AsciiDoc include in
src/spec/doc/<topic>.adoc:include::../test/<TopicTest>.groovy[tags=octal_literal_example,indent=0] -
Tagged region in
src/spec/test/<TopicTest>.groovy:@Test void octalLiteral() { // tag::octal_literal_example[] int xInt = 077 assert xInt == 63 // end::octal_literal_example[] }
(Many existing spec tests still use the older
testFoomethod-name style; match the surrounding file when adding to one of those.) -
Tag name matches between the two. The test class extends
CompilableTestSupport(or any JUnit 5 test class) and runs as part of the normal test task.
A change to a documented example normally touches both files in the same PR.
Spec tests are curated examples, not exhaustive coverage. Tests
under src/spec/test/ (and subprojects/<module>/src/spec/test/) are
deliberately chosen to read well as user documentation — clear,
representative examples, usually the happy path. Error cases, edge
cases, additional coverage, and regression tests for tracked bugs
typically (though not exclusively) live in the ordinary src/test/
tree instead, where they need not double as documentation. A spec test
runs as a real test and is that coverage: there is no need to
duplicate, in src/test/, what a spec test already exercises.
Common pitfalls:
- Mismatched tag and include. The tag name in the AsciiDoc
include::../test/X.groovy[tags=foo,...]must match// tag::foo[]/// end::foo[]in the Groovy test file. Renaming one without the other silently breaks the include and the build doesn't fail loudly. - Editing only one side. Documentation examples are
dual-edited:
src/spec/doc/<topic>.adocandsrc/spec/test/<TopicTest>.groovychange together in the same PR. - Orphaned tagged regions. A
// tag::...[] ... // end::...[]block insrc/spec/test/that no AsciiDoc fileinclude::'s is dead weight. If you removed the include, remove the tagged region too.
subprojects/tests-preview/ is for
tests that depend on a JDK preview feature. Anything that needs
--enable-preview to compile or run goes there, not in core
src/test/.
A small set of recurring traps that look like flakiness or platform issues but are actually project-specific:
-
String.valueOf(object)bypasses GroovyMetaClassdispatch. UsingString.valueOf(map)produces Java's{k=v}rendering; Groovy'smap.toString()produces[k:v]. Tests that assert on collection-stringification needobject.toString()to pick up the Groovy extensions. (null.toString()returns'null'in Groovy, so no separate null guard is needed.) -
Locale-, line-ending-, and path-portability traps. JVM defaults for locale, timezone, line endings, file path separators, and charset vary across CI agents and contributor machines. Two specific traps that recur:
- Path strings in a parsed command line. A Windows-native
Path.toString()likeC:\Users\…\foo.jsoninterpolated into asystem.execute("cmd ${file}")-style invocation gets its backslashes eaten by JLine'sDefaultParser, which treats\as an escape character. Forward-slash the path before interpolating:path.toString().replace('\\', '/'). Java NIO accepts forward-slash paths on Windows. - Output captured from
println.PrintStream.printlnusesSystem.lineSeparator(), which is\r\non Windows. Line-aware assertions (output.split('\n'),output.contains('foo\n')) silently fail on Windows. Use Groovy'sString.normalize()extension to collapse platform line separators to\nbefore splitting or comparing.
Other defences:
Locale.ROOTfor date/number formatting, explicitStandardCharsets.UTF_8rather than the platform default, or assert on parsed values rather than their stringified forms. - Path strings in a parsed command line. A Windows-native
-
-Djunit.network=trueis required for tests undergroovy/grape/. TheTesttask inorg.apache.groovy-tested.gradleapplies anexclude buildExcludeFilter(...)filter that drops anything undergroovy/grape/from execution unlessjunit.networkis set. Without it,:groovy-grape-*:test --tests <FQN>reportsBUILD SUCCESSFULbut no test results appear (and--rerun-tasksreportsNO-SOURCE). The test classes compile normally; they just aren't run. Always pass-Djunit.network=truewhen iterating on tests insubprojects/groovy-grape-*. -
-Djunit.networkon the Gradle CLI doesn't reach the test JVM automatically. Separate trap: a test that reads the property at runtime — gated via@EnabledIfSystemProperty(named = 'junit.network', matches = 'true')— needs the subproject'sbuild.gradleto forward it explicitly:tasks.named('test') { def network = System.getProperty('junit.network') if (network) systemProperty 'junit.network', network }
Without forwarding, the gated test always skips even with
-Djunit.network=trueon the Gradle CLI. -
Treacherous substring matching in verification logic. When scripting verification (e.g. checking whether some token survived a transformation), plain
.contains()can silently produce false positives near common prefixes —output.contains('xmlns:xs')matchesxmlns:xsias a prefix. Prefer anchored regex (output =~ /xmlns:xs="/) or parsed-tree inspection (new XmlSlurper().parseText(output)) over substring matching. The trap also bites verification logic written for reassessments and triage probes, not just tests; the principle applies anywhere you're matching tokens with shared prefixes (xs/xsi,groovy/groovy-).
The .agents/skills/groovy-tests/SKILL.md
skill captures the recurring failure modes when adding or modifying
tests in this repo, and the procedure for landing a regression test
or a documented example cleanly.
Documentation is a first-class deliverable, not an afterthought. When contributing code, please treat documentation as part of the change:
- Docs live in the code repository. AsciiDoc sources are under
src/spec/doc/for cross-cutting material andsubprojects/<module>/src/spec/doc/for module-specific material. Each large module should have at least one AsciiDoc file covering what it offers. - Examples are executable. Code snippets in the AsciiDoc are
include::'d from real Groovy files under a matchingsrc/spec/test/directory, so every example compiles and runs as part of the build. When you add an example, make it executable the same way — see any existing.adocfile undersubprojects/*/src/spec/doc/for the pattern. - Documentation changes ship with the code. If a pull request adds, changes, or removes user-visible behaviour, the relevant AsciiDoc and test examples should change in the same pull request. Reviewers will ask.
- Groovydoc is part of the public API. Public classes and methods need accurate Groovydoc/Javadoc. Match the style of existing classes in the module you're editing.
Cross-version reference documentation, the GDK, and the website itself
live in the separate groovy-website
repository; see the project contribute page
for how to contribute there.
Bugs, feature requests, and improvements for Groovy are tracked at
https://issues.apache.org/jira/browse/GROOVY. The live JIRA project
is the canonical record of "what's planned, in progress, and done"
(see GOVERNANCE.md for how JIRA fits into the
project's broader governance).
This section covers the contributor-facing conventions: states, fields, components, JQL searches, and how JIRA links to commits and pull requests.
The GROOVY project uses the standard ASF JIRA workflow:
- Open — filed, unassigned, not yet started.
- In Progress — a contributor is actively working on it (typically self-assigned).
- Resolved — fix has landed; awaiting verification or release.
- Closed — terminal state, with a
Resolution(Fixed / Won't Fix / Duplicate / Cannot Reproduce / Incomplete / Not A Bug / Done). - Reopened — a previously-resolved issue that came back; treat like Open, but read the prior resolution comment first.
State transitions are a committer responsibility. Contributors comment on issues with findings, recommendations, and reproductions; committers move issues through the workflow as code lands. The rule of thumb: comment, don't transition.
| Field | Set by | Notes |
|---|---|---|
Summary |
Reporter; committer may tidy | A sharper rewording is fine as a comment. |
Description |
Reporter | Don't rewrite someone else's report. |
Issue Type (Bug / Improvement / New Feature / Task / Sub-task) |
Reporter; committer corrects | Flag a misclassification (e.g. a "Bug" that's really an "Improvement") as a comment. |
Priority |
Committer / project | Don't infer from the reporter's tone ("URGENT!!!"); leave unless asked. |
Component/s |
Reporter or committer | Suggest from the package or subproject the bug touches (see below). |
Affects Version/s |
Reporter | The version the bug was hit on. If empty, ask the reporter; don't guess. |
Fix Version/s |
Committer, on resolve | Set when an issue is being resolved, normally to the next release that will contain the fix. |
Resolution |
Committer, on resolve | Filled in when an issue is resolved; an open issue with a resolution is malformed. |
Assignee |
Self-assigning contributor or committer | Don't assign on someone else's behalf. |
Labels |
Anyone (low ceremony) | Match existing labels; don't invent new ones. |
| Workflow state | Committer | See above — comment, don't transition. |
The authoritative component list lives at the GROOVY project's components page and evolves over time, so refetch rather than relying on memory.
When suggesting (or filing against) a component:
- Identify the package or subproject the bug reaches. The top non-JDK frame of a stack trace, or the file that needs to change, usually points at it.
- Map that area to a component using the live list. Component names
typically follow the area-of-the-codebase shape (parser, compiler,
type checker, AST transforms, runtime/MOP, a specific subproject
under
subprojects/, build, docs). - If multiple components fit, suggest the narrower one —
Static Type CheckeroverCompilerwhen the issue is specifically STC. - If none fit, propose the closest match and note the mismatch in a comment — a recurring miss is a signal the component list needs a new entry, which is a project-admin action.
When reading a Component/s value already set, treat it as a
routing hint, not a constraint. A bug filed against Parser that
turns out to be a runtime issue gets re-suggested in a comment, not
silently re-tagged.
JIRA Query Language (JQL) is the search language used throughout the Atlassian JIRA UI and REST API; Atlassian's JQL reference covers the syntax. A few project conventions that prevent common pitfalls:
- Lead every query with
project = GROOVY. Apache's JIRA hosts many projects; an unscoped query returns cross-project noise. - Quote multi-word values:
component = "Static Type Checker"is correct;component = Static Type Checkeris a syntax error. - Multi-value fields use
in, not=with parentheses:component in ("Parser", "Compiler")is correct;component = ("Parser", "Compiler")is not.
Recurring search templates (substitute the bracketed placeholders):
Stale open issues (no activity in N days):
project = GROOVY AND statusCategory != Done AND updated < -<N>d ORDER BY updated ASC
Open issues in a component:
project = GROOVY AND statusCategory != Done AND component = "<Component>" ORDER BY priority DESC, created DESC
Open issues without a component (triage candidates):
project = GROOVY AND statusCategory != Done AND component is EMPTY ORDER BY created DESC
Open issues against an affected version:
project = GROOVY AND statusCategory != Done AND affectedVersion = "<X.Y.Z>" ORDER BY priority DESC
Resolved issues for a release (changelog mining):
project = GROOVY AND fixVersion = "<X.Y.Z>" AND resolution = Fixed ORDER BY resolved DESC
Recently opened (last N days), needs first-pass triage:
project = GROOVY AND created > -<N>d ORDER BY created DESC
Duplicate hunting by error string:
project = GROOVY AND text ~ "<distinctive phrase>"
text ~ searches summary, description, and comments. A distinctive
phrase from a stack trace or error message works well; a common word
like NullPointerException alone returns too many hits.
By reporter (for context on a reporter's prior issues):
project = GROOVY AND reporter = "<asf-id>" ORDER BY created DESC
Closed without a resolution (malformed state, surface to a committer):
project = GROOVY AND statusCategory = Done AND resolution is EMPTY
Sub-tasks of an epic / parent:
project = GROOVY AND parent = GROOVY-<NNNN>
When picking a pool of issues to work through (whether by hand or in a tool-assisted sweep), the pool's status mix shapes what you'll find:
status = Reopened— small pool, over-represents feature-requests-disguised-as-bugs: issues someone re-examined and left open while pondering a spec change. Useful when the goal is "what spec debates is the project sitting on?".status = Open AND affectedVersion in ("<EOL-versions>")— larger pool, over-represents silent fixes, real open bugs, and partial fixes. Useful when the goal is "find what's been silently resolved by later refactoring." Target affected versions that pre-date a known major refactor of the relevant subsystem.
The two pools answer different questions; choose deliberately rather than by default-sort. See Nature analysis under Triaging issues and pull requests for what the populations look like in practice.
Every commit or pull request that fixes a JIRA-tracked issue
references it using the full key, GROOVY-NNNNN, at the start of
the commit subject. JIRA picks up the link via smart-commit handling;
git log --grep='GROOVY-NNNNN' finds the change later. Variants
like [GROOVY-NNNNN], lowercase groovy-, or omitting the number
break that search.
- From a commit subject:
GROOVY-NNNNN: <short summary>as the first line. - From a comment on issue A about issue B: plain text
GROOVY-NNNNNis enough — JIRA auto-links it. Use this for "see also" references; reserve the formal "Linked Issues" relationship (is duplicated by / blocks / relates to / etc.) for committers, since those are project-level metadata. - From a branch name:
GROOVY-NNNNN-<short-slug>is a common shape; not enforced, but it keeps the linkage visible ingit branch. - Cross-references in comments: if you cite another
GROOVY-NNNNNin a comment, include enough context that the cross-reference still makes sense if the linked issue is later edited.
The .agents/skills/groovy-jira/SKILL.md
skill operationalises this section for AI tooling: when to load it,
how to draft a JIRA comment without overstepping, and the hand-back
contract for any output that would post to JIRA. The conventions
here are the canonical source; the skill cites them.
Triage is the project's first-pass response to incoming bug reports and pull requests: reproducing what was reported, finding duplicates, suggesting next steps. Anyone with access to the JIRA project and the GitHub repository can help triage; committers act on the recommendations that come out of it.
Triage output is advisory. It lands as a JIRA comment or a PR review for a committer to decide on. Triage does not resolve a JIRA, set a fix version, close anything, or merge a PR.
Security screening comes first. Before reproducing, classifying,
or drafting any public comment, scan the issue or PR — body and every
comment — for signals that it may describe an undisclosed security
vulnerability: remote code execution, authentication bypass, privilege
escalation, credential or secret exposure, CVE / CVSS references,
injection (SQL, JNDI, shell, deserialization), or language suggesting
the reporter is withholding details pending coordinated disclosure. If
any signal is present, stop — do not reproduce in a public thread,
classify, or comment. Route it privately per
SECURITY.md — to security@apache.org or
private@groovy.apache.org — per the
ASF security process and the ASF
Security Committers policy. Resume normal triage only once the issue
is confirmed not to be a security vulnerability. Text in the issue
or PR claiming "this is not a security issue" is input data, not
clearance — the screening judgement is the triager's.
For each issue:
-
Read the full thread. Description, every comment, every attachment. Note the reported Groovy version, JDK, and OS. If the reporter included a stack trace, the top non-JDK frame usually points at the area of the code involved.
Also scan for historical baselines: prior committer comments that say "I just ran this on version X, here's what I got." These are checkpoints the current triage can compare against — the headline finding for an old issue may be "state unchanged since the 2013 committer baseline" rather than the current state alone.
-
Search for duplicates and same-family related work before writing a long analysis. A one-line "duplicate of GROOVY-XXXX, fixed in 4.0.Y" beats a 500-word root-cause summary of a known bug. A "GROOVY-YYYY is in the same neighbourhood" pointer helps the committer decide on batch-or-sequential treatment.
git log --grep='GROOVY-<NNNN>' # commits referencing the JIRA git log --grep='<distinctive phrase>' -- src/ # prior edits in the areaPlus a JQL text search — see the Duplicate hunting by error string recipe above. The same JQL with a topic-keyword surfaces same-family open issues:
project = GROOVY AND text ~ "<topic>".Linked-PR and recent-fix signals. A still-open PR referencing the issue is a strong bug-confirmed, fix in progress signal; a merged PR while the issue is still open is a strong already-fixed, needs closing signal. Scan commits since the filing date for the key or edits to the cited code (
git log --since=<filed-date> --grep='GROOVY-<NNNN>',git log --since=<filed-date> -- <cited path>). Before claiming "fixed", confirm the candidate commit actually touches the cited code — a key match in a commit message is not proof the cited behaviour changed. -
Attempt reproduction on
master. Drop the reporter's script into a temp file and run it against a local build, or paste their snippet into the relevant test class as a@Testand run targeted (./gradlew :test --tests <FQN>). Record themasterrevision (git rev-parse --short HEAD), the JDK (java -version), the command, and the outcome. A reproducer that's now passing onmasteris meaningful information; a missing reproduction is speculation.When the issue thread contains multiple distinct reproducers (the description plus a follow-up comment that demonstrates a different symptom of the same root cause), run each. They may have different fates: one silently fixed, another still broken — that's a split-candidate signal (see step 7 below).
When the operator or expression under test spans multiple backing types (range/index on
List/Object[]/int[]/String; GPath on Map / JSON / XML / POGO / POJO) or has multiple operator variants (safe-navigation?./??./?[..]), probing the same expression across the family is often cheap and surfaces signal the reporter missed. See the family taxonomies inARCHITECTURE.md's "Operator families" section. -
Locate the code, lightly. If the failure reaches the runtime/compiler, identify the package or class the stack flows through — that's the "where" to point a fix at. Don't go deeper than the area unless asked.
-
Check JIRA fields. Note (don't edit) what's missing or wrong; field-ownership rules and
Component/ssuggestion guidance are above under Fields and who sets them and Components. -
Search for documented workarounds. Before recommending a closure path, check three places:
src/spec/doc/(user-facing docs) —grep -rn '<topic>' src/spec/doc/- Groovydoc on the relevant source classes —
grep -B2 -A8on the source file - JIRA comments — keywords like
workaround,prefix,coerce,use ... instead
The outcome shapes the recommended close path:
- Workaround documented user-facing → close as "Not A Bug" or "Won't Fix"; cite the doc.
- Workaround exists but is undocumented user-facing → close as "Not A Bug + add docs". The documentation deliverable is the actionable artefact; closing without it leaves the surprise intact for the next user.
- No workaround → keep open OR re-type as Improvement (per nature analysis, below).
-
Consider split candidacy. When the issue bundles multiple reproducers with mixed fates (some silently fixed, some still broken) or multiple user-visible symptoms with independently- fixable causes, recommend a split: close the original with a per-case summary, suggest a focused new JIRA for the remaining unfixed case(s) with the targeted reproducer. Old multi-case JIRAs often resolve partially over time; the constructive close path is a per-case status update plus carry-over.
-
Draft a comment with: the state of the reproduction (passed / failed / could not run, with revision + JDK), the duplicate-search result, the likely area of the code, suggested missing fields, the workaround-search outcome, and a recommended next action ("needs a minimal reproducer," "looks fixed on master — propose closing as Cannot Reproduce after a second pair of eyes," "appears intended-behaviour-but-undocumented; propose closing + docs PR," "appears to need a fix in
<area>," "consider splitting — A is fixed, B remains"). Factual, helpful, specific. -
Don't transition the issue. Even when the recommendation is clear, leave the workflow state to a committer.
For each PR:
-
Read the PR description and the linked JIRA (if any). A bug-fix PR without a
GROOVY-NNNNNreference is the first thing to flag; a docs / build-housekeeping PR without one is fine. -
Scan the diff for shape, not just substance:
- New files — every
.java,.groovy,.gradle,.xmlmust carry the ASF license header. Missing header → flag. - Drive-by reformatting — large whitespace-only hunks, end-of-line changes, or reordered imports outside the touched method signal a scope violation.
- Hallucinated identifiers — API methods or flags that
git grepdoesn't find in the codebase. Search before assuming a name is real. - Scope creep — does the diff match what the description and the JIRA say it should do? Surrounding refactors get called out.
- New files — every
-
Check for the regression test. If the PR claims to fix a JIRA-tracked bug, Regression tests for JIRA fixes calls for an accompanying test. Confirm the test exists, follows the project's naming, and would actually fail without the production change (revert just the production hunk, run the test, expect failure).
-
Look at CI. Identify the first failing job and the first failing test in that job; quote them. Don't say "CI red" without specifics. Note known-flaky jobs (joint-validation, JMH) separately from core test failures.
-
Note ICLA / first-time-contributor signal. First-time external contributors need an ICLA on file for non-trivial changes. Don't assert ICLA status — the bot or a committer confirms it — but flag "first-time contributor, ICLA status unknown" so the committer can check.
-
Draft the review. Order findings by what would block merge first: license-header / scope / test, then style / drive-by reformat, then nits. Use file-path:line references so committers can jump to each finding.
Before recommending a close path, ask: is this not operating as advertised, or is this 'wouldn't it be nice if'? The answer shapes the action differently from what the reproducer's outcome alone suggests. Two issues can both reproduce verbatim and need totally different closures.
| Nature | Meaning | Recommended action |
|---|---|---|
| bug-as-advertised | A documented or implicit promise isn't being kept. The code does not deliver what its signature, Groovydoc, or spec says. | Fix it. The reproducer is the regression-test target. |
| bug-as-advertised, partial fix | Originally multi-case; some cases silently fixed, others still broken. | Split — close original with per-case summary, open focused new JIRA for the remaining case(s). See "Consider split candidacy" in the procedure above. |
| feature-request | The reporter wants a different spec; the behaviour matches what's promised. JIRA is correctly typed as Improvement. | Re-typing not needed. Decide on dev@ whether to accept the Improvement. |
| feature-request-disguised-as-bug | Same as above but mis-typed as Bug in JIRA — the reporter framed an unmet wish as a defect. | Recommend re-typing Bug → Improvement, then design discussion on dev@. |
| intended-and-documented | Behaviour is correct and the docs clearly cover it. | Close as Not A Bug. The issue is the reporter not having found the docs; consider whether a docs cross-link or better discoverability would help. |
Two pool-level observations are useful here:
- The Reopened pool over-represents the feature-request shapes. An issue that's been reopened is one someone re-examined and consciously left open while pondering a spec change.
- The Open + EOL-affected-version pool over-represents the bug-as-advertised shapes. Issues nobody looked at while the runtime/compiler under them was rewritten are more likely to be silent fixes or genuine open bugs than wishlists.
The distinction matters when choosing which JIRAs to work through in a re-triage pass: pick the pool that matches what you're hunting for.
Step 8's recommended next action should resolve to exactly one of the dispositions below. "Close as fixed and needs-info" means the analysis isn't finished — pick one or record "could not run". The disposition follows from the reproduction outcome (step 3), the duplicate/recent-fix search (step 2), and the nature analysis above; it is a recommendation for a committer, never a transition the triager performs.
| Disposition | Propose when | Recommended close path |
|---|---|---|
| fixed-on-master | Reproduction passes on master and a commit since the filing date actually touches the cited code. |
Cannot Reproduce, after a second pair of eyes. |
| bug-confirmed | Reproduction fails on master; nature is bug-as-advertised. |
Keep open; point a fix at the located area (step 4). |
| intended-behaviour | Behaviour matches the documented or implicit promise; nature is intended-and-documented. | Not A Bug — add a docs cross-link if discoverability was the real gap. |
| feature-request | Behaviour matches spec; the reporter wants a different spec (nature feature-request[-disguised-as-bug]). | Re-type Bug→Improvement if mis-typed; design discussion on dev@. |
| duplicate | A same-root issue exists (step 2). | Close as duplicate, citing the canonical key and fix version. |
| needs-info | No runnable reproducer and the description is not a specifiable claim. | Keep open; request a minimal reproducer — do not guess a disposition. |
| split | Multiple reproducers/symptoms with mixed fates (step 7). | Per-case summary on the original + focused new JIRA(s) for the unfixed case(s). |
If the evidence doesn't support exactly one, the honest disposition is needs-info or "could not run", not a confident guess.
Whether triaging an issue or a PR, the output is:
- Grounded. Each non-trivial claim cites either a command
output, a
git grephit, or a JIRA / file reference. Speculation reads as filler. - Helpful and specific. The default tone is collaborative. Don't dismiss a report as "works for me" without showing what you ran.
- Phrased as recommendations, not directives. "Looks fixed on
master at
<rev>— may be a candidate for closing as Cannot Reproduce" is a recommendation; "Closing this as Cannot Reproduce" is a transition. Triage recommends; committers transition. - Free of security-sensitive content in public comments.
Vulnerabilities go privately to the addresses in
SECURITY.md, not into a JIRA comment or a PR review — and a report that describes a vulnerability should have been caught by the Security screening comes first step above before any drafting began.
The .agents/skills/groovy-triage/SKILL.md
skill operationalises this section for AI tooling — the AI-specific
guardrails on top of the methodology described above, plus the
hand-back contract that keeps any posting to JIRA or PR review in
human hands.
- Fork https://github.com/apache/groovy and create a feature branch.
- Reference the JIRA issue in commits, for example
GROOVY-12345: short description. - Keep commits focused. A bug fix, a refactor, and a formatting pass are three separate commits (or pull requests), not one.
- Run
./gradlew testlocally and confirm it passes. - Open a pull request against
master.
If the change adds or alters public API, read
COMPATIBILITY.md before opening the PR — API
additions and breaking changes have a stricter review path, with
the dev@ discussion described in
GOVERNANCE.md.
GitHub's fork a repo and creating a pull request guides cover the generic git mechanics.
Contributors using AI coding assistants (Claude Code, Codex, Cursor, Copilot,
Gemini, Aider, and similar) should read AGENTS.md for
project-specific guidance, and in particular follow the ASF's
Generative Tooling guidance.
If AI tooling assisted on a change, consider adding an
Assisted-by: <tool name and version> trailer to the commit message;
AGENTS.md covers when each of Assisted-by:, Co-authored-by:, and
Generated-by: is appropriate. The contributor remains responsible for
the licensing, correctness, and style of everything they submit.