diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d07adf074..e40a01958 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,4 @@ -# pull request +# Pull Request diff --git a/.github/workflows/create-configlet-sync-issues.yml b/.github/workflows/create-configlet-sync-issues.yml new file mode 100644 index 000000000..1a256cfb9 --- /dev/null +++ b/.github/workflows/create-configlet-sync-issues.yml @@ -0,0 +1,176 @@ +name: Create Configlet Sync Issues + +on: + workflow_call: + workflow_dispatch: + +permissions: + issues: write + contents: read + +jobs: + create-sync-issues: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PARENT_ISSUE_TITLE: "🚨 configlet sync --test found unsynced tests" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Fetch configlet + run: ./bin/fetch-configlet + + - name: Run configlet sync --tests and capture output + id: sync + shell: bash {0} + run: | + raw_output="$(./bin/configlet sync --tests 2>&1)" + exit_code=$? + printf "exit_code=%d\n" "$exit_code" >> "$GITHUB_OUTPUT" + { + printf "output<> "$GITHUB_OUTPUT" + printf "configlet exit code: %d\n" "$exit_code" + printf "%s\n" "$raw_output" + + - name: Parse exercises with missing tests + id: parse + if: steps.sync.outputs.exit_code != '0' + shell: bash + run: | + output='${{ steps.sync.outputs.output }}' + + # Extract exercise slugs from lines like: [warn] dot-dsl: missing 19 test cases + mapfile -t exercises < <(printf "%s\n" "$output" | grep -oP '(?<=\[warn\] )[a-z][a-z0-9-]+(?=: missing \d+ test case)') + + if [[ ${#exercises[@]} -eq 0 ]]; then + printf "No exercises with missing tests found in output.\n" + printf "exercises_json=[]\n" >> "$GITHUB_OUTPUT" + exit 0 + fi + + printf "Found %d exercise(s) with missing tests:\n" "${#exercises[@]}" + printf " - %s\n" "${exercises[@]}" + + # Build JSON array of slugs + json="[" + for i in "${!exercises[@]}"; do + [[ $i -gt 0 ]] && json+="," + json+="\"${exercises[$i]}\"" + done + json+="]" + printf "exercises_json=%s\n" "$json" >> "$GITHUB_OUTPUT" + + # Build per-exercise details: slug test_name uuid (one row per test) + { + printf "details<> "$GITHUB_OUTPUT" + + - name: Find parent tracking issue + id: find_parent + if: steps.sync.outputs.exit_code != '0' && steps.parse.outputs.exercises_json != '[]' + shell: bash + run: | + issue_data=$(gh issue list \ + --repo "${{ github.repository }}" \ + --search "is:issue is:open in:title \"${PARENT_ISSUE_TITLE}\"" \ + --json number \ + --jq '.[0].number // empty') + + if [[ -z "$issue_data" ]]; then + printf "::warning::Parent issue not found. Run 'Run Configlet Sync' first.\n" + printf "parent_number=\n" >> "$GITHUB_OUTPUT" + else + printf "Found parent issue #%s\n" "$issue_data" + printf "parent_number=%s\n" "$issue_data" >> "$GITHUB_OUTPUT" + fi + + - name: Create or update child issues per exercise + if: steps.sync.outputs.exit_code != '0' && steps.parse.outputs.exercises_json != '[]' + shell: bash + env: + EXERCISES_JSON: ${{ steps.parse.outputs.exercises_json }} + DETAILS: ${{ steps.parse.outputs.details }} + PARENT_NUMBER: ${{ steps.find_parent.outputs.parent_number }} + run: | + repo="${{ github.repository }}" + + mapfile -t exercises < <(printf "%s\n" "$EXERCISES_JSON" | jq -r '.[]') + + for slug in "${exercises[@]}"; do + child_title="[configlet] ${slug}: missing test cases" + + # Collect missing tests for this exercise + missing_tests="" + while IFS=$'\t' read -r es_slug test_name uuid; do + [[ "$es_slug" == "$slug" ]] || continue + missing_tests+="- \`${uuid}\` ${test_name}"$'\n' + done <<< "$DETAILS" + + parent_ref="" + [[ -n "$PARENT_NUMBER" ]] && parent_ref="Part of #${PARENT_NUMBER}." + + # Write body to a temp file to avoid quoting / indentation issues + body_file=$(mktemp) + cat > "$body_file" << ISSUE_BODY_EOF + ## Missing test cases for \`${slug}\` + + ${parent_ref} + + The following test cases from [problem-specifications](https://github.com/exercism/problem-specifications/tree/main/exercises/${slug}) are not yet implemented in this track: + + ${missing_tests} + ### How to help + + For detailed instructions on how to fetch configlet and update the tests, please see the **"How to do this task"** section in the main tracking issue: + 👉 **[Read the instructions here](${{ github.server_url }}/${{ github.repository }}/issues/${PARENT_NUMBER:-"none"})** + + _This issue is managed automatically by the [Create Configlet Sync Issues](${{ github.server_url }}/${{ github.repository }}/actions/workflows/create-configlet-sync-issues.yml) workflow._ + ISSUE_BODY_EOF + + # Check for an existing open child issue + existing=$(gh issue list \ + --repo "$repo" \ + --search "is:issue is:open in:title \"${child_title}\"" \ + --json number \ + --jq '.[0].number // empty') + + if [[ -z "$existing" ]]; then + printf "Creating child issue for: %s\n" "$slug" + issue_url=$(gh issue create \ + --repo "$repo" \ + --title "$child_title" \ + --body-file "$body_file" \ + --label "x:knowledge/elementary,x:module/practice-exercise") + new_number=$(basename "$issue_url") + printf "Created #%s for %s\n" "$new_number" "$slug" + else + printf "Updating existing child issue #%s for: %s\n" "$existing" "$slug" + gh issue edit "$existing" \ + --repo "$repo" \ + --body-file "$body_file" + fi + + rm -f "$body_file" + done + + - name: All tests synced — nothing to do + if: steps.sync.outputs.exit_code == '0' + run: printf "✅ All exercises are fully synced. No child issues needed.\n" diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index ef2f2017e..867890a6c 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -11,34 +11,68 @@ on: workflow_dispatch: jobs: - build: - name: Check if tests compile cleanly with starter sources + build-all: + name: Check if all exercise tests compile cleanly with starter sources + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - name: Set up JDK 21 + - name: Set up JDK 25 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 with: - java-version: 21 + java-version: 25 distribution: "temurin" - name: Check if tests compile cleanly with starter sources run: ./gradlew compileStarterTestJava --continue working-directory: exercises - lint: - name: Lint Java files using Checkstyle + build-changed: + name: Check if changed exercise tests compile cleanly with starter sources + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 + with: + java-version: 25 + distribution: "temurin" + - name: Check if changed exercise tests compile cleanly + run: bin/build-changed-exercise + + lint-all: + name: Lint all exercises using Checkstyle + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - name: Set up JDK 21 + - name: Set up JDK 25 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 with: - java-version: 21 + java-version: 25 distribution: "temurin" - name: Run checkstyle run: ./gradlew check --exclude-task test --continue working-directory: exercises + lint-changed: + name: Lint changed exercises using Checkstyle + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 + with: + java-version: 25 + distribution: "temurin" + - name: Lint changed exercises + run: bin/lint-changed-exercise + test-all: name: Test all exercises using java-test-runner if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' diff --git a/.github/workflows/run-configlet-sync.yml b/.github/workflows/run-configlet-sync.yml index b49cbffe8..832564a3a 100644 --- a/.github/workflows/run-configlet-sync.yml +++ b/.github/workflows/run-configlet-sync.yml @@ -8,3 +8,8 @@ on: jobs: call-gha-workflow: uses: exercism/github-actions/.github/workflows/configlet-sync.yml@main + + create-sync-issues: + needs: call-gha-workflow + uses: ./.github/workflows/create-configlet-sync-issues.yml + secrets: inherit diff --git a/bin/build-changed-exercise b/bin/build-changed-exercise new file mode 100755 index 000000000..9238b6744 --- /dev/null +++ b/bin/build-changed-exercise @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -eo pipefail + +source "$(dirname "$0")/common.sh" + +# If any Gradle build file changed, run the full suite and exit +check_gradle_changes \ + "cd exercises && ./gradlew compileStarterTestJava --continue" \ + "Gradle build files changed, running full build suite..." + +if [ -z "$changed_exercises" ]; then + echo "No relevant exercises changed, skipping compile checks." + exit 0 +fi + +# Print exercises +echo "Changed exercises detected:" +echo "$changed_exercises" +echo "----------------------------------------" + +# Run build compile checks +exit_code=0 +for dir in $changed_exercises; do + slug=$(basename "$dir") + + echo "========================================" + echo "=== Running compileStarterTestJava for $slug ===" + echo "========================================" + + if [[ $dir == exercises/practice/* ]]; then + ./exercises/gradlew -p exercises ":practice:$slug:compileStarterTestJava" || exit_code=1 + elif [[ $dir == exercises/concept/* ]]; then + ./exercises/gradlew -p exercises ":concept:$slug:compileStarterTestJava" || exit_code=1 + fi +done + +exit $exit_code diff --git a/bin/common.sh b/bin/common.sh new file mode 100755 index 000000000..9a84eb6d8 --- /dev/null +++ b/bin/common.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -eo pipefail + +# Determine the base branch of the PR +BASE_BRANCH=${GITHUB_BASE_REF:-main} + +# Fetch full history for proper diff +git fetch origin "$BASE_BRANCH" + +# Compute merge base +MERGE_BASE=$(git merge-base HEAD origin/"$BASE_BRANCH") + +# Get changed files relative to merge base +changed_files=$(git diff --name-only "$MERGE_BASE" HEAD) + +# Function to check if Gradle build files changed and run a command +check_gradle_changes() { + local command="$1" + local message="$2" + + if echo "$changed_files" | grep -qE '(\.gradle|gradlew|\.bat|settings\.gradle|gradle-wrapper\.(properties|jar))$'; then + echo "$message" + eval "$command" + exit 0 + fi +} + +# Extract unique exercise directories +get_changed_exercises() { + echo "$changed_files" | \ + grep -E '^exercises/(practice|concept)/[^/]+/.+\.java$' | \ + cut -d/ -f1-3 | sort -u || true +} + +# Variable for reuse +changed_exercises=$(get_changed_exercises) diff --git a/bin/lint-changed-exercise b/bin/lint-changed-exercise new file mode 100755 index 000000000..bbb6efc26 --- /dev/null +++ b/bin/lint-changed-exercise @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -eo pipefail + +source "$(dirname "$0")/common.sh" + +# If any Gradle build file changed, run the full suite and exit +check_gradle_changes \ + "cd exercises && ./gradlew check --exclude-task test --continue" \ + "Gradle build files changed, running full lint suite..." + +if [ -z "$changed_exercises" ]; then + echo "No relevant exercises changed, skipping linting." + exit 0 +fi + +# Print exercises +echo "Changed exercises detected:" +echo "$changed_exercises" +echo "----------------------------------------" + +# Run lint checks +exit_code=0 +for dir in $changed_exercises; do + slug=$(basename "$dir") + + echo "========================================" + echo "=== Running checkstyle for $slug ===" + echo "========================================" + + if [[ $dir == exercises/practice/* ]]; then + ./exercises/gradlew -p exercises ":practice:$slug:check" --exclude-task test || exit_code=1 + elif [[ $dir == exercises/concept/* ]]; then + ./exercises/gradlew -p exercises ":concept:$slug:check" --exclude-task test || exit_code=1 + fi +done + +exit $exit_code diff --git a/bin/test-changed-exercise b/bin/test-changed-exercise index b18a488e8..4e71b25a4 100755 --- a/bin/test-changed-exercise +++ b/bin/test-changed-exercise @@ -1,29 +1,12 @@ #!/usr/bin/env bash set -eo pipefail -# Determine the base branch of the PR -BASE_BRANCH=${GITHUB_BASE_REF:-main} - -# Fetch full history for proper diff -git fetch origin "$BASE_BRANCH" - -# Compute merge base -MERGE_BASE=$(git merge-base HEAD origin/"$BASE_BRANCH") - -# Get changed files relative to merge base -changed_files=$(git diff --name-only "$MERGE_BASE" HEAD) +source "$(dirname "$0")/common.sh" # If any Gradle build file changed, run the full suite and exit -if echo "$changed_files" | grep -qE '\.(gradle|gradlew|bat)$|settings\.gradle'; then - echo "Gradle build files changed, running full test suite..." - ./bin/test-with-test-runner - exit 0 -fi - -# Extract unique exercise directories -changed_exercises=$(echo "$changed_files" | \ - grep -E '^exercises/(practice|concept)/[^/]+/.+\.java$' | \ - cut -d/ -f1-3 | sort -u) +check_gradle_changes \ + "./bin/test-with-test-runner" \ + "Gradle build files changed, running full test suite..." if [ -z "$changed_exercises" ]; then echo "No relevant exercises changed, skipping tests." diff --git a/exercises/concept/bird-watcher/.meta/config.json b/exercises/concept/bird-watcher/.meta/config.json index d88246548..fcbd526bf 100644 --- a/exercises/concept/bird-watcher/.meta/config.json +++ b/exercises/concept/bird-watcher/.meta/config.json @@ -3,6 +3,9 @@ "samuelteixeiras", "ystromm" ], + "contributors": [ + "jagdish-15" + ], "files": { "solution": [ "src/main/java/BirdWatcher.java" diff --git a/exercises/concept/bird-watcher/.meta/src/reference/java/BirdWatcher.java b/exercises/concept/bird-watcher/.meta/src/reference/java/BirdWatcher.java index 027e38a4c..e0884d13e 100644 --- a/exercises/concept/bird-watcher/.meta/src/reference/java/BirdWatcher.java +++ b/exercises/concept/bird-watcher/.meta/src/reference/java/BirdWatcher.java @@ -6,7 +6,7 @@ public BirdWatcher(int[] birdsPerDay) { this.birdsPerDay = birdsPerDay.clone(); } - public int[] getLastWeek() { + public static int[] getLastWeek() { return new int[] { 0, 2, 5, 3, 7, 8, 4 }; } diff --git a/exercises/concept/bird-watcher/src/main/java/BirdWatcher.java b/exercises/concept/bird-watcher/src/main/java/BirdWatcher.java index c19dd38e6..ccdd53ad2 100644 --- a/exercises/concept/bird-watcher/src/main/java/BirdWatcher.java +++ b/exercises/concept/bird-watcher/src/main/java/BirdWatcher.java @@ -6,7 +6,7 @@ public BirdWatcher(int[] birdsPerDay) { this.birdsPerDay = birdsPerDay.clone(); } - public int[] getLastWeek() { + public static int[] getLastWeek() { throw new UnsupportedOperationException("Please implement the BirdWatcher.getLastWeek() method"); } diff --git a/exercises/concept/bird-watcher/src/test/java/BirdWatcherTest.java b/exercises/concept/bird-watcher/src/test/java/BirdWatcherTest.java index 5b1746082..750329177 100644 --- a/exercises/concept/bird-watcher/src/test/java/BirdWatcherTest.java +++ b/exercises/concept/bird-watcher/src/test/java/BirdWatcherTest.java @@ -1,55 +1,43 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.BeforeEach; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; public class BirdWatcherTest { - private static final int DAY1 = 0; - private static final int DAY2 = 2; - private static final int DAY3 = 5; - private static final int DAY4 = 3; - private static final int DAY5 = 7; - private static final int DAY6 = 8; - private static final int TODAY = 4; - - private BirdWatcher birdWatcher; - private final int[] lastWeek = {DAY1, DAY2, DAY3, DAY4, DAY5, DAY6, TODAY}; - - @BeforeEach - public void setUp() { - birdWatcher = new BirdWatcher(lastWeek); - } - @Test @Tag("task:1") @DisplayName("The getLastWeek method correctly returns last week's counts") public void itTestGetLastWeek() { - assertThat(birdWatcher.getLastWeek()) - .containsExactly(DAY1, DAY2, DAY3, DAY4, DAY5, DAY6, TODAY); + assertThat(BirdWatcher.getLastWeek()).isEqualTo(new int[] {0, 2, 5, 3, 7, 8, 4}); } @Test @Tag("task:2") @DisplayName("The getToday method correctly returns today's counts") public void itTestGetToday() { - assertThat(birdWatcher.getToday()).isEqualTo(TODAY); + int[] counts = new int[] {8, 8, 9, 5, 4, 7, 10}; + BirdWatcher birdWatcher = new BirdWatcher(counts); + assertThat(birdWatcher.getToday()).isEqualTo(10); } @Test @Tag("task:3") @DisplayName("The incrementTodaysCount method correctly increments today's counts") public void itIncrementTodaysCount() { + int[] counts = new int[] {8, 8, 9, 2, 1, 6, 4}; + BirdWatcher birdWatcher = new BirdWatcher(counts); birdWatcher.incrementTodaysCount(); - assertThat(birdWatcher.getToday()).isEqualTo(TODAY + 1); + assertThat(birdWatcher.getToday()).isEqualTo(5); } @Test @Tag("task:4") - @DisplayName("The hasDayWithoutBirds method returns true when day had no visits") + @DisplayName("The hasDayWithoutBirds method returns true when at least one day had no visits") public void itHasDayWithoutBirds() { + int[] counts = new int[] {5, 5, 4, 0, 7, 6, 7}; + BirdWatcher birdWatcher = new BirdWatcher(counts); assertThat(birdWatcher.hasDayWithoutBirds()).isTrue(); } @@ -57,7 +45,8 @@ public void itHasDayWithoutBirds() { @Tag("task:4") @DisplayName("The hasDayWithoutBirds method returns false when no day had zero visits") public void itShouldNotHaveDaysWithoutBirds() { - birdWatcher = new BirdWatcher(new int[]{1, 2, 5, 3, 7, 8, 4}); + int[] counts = new int[] {4, 5, 9, 10, 9, 4, 3}; + BirdWatcher birdWatcher = new BirdWatcher(counts); assertThat(birdWatcher.hasDayWithoutBirds()).isFalse(); } @@ -65,7 +54,8 @@ public void itShouldNotHaveDaysWithoutBirds() { @Tag("task:4") @DisplayName("The hasDayWithoutBirds method returns true if the last day has zero visits") public void itHasLastDayWithoutBirds() { - birdWatcher = new BirdWatcher(new int[]{1, 2, 5, 3, 7, 8, 0}); + int[] counts = new int[] {1, 2, 5, 3, 7, 8, 0}; + BirdWatcher birdWatcher = new BirdWatcher(counts); assertThat(birdWatcher.hasDayWithoutBirds()).isTrue(); } @@ -73,30 +63,50 @@ public void itHasLastDayWithoutBirds() { @Tag("task:5") @DisplayName("The getCountForFirstDays method returns correct visits' count for given number of days") public void itTestGetCountForFirstDays() { - assertThat(birdWatcher.getCountForFirstDays(4)).isEqualTo(DAY1 + DAY2 + DAY3 + DAY4); + int[] counts = new int[] {5, 9, 12, 6, 8, 8, 17}; + BirdWatcher birdWatcher = new BirdWatcher(counts); + assertThat(birdWatcher.getCountForFirstDays(4)).isEqualTo(32); } @Test @Tag("task:5") @DisplayName("The getCountForFirstDays method returns overall count when number of days is higher than array size") public void itTestGetCountForMoreDaysThanTheArraySize() { - assertThat(birdWatcher.getCountForFirstDays(10)) - .isEqualTo(DAY1 + DAY2 + DAY3 + DAY4 + DAY5 + DAY6 + TODAY); + int[] counts = new int[] {5, 9, 12, 6, 8, 8, 17}; + BirdWatcher birdWatcher = new BirdWatcher(counts); + assertThat(birdWatcher.getCountForFirstDays(10)).isEqualTo(65); + } + + @Test + @Tag("task:5") + @DisplayName("The incrementTodaysCount method adds one to getCountForFirstDays method") + public void itIncrementDoesNotChangeCountForOtherDays() { + int[] counts = new int[] {5, 1, 0, 4, 2, 3, 0}; + BirdWatcher birdWatcher = new BirdWatcher(counts); + + int countPriorIncrement = birdWatcher.getCountForFirstDays(7); + birdWatcher.incrementTodaysCount(); + int countAfterIncrement = birdWatcher.getCountForFirstDays(7); + + assertThat(countPriorIncrement).isEqualTo(15); + assertThat(countAfterIncrement).isEqualTo(16); } @Test @Tag("task:6") @DisplayName("The getBusyDays method returns the correct count of busy days") public void itTestGetCountForBusyDays() { - // DAY3, DAY5 and DAY6 are all >= 5 birds - assertThat(birdWatcher.getBusyDays()).isEqualTo(3); + int[] counts = new int[] {4, 9, 5, 7, 8, 8, 2}; + BirdWatcher birdWatcher = new BirdWatcher(counts); + assertThat(birdWatcher.getBusyDays()).isEqualTo(5); } @Test @Tag("task:6") @DisplayName("The getBusyDays method correctly returns zero in case of no busy days") public void itShouldNotHaveBusyDays() { - birdWatcher = new BirdWatcher(new int[]{1, 2, 3, 3, 2, 1, 4}); + int[] counts = new int[] {1, 2, 3, 3, 2, 1, 4}; + BirdWatcher birdWatcher = new BirdWatcher(counts); assertThat(birdWatcher.getBusyDays()).isEqualTo(0); } } diff --git a/exercises/practice/grep/.docs/introduction.md b/exercises/practice/grep/.docs/introduction.md new file mode 100644 index 000000000..404129046 --- /dev/null +++ b/exercises/practice/grep/.docs/introduction.md @@ -0,0 +1,5 @@ +# Introduction + +You have taken a job at a local library helping organize their collection of old books. +The student patrons are often hunting for half-remembered quotes to cite in their term papers. +Rather than manually read every book from cover to cover, you decide to build a small tool to scan them, looking for these partial quotes. diff --git a/exercises/practice/prism/.meta/config.json b/exercises/practice/prism/.meta/config.json index 80bee3ce1..1dd33d011 100644 --- a/exercises/practice/prism/.meta/config.json +++ b/exercises/practice/prism/.meta/config.json @@ -11,6 +11,9 @@ ], "example": [ ".meta/src/reference/java/Prism.java" + ], + "invalidator": [ + "build.gradle" ] }, "blurb": "Calculate the path of a laser through refractive prisms.",