Skip to content

Commit 2344434

Browse files
committed
approach to run subset of tests
1 parent 7c99a83 commit 2344434

4 files changed

Lines changed: 314 additions & 18 deletions

File tree

‎.github/scripts/guide_ci_tasks.py‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import json
6+
import subprocess
7+
import sys
8+
from dataclasses import dataclass
9+
from pathlib import Path
10+
from typing import Iterable
11+
12+
13+
NULL_SHA = "0" * 40
14+
15+
16+
@dataclass(frozen=True)
17+
class GuideMetadata:
18+
slug: str
19+
publish: bool
20+
base: str | None
21+
22+
23+
def kebab_case_to_gradle_name(name: str) -> str:
24+
gradle_name = "".join(part[:1].upper() + part[1:] for part in name.split("-"))
25+
return gradle_name[:1].lower() + gradle_name[1:]
26+
27+
28+
def guide_slug_from_path(path: str) -> str | None:
29+
parts = path.strip().strip("/").split("/")
30+
if len(parts) >= 2 and parts[0] == "guides" and parts[1]:
31+
return parts[1]
32+
return None
33+
34+
35+
def guide_slugs_from_paths(paths: Iterable[str]) -> set[str]:
36+
return {
37+
slug
38+
for path in paths
39+
if (slug := guide_slug_from_path(path)) is not None
40+
}
41+
42+
43+
def load_guides(guides_dir: Path) -> dict[str, GuideMetadata]:
44+
guides = {}
45+
for metadata_path in sorted(guides_dir.glob("*/metadata.json")):
46+
with metadata_path.open(encoding="utf-8") as metadata_file:
47+
metadata = json.load(metadata_file)
48+
slug = metadata_path.parent.name
49+
guides[slug] = GuideMetadata(
50+
slug=slug,
51+
publish=metadata.get("publish", True) is not False,
52+
base=metadata.get("base"),
53+
)
54+
return guides
55+
56+
57+
def resolve_impacted_guide_slugs(
58+
changed_files: Iterable[str],
59+
guides: dict[str, GuideMetadata],
60+
) -> list[str]:
61+
impacted_slugs = guide_slugs_from_paths(changed_files)
62+
63+
changed = True
64+
while changed:
65+
changed = False
66+
for guide in guides.values():
67+
if guide.base in impacted_slugs and guide.slug not in impacted_slugs:
68+
impacted_slugs.add(guide.slug)
69+
changed = True
70+
71+
return sorted(
72+
guide.slug
73+
for guide in guides.values()
74+
if guide.publish and guide.slug in impacted_slugs
75+
)
76+
77+
78+
def tasks_for_changed_files(changed_files: Iterable[str], guides_dir: Path) -> list[str]:
79+
guides = load_guides(guides_dir)
80+
return [
81+
f"{kebab_case_to_gradle_name(slug)}Build"
82+
for slug in resolve_impacted_guide_slugs(changed_files, guides)
83+
]
84+
85+
86+
def matrix_for_tasks(tasks: Iterable[str]) -> dict[str, list[str]]:
87+
return {"group_test_tasks": list(tasks)}
88+
89+
90+
def git_changed_files(
91+
repo: Path,
92+
base: str | None,
93+
head: str,
94+
diff_mode: str = "range",
95+
) -> list[str]:
96+
if base and base != NULL_SHA:
97+
revisions = [f"{base}...{head}"] if diff_mode == "merge-base" else [base, head]
98+
command = ["git", "diff", "--name-only", "--diff-filter=ACMRT", *revisions]
99+
else:
100+
command = ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", head]
101+
102+
result = subprocess.run(
103+
command,
104+
cwd=repo,
105+
check=True,
106+
text=True,
107+
stdout=subprocess.PIPE,
108+
)
109+
return [line for line in result.stdout.splitlines() if line]
110+
111+
112+
def write_github_output(output_path: Path, matrix: dict[str, list[str]]) -> None:
113+
tasks = matrix["group_test_tasks"]
114+
with output_path.open("a", encoding="utf-8") as output_file:
115+
output_file.write(f"matrix={json.dumps(matrix, separators=(',', ':'))}\n")
116+
output_file.write(f"has_tasks={str(bool(tasks)).lower()}\n")
117+
118+
119+
def parse_args() -> argparse.Namespace:
120+
parser = argparse.ArgumentParser(
121+
description="Resolve changed guide paths to Micronaut guide Gradle build tasks.",
122+
)
123+
parser.add_argument("--repo", default=".", help="Repository root.")
124+
parser.add_argument("--guides-dir", help="Guides directory. Defaults to <repo>/guides.")
125+
parser.add_argument("--base", help="Base commit for git diff.")
126+
parser.add_argument("--head", default="HEAD", help="Head commit for git diff.")
127+
parser.add_argument(
128+
"--diff-mode",
129+
choices=("range", "merge-base"),
130+
default="range",
131+
help="Use range for base..head or merge-base for base...head.",
132+
)
133+
parser.add_argument(
134+
"--changed-file",
135+
action="append",
136+
dest="changed_files",
137+
help="Changed file path. May be supplied more than once; skips git diff.",
138+
)
139+
parser.add_argument(
140+
"--github-output",
141+
help="Path to the GitHub Actions output file.",
142+
)
143+
return parser.parse_args()
144+
145+
146+
def main() -> int:
147+
args = parse_args()
148+
repo = Path(args.repo).resolve()
149+
guides_dir = Path(args.guides_dir).resolve() if args.guides_dir else repo / "guides"
150+
151+
changed_files = (
152+
args.changed_files
153+
if args.changed_files is not None
154+
else git_changed_files(repo, args.base, args.head, args.diff_mode)
155+
)
156+
tasks = tasks_for_changed_files(changed_files, guides_dir)
157+
matrix = matrix_for_tasks(tasks)
158+
159+
print(f"Changed files: {json.dumps(changed_files)}", file=sys.stderr)
160+
print(f"Selected Gradle tasks: {json.dumps(tasks)}", file=sys.stderr)
161+
print(json.dumps(matrix, separators=(",", ":")))
162+
163+
if args.github_output:
164+
write_github_output(Path(args.github_output), matrix)
165+
166+
return 0
167+
168+
169+
if __name__ == "__main__":
170+
raise SystemExit(main())
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import json
5+
import tempfile
6+
import unittest
7+
from pathlib import Path
8+
9+
import guide_ci_tasks
10+
11+
12+
class GuideCiTasksTest(unittest.TestCase):
13+
def test_direct_guide_change_maps_to_build_task(self) -> None:
14+
with tempfile.TemporaryDirectory() as temp_dir:
15+
guides_dir = Path(temp_dir)
16+
write_metadata(guides_dir, "micronaut-data-mongodb-synchronous")
17+
18+
tasks = guide_ci_tasks.tasks_for_changed_files(
19+
["guides/micronaut-data-mongodb-synchronous/src/main/java/Book.java"],
20+
guides_dir,
21+
)
22+
23+
self.assertEqual(["micronautDataMongodbSynchronousBuild"], tasks)
24+
25+
def test_base_guide_change_maps_to_dependent_published_guide(self) -> None:
26+
with tempfile.TemporaryDirectory() as temp_dir:
27+
guides_dir = Path(temp_dir)
28+
write_metadata(guides_dir, "hello-base", publish=False)
29+
write_metadata(guides_dir, "creating-your-first-micronaut-app", base="hello-base")
30+
31+
tasks = guide_ci_tasks.tasks_for_changed_files(
32+
["guides/hello-base/src/main/java/example/micronaut/MessageController.java"],
33+
guides_dir,
34+
)
35+
36+
self.assertEqual(["creatingYourFirstMicronautAppBuild"], tasks)
37+
38+
def test_transitive_base_guide_changes_are_resolved(self) -> None:
39+
with tempfile.TemporaryDirectory() as temp_dir:
40+
guides_dir = Path(temp_dir)
41+
write_metadata(guides_dir, "root-base", publish=False)
42+
write_metadata(guides_dir, "intermediate-base", publish=False, base="root-base")
43+
write_metadata(guides_dir, "published-guide", base="intermediate-base")
44+
45+
tasks = guide_ci_tasks.tasks_for_changed_files(
46+
["guides/root-base/common.adoc"],
47+
guides_dir,
48+
)
49+
50+
self.assertEqual(["publishedGuideBuild"], tasks)
51+
52+
def test_multiple_changed_files_deduplicate_tasks(self) -> None:
53+
with tempfile.TemporaryDirectory() as temp_dir:
54+
guides_dir = Path(temp_dir)
55+
write_metadata(guides_dir, "hello-base", publish=False)
56+
write_metadata(guides_dir, "creating-your-first-micronaut-app", base="hello-base")
57+
58+
tasks = guide_ci_tasks.tasks_for_changed_files(
59+
[
60+
"guides/creating-your-first-micronaut-app/metadata.json",
61+
"guides/creating-your-first-micronaut-app/src/main/java/Application.java",
62+
"guides/hello-base/src/main/java/MessageController.java",
63+
],
64+
guides_dir,
65+
)
66+
67+
self.assertEqual(["creatingYourFirstMicronautAppBuild"], tasks)
68+
69+
def test_non_guide_changes_return_empty_task_list(self) -> None:
70+
with tempfile.TemporaryDirectory() as temp_dir:
71+
guides_dir = Path(temp_dir)
72+
write_metadata(guides_dir, "micronaut-data-mongodb-synchronous")
73+
74+
tasks = guide_ci_tasks.tasks_for_changed_files(
75+
[".github/workflows/gradle.yml", "buildSrc/src/main/groovy/Plugin.groovy"],
76+
guides_dir,
77+
)
78+
79+
self.assertEqual([], tasks)
80+
81+
82+
def write_metadata(
83+
guides_dir: Path,
84+
slug: str,
85+
*,
86+
publish: bool = True,
87+
base: str | None = None,
88+
) -> None:
89+
guide_dir = guides_dir / slug
90+
guide_dir.mkdir()
91+
metadata = {"title": slug}
92+
if publish is not True:
93+
metadata["publish"] = publish
94+
if base is not None:
95+
metadata["base"] = base
96+
(guide_dir / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8")
97+
98+
99+
if __name__ == "__main__":
100+
unittest.main()

‎.github/workflows/gradle.yml‎

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,31 +14,28 @@ jobs:
1414
runs-on: ubuntu-latest
1515
outputs:
1616
matrix: ${{ steps.test_matrix_step.outputs.matrix }}
17+
has_tasks: ${{ steps.test_matrix_step.outputs.has_tasks }}
1718
steps:
1819
- uses: actions/checkout@v4
19-
- name: Set up JDK
20-
uses: actions/setup-java@v4
21-
with:
22-
distribution: 'adopt'
23-
java-version: '25'
24-
- uses: actions/cache@v3
2520
with:
26-
path: ~/.gradle/caches
27-
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
28-
restore-keys: |
29-
${{ runner.os }}-gradle-
21+
fetch-depth: 0
3022
- name: Generate Guide Test Matrix
3123
id: test_matrix_step
24+
env:
25+
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
26+
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
27+
DIFF_MODE: ${{ github.event_name == 'pull_request' && 'merge-base' || 'range' }}
3228
run: |
33-
echo "Searching for groups..."
34-
gradleTasks=$(./gradlew tasks | grep "Run group of guide tests" | sed 's/\(.*\) - Run group of guide tests/\"\1\"/' | tr '\n' ',' | sed 's/.$//')
35-
tasksEntry="\"group_test_tasks\":[$(echo $gradleTasks)]"
36-
matrixValue="{$(echo $tasksEntry)}"
37-
echo "Created matrix: $matrixValue"
38-
echo "matrix=$matrixValue" >> $GITHUB_OUTPUT
29+
python3 .github/scripts/guide_ci_tasks.py \
30+
--repo "$GITHUB_WORKSPACE" \
31+
--base "$BASE_SHA" \
32+
--head "$HEAD_SHA" \
33+
--diff-mode "$DIFF_MODE" \
34+
--github-output "$GITHUB_OUTPUT"
3935
alltests:
4036
name: Running ${{ matrix.group }} with Java ${{ matrix.java }}
4137
needs: test_matrix
38+
if: needs.test_matrix.outputs.has_tasks == 'true'
4239
runs-on: ubuntu-latest
4340
strategy:
4441
matrix:
@@ -61,9 +58,9 @@ jobs:
6158
with:
6259
distribution: 'adopt'
6360
java-version: ${{ matrix.java }}
64-
- name: Run All Guide Tests
61+
- name: Run Selected Guide Test
6562
run: './gradlew ${{ matrix.group }}'
6663
env:
6764
GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }}
6865
GRADLE_ENTERPRISE_CACHE_USERNAME: ${{ secrets.GRADLE_ENTERPRISE_CACHE_USERNAME }}
69-
GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }}
66+
GRADLE_ENTERPRISE_CACHE_PASSWORD: ${{ secrets.GRADLE_ENTERPRISE_CACHE_PASSWORD }}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Python Scripts
2+
on:
3+
push:
4+
branches:
5+
- master
6+
- '[1-9]+.[0-9]+.x'
7+
paths:
8+
- '.github/scripts/**'
9+
- '.github/workflows/python-scripts.yml'
10+
pull_request:
11+
branches:
12+
- master
13+
- '[1-9]+.[0-9]+.x'
14+
paths:
15+
- '.github/scripts/**'
16+
- '.github/workflows/python-scripts.yml'
17+
18+
jobs:
19+
test:
20+
name: Test Python Scripts
21+
runs-on: ubuntu-latest
22+
steps:
23+
- uses: actions/checkout@v4
24+
- name: Set up Python
25+
uses: actions/setup-python@v5
26+
with:
27+
python-version: '3.x'
28+
- name: Run Tests
29+
run: python3 -m unittest discover -s .github/scripts -p 'test_*.py'

0 commit comments

Comments
 (0)