diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..b85b196c --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,32 @@ +changelog: + exclude: + labels: + - ignore-for-release + - dependencies + authors: + - dependabot + - github-actions + categories: + - title: "πŸš€ Features" + labels: + - feature + - enhancement + - title: "πŸ› Bug Fixes" + labels: + - bug + - fix + - title: "πŸ“ Documentation" + labels: + - documentation + - docs + - title: "🧹 Maintenance" + labels: + - chore + - refactor + - cleanup + - title: "⬆️ Dependencies" + labels: + - dependencies + - title: "Other Changes" + labels: + - "*" diff --git a/.github/workflows/build-universal.yml b/.github/workflows/build-universal.yml new file mode 100644 index 00000000..cd2230ab --- /dev/null +++ b/.github/workflows/build-universal.yml @@ -0,0 +1,104 @@ +name: Build GitHub Copilot for Anypoint Studio (Universal) + +on: + push: + branches: [ build/universal ] + workflow_dispatch: + +env: + MAVEN_OPTS: -Xmx2g + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract plugin version + run: | + VERSION=$(grep -oP '(?<=)[^<]+' pom.xml) + TIMESTAMP=$(date -u +%Y%m%d%H%M%S) + DISPLAY_VERSION="${VERSION/-SNAPSHOT/-$TIMESTAMP}" + echo "COPILOT_PLUGIN_VERSION=$VERSION" >> $GITHUB_ENV + echo "COPILOT_DISPLAY_VERSION=$DISPLAY_VERSION" >> $GITHUB_ENV + + - name: Set up Java 21 (Temurin) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: 'maven' + + - name: Cache Maven local repo + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Copilot agent + working-directory: com.microsoft.copilot.eclipse.core/copilot-agent + run: | + npm install --force + cp -r node_modules/@github/copilot-language-server/dist ./dist + + - name: Make mvnw executable + run: chmod +x ./mvnw + + - name: Build (Tycho/Maven) + run: | + ./mvnw -DskipTests -Dcheckstyle.skip=true clean verify + + - name: Prepare build artifact + run: | + mkdir -p build + SRC=com.microsoft.copilot.eclipse.repository/target/com.microsoft.copilot.eclipse.repository-${COPILOT_PLUGIN_VERSION}.zip + if [ ! -f "$SRC" ]; then + echo "ERROR: Expected ZIP not found at $SRC" + ls -la com.microsoft.copilot.eclipse.repository/target || true + exit 1 + fi + if ! unzip -l "$SRC" | grep -qE 'content\.(jar|xml|xml\.xz)|p2\.index'; then + echo "ERROR: p2 repository ZIP is missing p2 metadata (content.jar / content.xml.xz / p2.index)" + unzip -l "$SRC" + exit 1 + fi + find build/ -maxdepth 1 -name "github-copilot-for-anypoint-*.zip" -delete 2>/dev/null || true + OUT=build/github-copilot-for-anypoint-${COPILOT_DISPLAY_VERSION}.zip + cp -f "$SRC" "$OUT" + sha256sum "$OUT" | awk '{print $1 " " $2}' > "${OUT}.sha256" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }} + path: | + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}.zip.sha256 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: "v${{ env.COPILOT_DISPLAY_VERSION }}" + name: "GitHub Copilot for Anypoint Studio v${{ env.COPILOT_DISPLAY_VERSION }}" + prerelease: true + generate_release_notes: true + body: | + Pre-release build of GitHub Copilot for Anypoint Studio. + + **Install in Anypoint Studio:** + Download `github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}.zip` and add it as a local p2 update site. + files: | + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}.zip.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-win-mac-universal.yml b/.github/workflows/build-win-mac-universal.yml new file mode 100644 index 00000000..aa9bdae0 --- /dev/null +++ b/.github/workflows/build-win-mac-universal.yml @@ -0,0 +1,143 @@ +name: Build GitHub Copilot for Anypoint Studio (Windows, macOS & Universal) + +on: + push: + branches: [ main ] + workflow_dispatch: + +env: + MAVEN_OPTS: -Xmx2g + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract plugin version + run: | + VERSION=$(grep -oP '(?<=)[^<]+' pom.xml) + TIMESTAMP=$(date -u +%Y%m%d%H%M%S) + DISPLAY_VERSION="${VERSION/-SNAPSHOT/-$TIMESTAMP}" + echo "COPILOT_PLUGIN_VERSION=$VERSION" >> $GITHUB_ENV + echo "COPILOT_DISPLAY_VERSION=$DISPLAY_VERSION" >> $GITHUB_ENV + + - name: Set up Java 21 (Temurin) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: 'maven' + + - name: Cache Maven local repo + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Copilot agent + working-directory: com.microsoft.copilot.eclipse.core/copilot-agent + run: | + npm install --force + cp -r node_modules/@github/copilot-language-server/dist ./dist + + - name: Make mvnw executable + run: chmod +x ./mvnw + + - name: Build (Tycho/Maven) + run: | + ./mvnw -DskipTests -Dcheckstyle.skip=true clean verify + + - name: Prepare build artifacts + run: | + mkdir -p build + SRC=com.microsoft.copilot.eclipse.repository/target/com.microsoft.copilot.eclipse.repository-${COPILOT_PLUGIN_VERSION}.zip + if [ ! -f "$SRC" ]; then + echo "ERROR: Expected ZIP not found at $SRC" + ls -la com.microsoft.copilot.eclipse.repository/target || true + exit 1 + fi + if ! unzip -l "$SRC" | grep -qE 'content\.(jar|xml|xml\.xz)|p2\.index'; then + echo "ERROR: p2 repository ZIP is missing p2 metadata (content.jar / content.xml.xz / p2.index)" + unzip -l "$SRC" + exit 1 + fi + find build/ -maxdepth 1 -name "github-copilot-for-anypoint-*.zip" -delete 2>/dev/null || true + + # Create universal p2 repository (all platforms) + OUT_UNIVERSAL=build/github-copilot-for-anypoint-${COPILOT_DISPLAY_VERSION}.zip + cp -f "$SRC" "$OUT_UNIVERSAL" + sha256sum "$OUT_UNIVERSAL" | awk '{print $1 " " $2}' > "${OUT_UNIVERSAL}.sha256" + + # Create Windows-specific package (win32 only) + OUT_WIN=build/github-copilot-for-anypoint-${COPILOT_DISPLAY_VERSION}-win32.zip + unzip -q "$SRC" -d temp_win + rm -rf temp_win/plugins/*linux* temp_win/plugins/*macosx* temp_win/features/*linux* temp_win/features/*macosx* 2>/dev/null || true + cd temp_win && zip -q -r "../${OUT_WIN}" . && cd .. + rm -rf temp_win + sha256sum "$OUT_WIN" | awk '{print $1 " " $2}' > "${OUT_WIN}.sha256" + + # Create macOS-specific package (macosx only) + OUT_MAC=build/github-copilot-for-anypoint-${COPILOT_DISPLAY_VERSION}-macos.zip + unzip -q "$SRC" -d temp_mac + rm -rf temp_mac/plugins/*linux* temp_mac/plugins/*win32* temp_mac/features/*linux* temp_mac/features/*win32* 2>/dev/null || true + cd temp_mac && zip -q -r "../${OUT_MAC}" . && cd .. + rm -rf temp_mac + sha256sum "$OUT_MAC" | awk '{print $1 " " $2}' > "${OUT_MAC}.sha256" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }} + path: | + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}.zip.sha256 + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip.sha256 + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-macos.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-macos.zip.sha256 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: "v${{ env.COPILOT_DISPLAY_VERSION }}" + name: "GitHub Copilot for Anypoint Studio v${{ env.COPILOT_DISPLAY_VERSION }}" + prerelease: true + generate_release_notes: true + body: | + Pre-release build of GitHub Copilot for Anypoint Studio. + + **Installation in Anypoint Studio:** + + Select the package appropriate for your platform: + + - **Windows (64-bit)**: Download `github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip` + - **macOS (Intel & Apple Silicon)**: Download `github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-macos.zip` + - **All Platforms**: Download `github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}.zip` (universal p2 repository with all platform support) + + **Installation Steps:** + 1. In Anypoint Studio, go to **Help β†’ Install New Software...** + 2. Click **Add...** and select the downloaded ZIP file as a local update site. + 3. Follow the installation wizard and restart Anypoint Studio. + + **Verification:** + Each package includes a `.sha256` file for integrity verification. Use `sha256sum -c file.zip.sha256` to verify the download. + files: | + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}.zip.sha256 + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip.sha256 + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-macos.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-macos.zip.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-win-mac.yml b/.github/workflows/build-win-mac.yml new file mode 100644 index 00000000..86366681 --- /dev/null +++ b/.github/workflows/build-win-mac.yml @@ -0,0 +1,133 @@ +name: Build GitHub Copilot for Anypoint Studio (Windows and macOS) + +on: + push: + branches: [ build/win-mac ] + workflow_dispatch: + +env: + MAVEN_OPTS: -Xmx2g + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract plugin version + run: | + VERSION=$(grep -oP '(?<=)[^<]+' pom.xml) + TIMESTAMP=$(date -u +%Y%m%d%H%M%S) + DISPLAY_VERSION="${VERSION/-SNAPSHOT/-$TIMESTAMP}" + echo "COPILOT_PLUGIN_VERSION=$VERSION" >> $GITHUB_ENV + echo "COPILOT_DISPLAY_VERSION=$DISPLAY_VERSION" >> $GITHUB_ENV + + - name: Set up Java 21 (Temurin) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: 'maven' + + - name: Cache Maven local repo + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Copilot agent + working-directory: com.microsoft.copilot.eclipse.core/copilot-agent + run: | + npm install --force + cp -r node_modules/@github/copilot-language-server/dist ./dist + + - name: Make mvnw executable + run: chmod +x ./mvnw + + - name: Build (Tycho/Maven) + run: | + ./mvnw -DskipTests -Dcheckstyle.skip=true clean verify + + - name: Prepare build artifacts + run: | + mkdir -p build + SRC=com.microsoft.copilot.eclipse.repository/target/com.microsoft.copilot.eclipse.repository-${COPILOT_PLUGIN_VERSION}.zip + if [ ! -f "$SRC" ]; then + echo "ERROR: Expected ZIP not found at $SRC" + ls -la com.microsoft.copilot.eclipse.repository/target || true + exit 1 + fi + if ! unzip -l "$SRC" | grep -qE 'content\.(jar|xml|xml\.xz)|p2\.index'; then + echo "ERROR: p2 repository ZIP is missing p2 metadata (content.jar / content.xml.xz / p2.index)" + unzip -l "$SRC" + exit 1 + fi + find build/ -maxdepth 1 -name "github-copilot-for-anypoint-*.zip" -delete 2>/dev/null || true + + # Create Windows-specific package (win32 only) + OUT_WIN=build/github-copilot-for-anypoint-${COPILOT_DISPLAY_VERSION}-win32.zip + unzip -q "$SRC" -d temp_win + rm -rf temp_win/plugins/*linux* temp_win/plugins/*macosx* temp_win/features/*linux* temp_win/features/*macosx* 2>/dev/null || true + cd temp_win && zip -q -r "../${OUT_WIN}" . && cd .. + rm -rf temp_win + sha256sum "$OUT_WIN" | awk '{print $1 " " $2}' > "${OUT_WIN}.sha256" + + # Create macOS-specific package (macosx only) + OUT_MAC=build/github-copilot-for-anypoint-${COPILOT_DISPLAY_VERSION}-macos.zip + unzip -q "$SRC" -d temp_mac + rm -rf temp_mac/plugins/*linux* temp_mac/plugins/*win32* temp_mac/features/*linux* temp_mac/features/*win32* 2>/dev/null || true + cd temp_mac && zip -q -r "../${OUT_MAC}" . && cd .. + rm -rf temp_mac + sha256sum "$OUT_MAC" | awk '{print $1 " " $2}' > "${OUT_MAC}.sha256" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }} + path: | + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip.sha256 + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-macos.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-macos.zip.sha256 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: "v${{ env.COPILOT_DISPLAY_VERSION }}" + name: "GitHub Copilot for Anypoint Studio - Windows & macOS v${{ env.COPILOT_DISPLAY_VERSION }}" + prerelease: true + generate_release_notes: true + body: | + Pre-release build of GitHub Copilot for Anypoint Studio. + + **Installation in Anypoint Studio:** + + Select the package appropriate for your platform: + + - **Windows (64-bit)**: Download `github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip` + - **macOS (Intel & Apple Silicon)**: Download `github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-macos.zip` + + **Installation Steps:** + 1. In Anypoint Studio, go to **Help β†’ Install New Software...** + 2. Click **Add...** and select the downloaded ZIP file as a local update site. + 3. Follow the installation wizard and restart Anypoint Studio. + + **Verification:** + Each package includes a `.sha256` file for integrity verification. Use `sha256sum -c file.zip.sha256` to verify the download. + files: | + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip.sha256 + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-macos.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-macos.zip.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-win.yml b/.github/workflows/build-win.yml new file mode 100644 index 00000000..d3ca00d0 --- /dev/null +++ b/.github/workflows/build-win.yml @@ -0,0 +1,118 @@ +name: Build GitHub Copilot for Anypoint Studio (Windows) + +on: + push: + branches: [ build/win ] + workflow_dispatch: + +env: + MAVEN_OPTS: -Xmx2g + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract plugin version + run: | + VERSION=$(grep -oP '(?<=)[^<]+' pom.xml) + TIMESTAMP=$(date -u +%Y%m%d%H%M%S) + DISPLAY_VERSION="${VERSION/-SNAPSHOT/-$TIMESTAMP}" + echo "COPILOT_PLUGIN_VERSION=$VERSION" >> $GITHUB_ENV + echo "COPILOT_DISPLAY_VERSION=$DISPLAY_VERSION" >> $GITHUB_ENV + + - name: Set up Java 21 (Temurin) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: 'maven' + + - name: Cache Maven local repo + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Copilot agent + working-directory: com.microsoft.copilot.eclipse.core/copilot-agent + run: | + npm install --force + cp -r node_modules/@github/copilot-language-server/dist ./dist + + - name: Make mvnw executable + run: chmod +x ./mvnw + + - name: Build (Tycho/Maven) + run: | + ./mvnw -DskipTests -Dcheckstyle.skip=true clean verify + + - name: Prepare build artifacts + run: | + mkdir -p build + SRC=com.microsoft.copilot.eclipse.repository/target/com.microsoft.copilot.eclipse.repository-${COPILOT_PLUGIN_VERSION}.zip + if [ ! -f "$SRC" ]; then + echo "ERROR: Expected ZIP not found at $SRC" + ls -la com.microsoft.copilot.eclipse.repository/target || true + exit 1 + fi + if ! unzip -l "$SRC" | grep -qE 'content\.(jar|xml|xml\.xz)|p2\.index'; then + echo "ERROR: p2 repository ZIP is missing p2 metadata (content.jar / content.xml.xz / p2.index)" + unzip -l "$SRC" + exit 1 + fi + find build/ -maxdepth 1 -name "github-copilot-for-anypoint-*.zip" -delete 2>/dev/null || true + + # Create Windows-specific package (win32 only) + OUT_WIN=build/github-copilot-for-anypoint-${COPILOT_DISPLAY_VERSION}-win32.zip + unzip -q "$SRC" -d temp_win + rm -rf temp_win/plugins/*linux* temp_win/plugins/*macosx* temp_win/features/*linux* temp_win/features/*macosx* 2>/dev/null || true + cd temp_win && zip -q -r "../${OUT_WIN}" . && cd .. + rm -rf temp_win + sha256sum "$OUT_WIN" | awk '{print $1 " " $2}' > "${OUT_WIN}.sha256" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }} + path: | + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip.sha256 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: "v${{ env.COPILOT_DISPLAY_VERSION }}" + name: "GitHub Copilot for Anypoint Studio - Windows v${{ env.COPILOT_DISPLAY_VERSION }}" + prerelease: true + generate_release_notes: true + body: | + Pre-release build of GitHub Copilot for Anypoint Studio. + + **Installation in Anypoint Studio:** + + - **Windows (64-bit)**: Download `github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip` + + **Installation Steps:** + 1. In Anypoint Studio, go to **Help β†’ Install New Software...** + 2. Click **Add...** and select the downloaded ZIP file as a local update site. + 3. Follow the installation wizard and restart Anypoint Studio. + + **Verification:** + Each package includes a `.sha256` file for integrity verification. Use `sha256sum -c file.zip.sha256` to verify the download. + files: | + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip + build/github-copilot-for-anypoint-${{ env.COPILOT_DISPLAY_VERSION }}-win32.zip.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index a77cc802..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: CI - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -permissions: - contents: read - -jobs: - build: - - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js environment - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Install Copilot agent - working-directory: com.microsoft.copilot.eclipse.core/copilot-agent - run: npm i -f - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - cache: maven - - - name: Build - uses: coactions/setup-xvfb@b6b4fcfb9f5a895edadc3bc76318fae0ac17c8b3 # v1.0.1 - with: - run: >- - ./mvnw clean verify --batch-mode diff --git a/.gitignore b/.gitignore index 650f8dc0..7ca41493 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ *.war *.nar *.ear -*.zip +# *.zip *.tar.gz *.rar @@ -45,3 +45,8 @@ com.microsoft.copilot.eclipse.core.agent.linux.aarch64/copilot-agent/ com.microsoft.copilot.eclipse.core.agent.win32/copilot-agent/ com.microsoft.copilot.eclipse.core.agent.macosx.x64/copilot-agent/ com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/copilot-agent/ +xdocs/* +install/* +oldbuilds/* +# Ignore generated build artifacts +build/*.zip \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a08dc11..dcd1f9ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,58 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 0.18.0 +## Unreleased β€” MuleSoft Anypoint Studio Enhancements + +### Added + +**13 new slash commands** for MuleSoft-specific workflows (available in Copilot Agent Mode): + +| Command | Purpose | +|---|---| +| `/mule-code-review` | Flow naming, error handlers, global configs, APIkit route coverage, DataWeave | +| `/mule-security-review` | Hardcoded secrets, XXE, SQL injection, XPath injection, TLS, policy gaps | +| `/mule-performance-review` | DataWeave streaming, batch sizing, connector pooling, N+1 queries, caching | +| `/deployment-readiness` | CloudHub/RTF/on-prem checklist, health endpoints, log levels, smoke tests | +| `/api-spec-review` | RAML/OpenAPI governance, APIkit binding, error contracts, security schemes | +| `/generate-munit-tests` | Happy/negative/error path, async, batch, scatter-gather, transactional tests | +| `/dataweave-best-practices` | Null safety, streaming, map/filter/reduce patterns, module reuse | +| `/connector-governance` | Version compatibility, deprecated connectors, pooling, retry strategies | +| `/logging-observability` | Correlation IDs, structured logging, log levels, Anypoint Monitoring | +| `/error-handling-contract` | On Error Propagate/Continue rules, typed matchers, error response shape | +| `/api-led-architecture-review` | Experience/Process/System layer audit, call direction enforcement | +| `/batch-job-review` | maxRecordsPerBlock sizing, aggregator, step error handling, On Complete | +| `/async-flow-review` | Scheduler flows, VM/MQ patterns, async scope, graceful shutdown | + +**Smarter `@console` for Mule errors** β€” when Anypoint Studio console output contains a `MuleRuntimeException`, a structured `[Mule Error Summary]` block (error type, flow name, root cause, component) is prepended before the raw output. This reduces context noise and lets the AI orient to the error immediately. + +**Richer `mule_project_scan` output** β€” the scan tool now returns 10 additional fields: `hasApikit`, `hasSecureProperties`, `hasBatchJob`, `schedulerFlows`, `hasReconnectForever`, `log4j2RootLevel`, `hasDbPoolConfig`, `hasHttpRequestTimeout`, `flowsWithCorrelationId`, `flowErrorHandlerTypes` (typed/catch-all/none per flow). + +**New automatic diagnostics from scan** β€” the project scanner now auto-flags: `reconnect-forever` in production connector config, `until-successful` without `maxRetries`, root log level set to DEBUG/TRACE, missing DB connection pool config, and missing HTTP request timeout. + +**`mule_project_scan` detects per-flow error handler quality** β€” each flow is classified as `typed` (all error handlers have type matchers), `catch-all` (at least one handler has no type), or `none` (no error handler). Directly guides the `/error-handling-contract` review. + +**Correlation ID detection per flow** β€” scan now reports which flows contain a `set-variable variableName="correlationId"` or reference `X-Correlation-ID` in a DataWeave expression. + +**log4j2.xml root level scanning** β€” scan reads `src/main/resources/log4j2.xml` and flags DEBUG/TRACE root level before deployment. + +**Maven profile support in `run_mule_maven_tests`** β€” new optional `mavenProfile` input activates a Maven profile with `-P`, e.g., `dev` or `test`. Tool description now documents MUnit-specific flags (`-Dmunit.test=.xml`) and multi-module project support (`-pl `). + +**`layer` and `targetEnvironment` inputs** β€” code review, security review, and MUnit suggestion tools now accept an API-led layer (`experience`, `process`, `system`) and deployment target (`cloudhub`, `cloudhub2`, `rtf`, `standalone`) to tailor findings. + +**MuleSoft `copilot-instructions.md` scaffold template** β€” a ready-to-use template at `com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md` documents Mule runtime version, API-led layer, connector conventions, error handling, logging, and MUnit expectations for workspace-level custom instructions. + +**Deepened agent definitions** β€” `mulesoft-agent.agent.md` and `mulesoft-engineer.agent.md` expanded from ~65 to ~110 lines with concrete rules for API-led architecture (Experience/Process/System hierarchy), error handling contract, DataWeave standards, logging discipline, connector governance, and security non-negotiables. + +**Enriched tool descriptions** β€” six tool `description` fields updated with Mulesoft-specific patterns, common findings, and when/how to invoke each tool: `mule_project_scan`, `mule_code_review`, `mule_security_review`, `api_schema_analyze`, `munit_validate_flow_tests`, `munit_full_review`. + +### Changed + +- `CONSOLE_CONTEXT_ENABLED` default changed from `false` to `true` β€” Anypoint Studio developers rely on the console for runtime error context; enabling by default eliminates a manual preference step. +- `WORKSPACE_CONTEXT_ENABLED` default changed from `false` to `true` β€” multi-project Mule workspaces frequently reference shared RAML specs and DataWeave modules across projects; workspace context is essential for correct cross-project reasoning. +- MuleSoft MCP preference page **Region** field changed from free-text to a dropdown (`PROD_US`, `PROD_EU`, `PROD_CA`, `PROD_JP`) to prevent typos that cause silent MCP registration failures. A status guidance label is added below the field. +- `summarize_mule_project` tool output now includes `hasApikit`, `hasSecureProperties`, `hasBatchJob`, `hasReconnectForever`, `log4j2RootLevel`, `hasDbPoolConfig`, `hasHttpRequestTimeout`, scheduler-triggered flows, flows with correlationId set, and a diagnostic count. + +## 0.17.1 ### Added - ℹ️ Prepare for the [upcoming usage-based billing](https://github.blog/news-insights/company-news/github-copilot-is-moving-to-usage-based-billing/). We strongly recommend upgrading to this version as soon as possible. [#203](https://github.com/microsoft/copilot-for-eclipse/issues/203) - Add Copilot preference for a chat's custom instructions loading. [#62](https://github.com/microsoft/copilot-for-eclipse/issues/62), contributed by [@travkin79](https://github.com/travkin79) diff --git a/MULESOFT_COPILOT_GUIDE.md b/MULESOFT_COPILOT_GUIDE.md new file mode 100644 index 00000000..1669f736 --- /dev/null +++ b/MULESOFT_COPILOT_GUIDE.md @@ -0,0 +1,632 @@ +# MuleSoft Copilot Guide β€” GitHub Copilot for Anypoint Studio + +This guide documents every MuleSoft-specific capability in the Copilot for Eclipse plugin, explains why each feature exists, and shows how to use it effectively. + +--- + +## Table of Contents + +1. [Quick Start](#quick-start) +2. [Slash Commands Reference](#slash-commands-reference) +3. [Smart Console Error Parsing](#smart-console-error-parsing) +4. [Project Scanning β€” What the AI Sees](#project-scanning--what-the-ai-sees) +5. [Agent Behavior and Built-in Rules](#agent-behavior-and-built-in-rules) +6. [Workspace Custom Instructions Template](#workspace-custom-instructions-template) +7. [MuleSoft MCP Server Setup](#mulesoft-mcp-server-setup) +8. [Preferences and Defaults](#preferences-and-defaults) +9. [Tool Reference](#tool-reference) +10. [Typical Workflows](#typical-workflows) + +--- + +## Quick Start + +1. Open a Mule 4 project in Anypoint Studio. +2. Open the Copilot chat panel. +3. Type `/` to see all available MuleSoft slash commands. +4. Run your first review: + ``` + /mule-code-review + ``` +5. The agent will automatically scan the project, run a code review, and return prioritized findings with recommended fixes. + +For best results, add a [`copilot-instructions.md`](#workspace-custom-instructions-template) to your project before starting. + +--- + +## Slash Commands Reference + +All commands run in **Agent Mode** and invoke the appropriate local Mule tools automatically. You do not need to specify a project path β€” the agent uses the open project in the workspace. + +### `/mule-code-review` + +**Purpose**: General code quality review across all Mule XML, DataWeave, properties, and MUnit suites. + +**What it checks**: +- Flow naming conventions (camelCase verb-noun: `getCustomerByIdFlow`) +- Sub-flows vs. private flows β€” when each is appropriate +- On Error Propagate presence on all HTTP-facing flows +- On Error Continue misuse as a catch-all +- Correlation ID set at HTTP Listener source, propagated in outbound headers +- Global config duplication and hardcoded values +- Property placeholder externalization (`${secure::}` for secrets) +- DataWeave output type declarations and null-safe field access +- APIkit route coverage vs. RAML/OpenAPI spec endpoints + +**Example invocation**: +``` +/mule-code-review +``` + +--- + +### `/mule-security-review` + +**Purpose**: Security vulnerability scan for Mule-specific threats. + +**What it checks**: +- Hardcoded credentials in XML attributes or property files +- Missing `${secure::}` prefix on sensitive properties +- Missing Mule Secure Configuration Properties module dependency +- **SQL injection**: DB connector queries with string concatenation instead of `:variable` syntax +- **XPath injection**: XPath expressions with user-controlled input +- **XXE**: XML parsing without secure parser settings +- Insecure HTTP Listener endpoints (missing TLS) +- Outbound HTTP Request configs with `insecure="true"` or no TLS context +- Missing authentication mechanism on HTTP-facing flows +- Full payload logging (exposes PII or secrets) +- API policy gaps (rate limiting, authentication enforcement) + +**Example invocation**: +``` +/mule-security-review +``` + +--- + +### `/mule-performance-review` + +**Purpose**: Performance and scalability analysis. + +**What it checks**: +- DataWeave transforms that materialize large payloads β€” recommends `streaming=true` +- Nested `map` over large collections (O(nΒ²)) β€” recommends `groupBy` + lookup +- Inline regex compiled per iteration β€” recommends pre-compile to a variable +- Batch job `maxRecordsPerBlock` sizing (default 100 is rarely optimal) +- `maxConcurrency` on CPU-intensive vs. IO-bound flows +- DB connector N+1 query patterns (`` inside ``) +- Missing `minPoolSize`/`maxPoolSize` on DB connector config +- Missing `responseTimeout` on HTTP Request configs +- Missing `reconnect-forever` (infinite retry blocks a thread) +- `` without `maxRetries` +- Caching opportunities for repeated calls to static/slow-changing APIs + +**Example invocation**: +``` +/mule-performance-review +``` + +--- + +### `/deployment-readiness` + +**Purpose**: Pre-deployment checklist tailored to the target platform. + +**What it checks (all platforms)**: +- `mule-artifact.json` present with correct `minMuleVersion` +- Maven plugin version compatible with target runtime +- All MUnit tests passing +- No hardcoded secrets +- Environment-specific properties externalized to `config-.yaml` +- Log level set to INFO or WARN in production log4j2.xml +- Health endpoint present and returning `{"status": "UP"}` + +**Additional checks by platform**: +- **CloudHub 1.0**: Worker sizing, persistent queues, static IP +- **CloudHub 2.0 / Runtime Fabric**: Resource requests/limits, replica count (β‰₯2 for HA), liveness probes +- **On-premises**: Cluster config, JVM heap sizing, process user permissions + +**Example invocation**: +``` +/deployment-readiness +``` +The agent will ask which platform you are targeting if it cannot infer it. + +--- + +### `/api-spec-review` + +**Purpose**: API contract governance and APIkit compatibility. + +**What it checks**: +- Required metadata (title, version, baseUri/servers) +- All request/response bodies use named schemas (no inline anonymous objects) +- Examples present and valid against their schema +- Error responses defined (400, 401, 404, 500 at minimum) +- Security scheme defined AND applied to all non-public endpoints +- URL versioning (e.g., `/v1/`) present and consistent +- APIkit route coverage: every spec endpoint has a router flow; no orphaned router flows +- RAML library fragments pinned to specific Exchange versions + +**Example invocation**: +``` +/api-spec-review +``` + +--- + +### `/generate-munit-tests` + +**Purpose**: Generate comprehensive MUnit test coverage. + +**What it generates**: +- Happy path, invalid input (400), connector failure simulation, error-response contract β€” per flow +- Async flow testing via `munit:run-flow` direct invocation (not scheduler-dependent) +- Batch job tests: unit-test steps in isolation + integration-test with fixture dataset +- Scatter-gather tests: each route mocked independently; one test with a failing route +- Transactional rollback tests: second connector mocked to fail, first write verified rolled back +- Choice router branch tests: one test per when-condition + otherwise +- Correlation ID assertion in Logger calls + +After generating, the agent runs `munit_validate_flow_tests` and `run_mule_maven_tests` to confirm correctness. + +**Example invocation**: +``` +/generate-munit-tests +``` +Specify a flow name to generate tests for a single flow: +``` +/generate-munit-tests for getCustomerByIdFlow +``` + +--- + +### `/dataweave-best-practices` + +**Purpose**: DataWeave-specific quality review. There is no other platform with DataWeave β€” this prompt covers Mule-unique patterns. + +**What it checks**: +- Output type declaration on every script +- Null-safe access via `default` operator on all optional fields +- Functional style: `map`, `filter`, `reduce`, `groupBy` over imperative `if/else` +- Nested `map` performance (O(nΒ²)) β€” recommends `groupBy` indexing +- Inline regex compiled inside `map` β€” recommends pre-compile to variable +- Unnecessary serialization round-trips (`write` β†’ `read`) +- Streaming appropriateness for large unknown-size payloads +- Repeated DW logic across transforms β€” recommends extracting to `.dwl` module +- Missing input/output type documentation on complex scripts + +**Example invocation**: +``` +/dataweave-best-practices +``` + +--- + +### `/connector-governance` + +**Purpose**: Connector version, configuration, and authentication audit. + +**What it checks**: +- Connector versions vs. Mule runtime compatibility matrix +- Deprecated connectors: HTTP v1, File Connector v1, Scripting Module (Groovy/JS/Python) +- Redundant connector configs (two `db:config` pointing to the same DB) +- DB connector: `minPoolSize`, `maxPoolSize`, `maxWait` present +- HTTP connector: `responseTimeout`, `connectionIdleTimeout` present +- `reconnect-forever` (blocks threads in production) +- `reconnect` without finite `count` and `frequency` +- `until-successful` without `maxRetries` and `millisBetweenRetries` +- Authentication method consistency (same upstream service should use same auth type) +- API key passed as query parameter instead of header + +**Example invocation**: +``` +/connector-governance +``` + +--- + +### `/logging-observability` + +**Purpose**: Logging quality and Anypoint Monitoring setup. + +**What it checks**: +- Correlation ID set at HTTP Listener from `X-Correlation-ID` header (fallback `uuid()`) +- Correlation ID propagated in all outbound HTTP Request headers +- Correlation ID included in all Logger calls in error handlers +- Log levels: INFO for flow entry/exit, ERROR for error handlers, DEBUG disabled in prod +- Structured JSON format in Logger `message` expressions (not string concatenation) +- Full payload logged at INFO (flag as PII/performance risk) +- Passwords/tokens/API keys logged without masking +- log4j2.xml root level (DEBUG/TRACE in production is flagged automatically by the scan) +- Anypoint Monitoring enabled for the deployed application + +**Example invocation**: +``` +/logging-observability +``` + +--- + +### `/error-handling-contract` + +**Purpose**: Dedicated review of error handling quality. Consolidates rules scattered across code review, logging, and security prompts. + +**What it checks**: +- Every HTTP-facing flow has per-flow `` (not just a global handler) +- `` vs. `` used correctly +- All handlers have typed error matchers (`type="HTTP:CONNECTIVITY"` etc.) +- Correlation ID logged in every error handler +- Error responses return consistent JSON shape: `{ "code", "message", "correlationId" }` +- HTTP status codes correct: 400/401/403/404/500/503 (never always 500) +- Raw Mule stack traces not returned to API consumers + +Uses `flowErrorHandlerTypes` data from the scan (typed/catch-all/none per flow) to focus on the worst offenders first. + +**Example invocation**: +``` +/error-handling-contract +``` + +--- + +### `/api-led-architecture-review` + +**Purpose**: Validates whether the project correctly implements its API-led layer. + +**Layer definitions enforced**: +- **Experience API**: consumer-facing, calls Process APIs only +- **Process API**: orchestrates System APIs, no direct backend connectors +- **System API**: one backend system, no business logic, no calls to other APIs + +**What it checks**: +- Correct call direction (Experience β†’ Process β†’ System, never upward) +- System API with multiple outbound HTTP connectors (likely doing Process API work) +- Process or Experience API with backend connector configs (DB, Salesforce) β€” System API layer missing +- Flow naming inconsistent with declared layer +- API spec vocabulary reflects the layer (business terms for xAPI/pAPI, backend terms for sAPI) + +**Example invocation**: +``` +/api-led-architecture-review +``` + +--- + +### `/batch-job-review` + +**Purpose**: Dedicated review for Mule batch processing. + +**What it checks**: +- `` structure: `batch:input`, at least one `batch:step`, `batch:on-complete` +- `maxRecordsPerBlock` sizing (default 100 β€” flags if not explicitly set) +- `` with explicit `size` and `streaming="true"` for large sets +- Step-level error handling: On Error Continue for per-record failures, On Error Propagate to abort +- On Complete phase logging: `loadedRecords`, `successfulRecords`, `failedRecords` +- DataWeave inside steps: nested maps, inline regex, unnecessary serialization +- Recommended test fixture: valid record + failing record + boundary record + +Only runs when `hasBatchJob=true` in the scan output. + +**Example invocation**: +``` +/batch-job-review +``` + +--- + +### `/async-flow-review` + +**Purpose**: Reviews scheduler flows, async scopes, VM queues, and Anypoint MQ listeners. + +**What it checks**: +- Scheduler flows: error handler present, correlation ID generated with `uuid()`, INFO logger at entry +- `` scopes: business-critical logic inside async (data loss risk), no internal error handler +- VM connector: no corresponding listener for published messages, no ack/nack strategy +- Anypoint MQ: `acknowledgementMode` and explicit `ack`/`nack` usage, dead-letter queue configured +- Thread pool impact of heavy DataWeave inside async operations +- Graceful shutdown: `shutdownTimeout` appropriate for message processing time + +Uses `schedulerFlows` data from the scan to target the right flows. + +**Example invocation**: +``` +/async-flow-review +``` + +--- + +## Smart Console Error Parsing + +When you use `@console` in the chat, the plugin automatically enriches the console output if it contains a Mule runtime exception. + +**Before enrichment** (raw dump sent to AI): +``` +2026-05-22 10:14:32,401 ERROR ... [MuleContainerSystemClassLoader] ... +org.mule.runtime.api.exception.DefaultMuleException: HTTP POST on resource 'https://...' failed: Connection refused. +org.mule.extension.http.api.error.HttpRequestFailedException + at org.mule.extension.http.internal... + ... 47 more lines of stack trace +``` + +**After enrichment** (what the AI receives first): +``` +[Mule Error Summary] +Error type: HTTP:CONNECTIVITY +Flow: processOrderFlow +Root cause: Connection refused to https://api.inventory.internal:8081 +Component: HTTP_Request @ processOrderFlow/processors/3 + +[Console Context] +Console: Anypoint Studio Console +Truncated: no +Output: + +... full raw output ... + +``` + +The AI immediately knows what failed, in which flow, and what the root cause is β€” without spending context tokens parsing 50 lines of Java stack trace. + +**How to use**: Simply prefix your message with `@console`: +``` +@console Why is my flow failing? +``` + +--- + +## Project Scanning β€” What the AI Sees + +Every slash command starts with `mule_project_scan`. Understanding what the scan returns helps you interpret agent responses. + +### Standard Scan Output + +| Field | What it contains | +|---|---| +| `runtimeVersion` | Mule 4.x version from `mule-artifact.json` or `pom.xml` | +| `flows` | All flow names across all XML files | +| `subFlows` | All sub-flow names | +| `globalConfigs` | Global config element names (connector configs, error handlers) | +| `connectors` | Connector artifact IDs from `pom.xml` | +| `munitFiles` | MUnit suite file paths | +| `apiSpecFiles` | RAML/OpenAPI/WSDL/XSD files found in `src/main/resources` | +| `deploymentPlugins` | CloudHub/RTF Maven plugin detected in `pom.xml` | +| `propertyPlaceholders` | All `${...}` placeholder keys found in XML | + +### New Fields (Round 2) + +| Field | Why it matters | +|---|---| +| `hasApikit` | APIkit router detected β€” spec/route coverage checks apply | +| `hasSecureProperties` | Secure Configuration Properties module present | +| `hasBatchJob` | `` detected β€” `/batch-job-review` is relevant | +| `schedulerFlows` | Names of scheduler-triggered flows β€” need `uuid()` correlation ID, direct MUnit invocation | +| `hasReconnectForever` | `reconnect-forever` detected β€” production reliability risk | +| `log4j2RootLevel` | Root log level from `log4j2.xml` β€” DEBUG/TRACE is flagged | +| `hasDbPoolConfig` | DB connector has `minPoolSize`/`maxPoolSize` configured | +| `hasHttpRequestTimeout` | HTTP Request connector has `responseTimeout` configured | +| `flowsWithCorrelationId` | Flows where a `set-variable variableName="correlationId"` is detected | +| `flowErrorHandlerTypes` | Per-flow: `typed`, `catch-all`, or `none` | +| `untilSuccessfulWithoutMaxRetries` | `` without `maxRetries` β€” runaway retry risk | + +### Automatic Diagnostics + +The scan automatically flags these issues without requiring a separate review command: + +| Severity | Condition | Recommendation | +|---|---|---| +| Medium | `reconnect-forever` detected | Replace with finite `reconnect` | +| Medium | `until-successful` without `maxRetries` | Set `maxRetries` and `millisBetweenRetries` | +| Medium | `log4j2RootLevel` is DEBUG or TRACE | Set root level to INFO before deploying | +| Medium | DB connector present, no pool config | Add `minPoolSize`, `maxPoolSize`, `maxWait` | +| Medium | HTTP connector present, no timeout | Add `responseTimeout` to `http:request-config` | + +--- + +## Agent Behavior and Built-in Rules + +The Mulesoft agent (`mulesoft-agent.agent.md`) enforces these rules automatically without needing to be asked: + +### API-Led Architecture +``` +Experience API β†’ Process API β†’ System API +``` +- Never suggests a System API calling a Process or Experience API +- Flags when a project's connectors don't match its declared layer +- Uses layer-appropriate naming conventions in generated flows + +### Error Handling Contract +- All HTTP-facing generated flows include `` with typed matchers +- Error handlers always log `correlationId`, `flow.name`, `error.errorType`, `error.description` +- Error responses use `{ "code", "message", "correlationId" }` with correct HTTP status codes +- Never returns a raw Mule error description as the API response body + +### DataWeave Standards +- Always reads `mule_read_transform` before modifying a Transform Message +- Generated DataWeave always declares `output` type +- Optional field accesses use `default` operator +- Large-payload transforms use `streaming=true` + +### Logging Discipline +- Generated Logger components at INFO include `correlationId` and `flowName` +- No generated code logs `payload` at INFO level +- Structured JSON format in all generated Logger `message` expressions +- DEBUG logging disabled in production + +### Connector Governance +- Suggests connector versions compatible with the project's `minMuleVersion` +- Never suggests `reconnect-forever` +- Generated DB configs include pool settings; generated HTTP configs include `responseTimeout` + +--- + +## Workspace Custom Instructions Template + +For the best Copilot experience in a Mule project, add a `copilot-instructions.md` to the project. This file is read automatically on every chat turn. + +**Setup**: +1. Copy the template from the plugin: + ``` + com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md + ``` +2. Place it at `.github/copilot-instructions.md` in your Mule project root. +3. Fill in the placeholders (runtime version, layer, deployment target, connectors). + +**What the template includes**: +- Mule runtime version and API-led layer declaration +- Flow and sub-flow naming conventions +- Secure property usage rules +- Error handling expectations +- Logging format and level expectations +- MUnit coverage requirements +- Connector versions in use + +Copilot reads this file automatically and tailors all suggestions to match your project's conventions. + +--- + +## MuleSoft MCP Server Setup + +The MuleSoft MCP server enables Copilot to interact directly with the Anypoint Platform β€” generating flows, deploying applications, searching Exchange, and running DataWeave scripts. + +### Prerequisites + +- [Node.js](https://nodejs.org/) installed and on `PATH` for the Studio process +- A [MuleSoft Connected App](https://docs.mulesoft.com/access-management/connected-apps-overview) in Anypoint Platform with appropriate scopes + +### Configuration Steps + +1. **Open preferences**: **Window β†’ Preferences β†’ Copilot β†’ MuleSoft MCP** +2. **Enable**: Check **Enable MuleSoft MCP Server registration** +3. **Enter credentials**: + - **Client ID**: Your Connected App client ID + - **Client Secret**: Stored in Eclipse secure storage + - **Region**: Select from dropdown β€” `PROD_US`, `PROD_EU`, `PROD_CA`, or `PROD_JP` +4. **Save**: Click **Apply and Close** +5. **Approve**: Open **Preferences β†’ Copilot β†’ MCP Servers** and approve the `mulesoft` server entry + +> **Environment variable fallback**: Leave fields blank to use `ANYPOINT_CLIENT_ID`, `ANYPOINT_CLIENT_SECRET`, and `ANYPOINT_REGION` from the Studio process environment. + +### Available MCP Tools + +Once registered, the following MuleSoft tools become available in Agent Mode: + +- `mulesoft/create_mule_project` β€” scaffold a new Mule 4 project +- `mulesoft/generate_mule_flow` β€” generate flows from a description +- `mulesoft/run_local_mule_application` β€” run the app locally +- `mulesoft/generate_api_spec` / `implement_api_spec` / `mock_api_spec` +- `mulesoft/dataweave_run_script_tool` β€” test DataWeave scripts +- `mulesoft/generate_or_modify_munit_test` β€” create or update MUnit suites +- `mulesoft/deploy_mule_application` / `update_mule_application` +- `mulesoft/search_asset` β€” search Anypoint Exchange +- `mulesoft/manage_api_instance_policy` β€” apply policies +- `mulesoft/get_platform_insights` β€” application monitoring data +- And 15+ more tools for Exchange assets, Flex Gateway, and Runtime Fabric + +--- + +## Preferences and Defaults + +### Changed Defaults + +| Preference | Old default | New default | Why | +|---|---|---|---| +| Console context (`@console`) | `false` | `true` | Mule developers rely on console for runtime error context | +| Workspace context (`@workspace`) | `false` | `true` | Multi-project Mule workspaces reference shared RAML specs and DataWeave modules | + +### Key Preferences + +- **Window β†’ Preferences β†’ Copilot β†’ General**: + - *Workspace context*: Enables `@workspace` for cross-project RAML/DWL references + - *Console context*: Enables `@console` for runtime error analysis (now on by default) + +- **Window β†’ Preferences β†’ Copilot β†’ Chat**: + - *Enable sub-agents*: Allows the agent to spawn sub-agents for parallel tasks + - *Maximum agent requests*: Default 25; increase for complex project-wide reviews + +- **Window β†’ Preferences β†’ Copilot β†’ MuleSoft MCP**: + - Region dropdown (PROD_US/EU/CA/JP) β€” replaces the free-text field + +--- + +## Tool Reference + +These tools are available in Copilot Agent Mode and are invoked automatically by slash commands. You can also ask the agent to use them explicitly. + +| Tool | What it returns | +|---|---| +| `mule_project_scan` | Full project metadata + all new diagnostic fields (see [Project Scanning](#project-scanning--what-the-ai-sees)) | +| `mule_code_review` | Code quality findings by severity with file/line references | +| `mule_security_review` | Security findings classified critical/high/medium/low | +| `mule_read_transform` | DataWeave scripts from a specific Transform Message component | +| `mule_write_transform` | Updates DataWeave in a Transform Message (requires confirmation) | +| `api_schema_analyze` | Governance diagnostics for RAML, OpenAPI, WSDL, XSD, AsyncAPI, GraphQL | +| `munit_validate_flow_tests` | MUnit structure validation: namespaces, config, assertions, mock coverage | +| `munit_full_review` | Full suite audit: scenario coverage, assertion quality, test duplication | +| `munit_improvement_suggestions` | Cadence recommendations for happy/negative/edge/failure scenarios | +| `summarize_mule_project` | Human-readable text summary including all new boolean flags and diagnostic count | +| `get_mule_project_errors` | Live project diagnostics from Studio problem markers | +| `run_mule_maven_tests` | Runs Maven tests; supports `mavenProfile` (`-P dev`), MUnit filtering (`-Dmunit.test=`), multi-module (`-pl`) | + +--- + +## Typical Workflows + +### New Feature: End-to-End Workflow + +``` +1. /api-spec-review β€” validate the RAML/OpenAPI contract first +2. /mule-code-review β€” check flow structure, error handlers, naming +3. /mule-security-review β€” scan for injection risks and credential exposure +4. /generate-munit-tests β€” generate tests for the new flow +5. /deployment-readiness β€” confirm all checklist items before push +``` + +### Investigating a Production Error + +``` +1. Paste the console output using @console + β†’ Copilot extracts [Mule Error Summary] automatically +2. Ask: "Why is this error happening and how do I fix it?" +3. Follow up with /error-handling-contract to improve error handling +``` + +### Pre-Merge Code Review + +``` +1. /mule-code-review β€” code quality and architecture +2. /mule-security-review β€” security vulnerabilities +3. /munit_full_review β€” test quality and coverage gaps +``` + +### DataWeave Optimization + +``` +1. Ask: "Review the Transform Message in getOrdersFlow for performance" + β†’ Agent uses mule_read_transform automatically +2. /dataweave-best-practices β€” comprehensive DW quality review +``` + +### Batch Job Implementation + +``` +1. /batch-job-review β€” review existing structure or get guidance for new batch job +2. /generate-munit-tests β€” generate batch step unit tests and integration test fixture +3. /mule-performance-review β€” verify block sizing and aggregator config +``` + +--- + +## File Locations Reference + +| File | Purpose | +|---|---| +| `com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md` | Main MuleSoft agent definition β€” API-led, error handling, DataWeave, logging, connector governance rules | +| `com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md` | Engineer agent variant β€” same rules, slightly different framing | +| `com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md` | Project-level custom instructions scaffold β€” copy to `.github/copilot-instructions.md` | +| `com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/*.prompt.md` | All 13 slash command prompt definitions | +| `com.microsoft.copilot.eclipse.ui/src/.../chat/services/MuleConsoleParser.java` | Mule error parser that enriches `@console` output | +| `com.microsoft.copilot.eclipse.ui/src/.../chat/tools/MuleProjectAnalyzer.java` | Core Mule project scanner and review engine | +| `com.microsoft.copilot.eclipse.ui/src/.../chat/tools/MuleProjectAnalysis.java` | Data model for scan results | +| `com.microsoft.copilot.eclipse.anypoint/src/.../MuleSoftMcpPreferencePage.java` | MCP credentials preference page (region dropdown) | +| `com.microsoft.copilot.eclipse.ui/src/.../preferences/CopilotPreferenceInitializer.java` | Default preferences (console and workspace context now `true`) | diff --git a/README.md b/README.md index 31cddede..b8ecf1ae 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GitHub Copilot for Eclipse +# GitHub Copilot for Eclipse (MuleSoft Anypoint Studio) GitHub Copilot for Eclipse brings AI-assisted coding to the Eclipse IDE with these core capabilities: @@ -7,6 +7,7 @@ GitHub Copilot for Eclipse brings AI-assisted coding to the Eclipse IDE with the - **Agent Mode** for conversational help and more autonomous, project-aware assistance. - **Model Context Protocol (MCP)** integration to connect Copilot with external tools and services. - **Advanced Agentic Capabilities** include Custom Agents, Isolated Subagents, and Plan Agent, with more agentic capabilities coming soon. +- **Anypoint Studio Integration** connects Copilot with the official MuleSoft MCP server for AI-assisted Mule application development directly in Anypoint Studio. ## Usage-based billing support @@ -84,6 +85,107 @@ MCP support enables integrating external tools and services into Copilot workflo For other available features in Eclipse, see the [Copilot feature matrix](https://docs.github.com/en/copilot/reference/copilot-feature-matrix?tool=eclipse). +## Anypoint Studio Integration + +This plugin includes a dedicated integration for [MuleSoft Anypoint Studio](https://www.mulesoft.com/platform/studio), enabling AI-assisted Mule 4 application development through Copilot's Agent Mode and MCP. + +### Slash Commands for MuleSoft Workflows + +Type `/` in the Copilot chat to see all available MuleSoft slash commands. Each command runs a pre-configured agent workflow tailored to a specific Mule development task: + +| Command | What it does | +|---|---| +| `/mule-code-review` | Reviews flow naming, error handlers, global configs, DataWeave, and APIkit route coverage | +| `/mule-security-review` | Detects hardcoded secrets, SQL/XPath injection risks, missing TLS, authentication gaps, and policy coverage | +| `/mule-performance-review` | Identifies DataWeave materialization, batch sizing issues, missing connector pooling, N+1 queries, and caching opportunities | +| `/deployment-readiness` | Platform-specific deployment checklist for CloudHub, Runtime Fabric, or standalone; includes health endpoints and smoke tests | +| `/api-spec-review` | Validates RAML/OpenAPI governance, APIkit router binding, security scheme implementation, and error response contracts | +| `/generate-munit-tests` | Generates MUnit tests covering happy path, error path, connector failure, async flows, batch jobs, and scatter-gather patterns | +| `/dataweave-best-practices` | Reviews Transform Message components for null safety, streaming, functional patterns, and module reuse | +| `/connector-governance` | Audits connector versions against the Mule runtime, flags deprecated connectors, missing pooling, and retry strategy gaps | +| `/logging-observability` | Reviews correlation ID propagation, structured log format, log levels, PII exposure, and Anypoint Monitoring setup | +| `/error-handling-contract` | Audits On Error Propagate/Continue usage, typed matchers, correlation ID in error handlers, and HTTP status codes | +| `/api-led-architecture-review` | Validates API-led layer assignment (Experience/Process/System), call direction rules, and connector placement | +| `/batch-job-review` | Reviews batch job structure, block sizing, aggregator config, step error handling, and On Complete logging | +| `/async-flow-review` | Reviews scheduler flows, VM/MQ listener patterns, async scope usage, and graceful shutdown configuration | + +### Smart Console Error Parsing + +When you use `@console` in the chat with Anypoint Studio console output that contains a Mule runtime exception, the plugin automatically prepends a structured summary before the raw output: + +``` +[Mule Error Summary] +Error type: HTTP:CONNECTIVITY +Flow: get-customer-main +Root cause: Connection refused to https://api.example.com:443 +Component: HTTP_Request @ get-customer-main/processors/2 +``` + +This lets the AI immediately understand the error context without parsing through Java stack trace noise. + +### Project Scanning + +The `mule_project_scan` tool (invoked automatically by most slash commands) returns rich Mule-specific analysis including: + +- Runtime version, flows, sub-flows, global configs, connectors, MUnit suite coverage +- Per-flow error handler classification: `typed` (has type matchers), `catch-all` (no type), or `none` +- Flows where a correlation ID is set at the source +- Scheduler-triggered flows (require different MUnit strategy from HTTP flows) +- `log4j2RootLevel` β€” flags DEBUG/TRACE in production +- DB connection pool config presence and HTTP request timeout presence +- `reconnect-forever` and `until-successful` without `maxRetries` detection +- `hasBatchJob`, `hasApikit`, `hasSecureProperties` presence flags + +### MuleSoft MCP Server + +The plugin can automatically register the official [`mulesoft-mcp-server`](https://www.npmjs.com/package/mulesoft-mcp-server) as an MCP server in Copilot Agent Mode. This unlocks MuleSoft-native tools such as: + +- Creating and validating Mule projects +- Generating and implementing API specs (RAML/OAS) +- Running DataWeave scripts and generating sample data +- Creating, running, and reviewing MUnit tests +- Searching Anypoint Exchange assets +- Deploying to CloudHub, Runtime Fabric, and Flex Gateway + +### Configuring MuleSoft MCP + +1. In Anypoint Studio, open **Window β†’ Preferences β†’ Copilot β†’ MuleSoft MCP**. +2. Check **Enable MuleSoft MCP Server registration**. +3. Enter your **Anypoint Platform Connected App** credentials: + - **Client ID** – the connected app client ID. + - **Client Secret** – stored securely in Eclipse secure storage. + - **Region** *(optional)* – select from the dropdown: `PROD_US`, `PROD_EU`, `PROD_CA`, or `PROD_JP`. Defaults to `PROD_US` when left blank. +4. Click **Apply and Close**, then approve the `mulesoft` server entry in **Preferences β†’ Copilot β†’ MCP Servers**. + +> **Tip:** If any field is left blank, the integration falls back to the `ANYPOINT_CLIENT_ID`, `ANYPOINT_CLIENT_SECRET`, and `ANYPOINT_REGION` environment variables set in the Studio process environment. + +### Workspace-Level Custom Instructions + +For best results, add a `copilot-instructions.md` file to your Mule project at `.github/copilot-instructions.md`. This file is read automatically on every chat turn and tells Copilot about your project's runtime version, API-led layer, connector conventions, error handling strategy, and MUnit expectations. + +A ready-to-use template is bundled at: +``` +com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md +``` +Copy it to `.github/copilot-instructions.md` in your Mule project and fill in the placeholders. + +### MuleSoft Agent Template + +A pre-built agent template (`mulesoft-agent.agent.md`) is bundled with the plugin. It configures a specialized Copilot agent scoped to Mule 4 development, automatically wiring the relevant MuleSoft MCP tools alongside built-in tools. The agent enforces: + +- **API-led architecture** β€” Experience β†’ Process β†’ System call direction rules +- **Error handling contract** β€” typed On Error Propagate handlers, correlation ID logging, consistent error response shape +- **DataWeave standards** β€” output type declaration, null-safe access, streaming for large payloads +- **Logging discipline** β€” structured JSON format, correlation IDs at INFO, no PII in logs +- **Connector governance** β€” version compatibility, pooling config, no `reconnect-forever` in production + +### Prerequisites for MuleSoft MCP + +- [Node.js](https://nodejs.org/) (includes `npx`) must be available on the `PATH` used by the Anypoint Studio process. +- A [MuleSoft Connected App](https://docs.mulesoft.com/access-management/connected-apps-overview) with the necessary scopes for the tools you intend to use. + +--- + ## Privacy and responsible use We follow responsible practices in accordance with our @@ -121,6 +223,8 @@ trademarks or logos is subject to and must follow Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. +For clarity: product and company names used in this repository (including but not limited to "GitHub", "GitHub Copilot", "Microsoft", "MuleSoft", and "Anypoint Studio") are the trademarks or registered trademarks of their respective owners. The presence of these names or logos in this project does not imply endorsement, sponsorship, or formal affiliation by the trademark owners. + ## License Copyright (c) Microsoft Corporation. All rights reserved. diff --git a/base.target b/base.target index a6fc6705..51be7098 100644 --- a/base.target +++ b/base.target @@ -15,6 +15,13 @@ + + + + + + + @@ -74,4 +81,4 @@ - \ No newline at end of file + diff --git a/com.microsoft.copilot.eclipse.anypoint.feature/feature.xml b/com.microsoft.copilot.eclipse.anypoint.feature/feature.xml new file mode 100644 index 00000000..2a9f35f5 --- /dev/null +++ b/com.microsoft.copilot.eclipse.anypoint.feature/feature.xml @@ -0,0 +1,32 @@ + + + + + GitHub Copilot packaged with the Eclipse dependencies needed for MuleSoft Anypoint Studio. + + + + + + + + + + + + + + + + + diff --git a/com.microsoft.copilot.eclipse.anypoint.feature/pom.xml b/com.microsoft.copilot.eclipse.anypoint.feature/pom.xml new file mode 100644 index 00000000..b665630b --- /dev/null +++ b/com.microsoft.copilot.eclipse.anypoint.feature/pom.xml @@ -0,0 +1,13 @@ + + 4.0.0 + + com.microsoft.copilot.eclipse + github-copilot-for-eclipse + ${copilot-plugin-version} + + com.microsoft.copilot.eclipse.anypoint.feature + eclipse-feature + ${base.name} :: Anypoint Studio Feature + diff --git a/com.microsoft.copilot.eclipse.anypoint/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.anypoint/META-INF/MANIFEST.MF new file mode 100644 index 00000000..ae06433e --- /dev/null +++ b/com.microsoft.copilot.eclipse.anypoint/META-INF/MANIFEST.MF @@ -0,0 +1,19 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: GitHub Copilot Anypoint Studio Integration +Bundle-SymbolicName: com.microsoft.copilot.eclipse.anypoint;singleton:=true +Bundle-Version: 0.18.0.qualifier +Bundle-Vendor: GitHub Copilot +Bundle-RequiredExecutionEnvironment: JavaSE-17 +Bundle-ActivationPolicy: lazy +Automatic-Module-Name: com.microsoft.copilot.eclipse.anypoint +Export-Package: com.microsoft.copilot.eclipse.anypoint +Require-Bundle: com.microsoft.copilot.eclipse.ui;bundle-version="0.15.0", + com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", + org.eclipse.core.runtime;bundle-version="[3.30.0,4.0.0)", + org.eclipse.ui;bundle-version="3.205.0", + org.eclipse.jface;bundle-version="3.30.0", + org.eclipse.swt, + org.eclipse.equinox.security, + com.google.gson;bundle-version="2.10.1" +Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)" diff --git a/com.microsoft.copilot.eclipse.anypoint/build.properties b/com.microsoft.copilot.eclipse.anypoint/build.properties new file mode 100644 index 00000000..bb5334bf --- /dev/null +++ b/com.microsoft.copilot.eclipse.anypoint/build.properties @@ -0,0 +1,6 @@ +source.. = src +output.. = target/classes +bin.includes = META-INF/,\ + .,\ + plugin.xml,\ + templates/ diff --git a/com.microsoft.copilot.eclipse.anypoint/plugin.xml b/com.microsoft.copilot.eclipse.anypoint/plugin.xml new file mode 100644 index 00000000..aa45ad57 --- /dev/null +++ b/com.microsoft.copilot.eclipse.anypoint/plugin.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/com.microsoft.copilot.eclipse.anypoint/pom.xml b/com.microsoft.copilot.eclipse.anypoint/pom.xml new file mode 100644 index 00000000..aee795cd --- /dev/null +++ b/com.microsoft.copilot.eclipse.anypoint/pom.xml @@ -0,0 +1,13 @@ + + 4.0.0 + + com.microsoft.copilot.eclipse + github-copilot-for-eclipse + ${copilot-plugin-version} + + com.microsoft.copilot.eclipse.anypoint + eclipse-plugin + ${base.name} :: Anypoint Studio Integration + diff --git a/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpConfiguration.java b/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpConfiguration.java new file mode 100644 index 00000000..d8b391c4 --- /dev/null +++ b/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpConfiguration.java @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.anypoint; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +/** + * Builds the MCP server configuration consumed by Copilot's MCP registration extension point. + */ +public final class MuleSoftMcpConfiguration { + public static final String SERVER_NAME = "mulesoft"; + public static final String ANYPOINT_CLIENT_ID = "ANYPOINT_CLIENT_ID"; + public static final String ANYPOINT_CLIENT_SECRET = "ANYPOINT_CLIENT_SECRET"; + public static final String ANYPOINT_REGION = "ANYPOINT_REGION"; + + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + + private MuleSoftMcpConfiguration() { + } + + /** + * Builds a JSON configuration for the official MuleSoft MCP server. + */ + public static String buildJson(String clientId, String clientSecret, String region) { + if (isBlank(clientId) || isBlank(clientSecret)) { + return ""; + } + + Map env = new LinkedHashMap<>(); + env.put(ANYPOINT_CLIENT_ID, clientId.trim()); + env.put(ANYPOINT_CLIENT_SECRET, clientSecret.trim()); + if (!isBlank(region)) { + env.put(ANYPOINT_REGION, region.trim()); + } + + Map server = new LinkedHashMap<>(); + server.put("command", "npx"); + server.put("args", List.of("-y", "mulesoft-mcp-server", "start")); + server.put("env", env); + + Map servers = new LinkedHashMap<>(); + servers.put(SERVER_NAME, server); + + Map root = new LinkedHashMap<>(); + root.put("servers", servers); + return GSON.toJson(root); + } + + static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpPreferencePage.java b/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpPreferencePage.java new file mode 100644 index 00000000..11c1b60a --- /dev/null +++ b/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpPreferencePage.java @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.anypoint; + +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.PreferencePage; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Text; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.IWorkbenchPreferencePage; + +import com.microsoft.copilot.eclipse.ui.preferences.PreferencePageUtils; + +/** + * Preferences for the MuleSoft MCP bridge used by Anypoint Studio. + */ +public class MuleSoftMcpPreferencePage extends PreferencePage implements IWorkbenchPreferencePage { + public static final String ID = "com.microsoft.copilot.eclipse.anypoint.preferences.MuleSoftMcpPreferencePage"; + + private static final String[] REGION_OPTIONS = { "", "PROD_US", "PROD_EU", "PROD_CA", "PROD_JP" }; + + private Button enabledButton; + private Text clientIdText; + private Text clientSecretText; + private Combo regionCombo; + + @Override + public void init(IWorkbench workbench) { + setDescription("Configure the official MuleSoft MCP Server for Copilot Agent Mode in Anypoint Studio."); + } + + @Override + protected Control createContents(Composite parent) { + Composite container = new Composite(parent, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + container.setBackground(parent.getBackground()); + + enabledButton = new Button(container, SWT.CHECK); + enabledButton.setText("Enable MuleSoft MCP Server registration"); + enabledButton.setLayoutData(spanTwoColumns()); + + createLabel(container, "Client ID:"); + clientIdText = createText(container, SWT.BORDER); + + createLabel(container, "Client Secret:"); + clientSecretText = createText(container, SWT.BORDER | SWT.PASSWORD); + + createLabel(container, "Region:"); + regionCombo = new Combo(container, SWT.DROP_DOWN | SWT.READ_ONLY); + regionCombo.setItems(REGION_OPTIONS); + regionCombo.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + + Label statusLabel = new Label(container, SWT.WRAP); + statusLabel.setText("Status: MCP server registration is managed in Preferences β†’ Copilot β†’ MCP Servers. " + + "After saving, approve the mulesoft server entry in that page for it to become active."); + statusLabel.setLayoutData(spanTwoColumns()); + useParentBackground(statusLabel); + + Label note = new Label(container, SWT.WRAP); + note.setText("Credentials are stored in Eclipse secure storage. If a field is blank, the integration also checks " + + "ANYPOINT_CLIENT_ID, ANYPOINT_CLIENT_SECRET, and ANYPOINT_REGION from the Studio process environment."); + note.setLayoutData(spanTwoColumns()); + useParentBackground(note); + + loadSettings(); + return container; + } + + @Override + public boolean performOk() { + String selectedRegion = regionCombo.getSelectionIndex() > 0 ? regionCombo.getText() : ""; + MuleSoftMcpSettings.save(enabledButton.getSelection(), clientIdText.getText(), clientSecretText.getText(), + selectedRegion); + if (enabledButton.getSelection() && MuleSoftMcpConfiguration.buildJson(clientIdText.getText(), + clientSecretText.getText(), selectedRegion).isEmpty()) { + MessageDialog.openWarning(getShell(), "MuleSoft MCP Server", + "MuleSoft MCP is enabled, but Client ID and Client Secret are empty. " + + "Set them here or in the Studio process environment before approving the MCP server."); + } + return true; + } + + private void loadSettings() { + enabledButton.setSelection(MuleSoftMcpSettings.isEnabled()); + clientIdText.setText(MuleSoftMcpSettings.getClientId()); + clientSecretText.setText(MuleSoftMcpSettings.getClientSecret()); + String savedRegion = MuleSoftMcpSettings.getRegion(); + regionCombo.select(0); + for (int i = 0; i < REGION_OPTIONS.length; i++) { + if (REGION_OPTIONS[i].equals(savedRegion)) { + regionCombo.select(i); + break; + } + } + } + + private static void createLabel(Composite container, String text) { + Label label = new Label(container, SWT.NONE); + label.setText(text); + useParentBackground(label); + } + + private static Text createText(Composite container, int style) { + Text text = new Text(container, style); + text.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + PreferencePageUtils.styleTextInput(text); + return text; + } + + private static GridData spanTwoColumns() { + GridData gridData = new GridData(SWT.FILL, SWT.CENTER, true, false); + gridData.horizontalSpan = 2; + return gridData; + } + + private static void useParentBackground(Control control) { + if (control != null && !control.isDisposed() && control.getParent() != null + && !control.getParent().isDisposed()) { + control.setBackground(control.getParent().getBackground()); + } + } +} diff --git a/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpRegistrationProvider.java b/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpRegistrationProvider.java new file mode 100644 index 00000000..b0ea10b0 --- /dev/null +++ b/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpRegistrationProvider.java @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.anypoint; + +import java.util.concurrent.CompletableFuture; + +import com.microsoft.copilot.eclipse.ui.extensions.IMcpRegistrationProvider; + +/** + * Registers the official MuleSoft MCP server with Copilot when configured by the user. + */ +public class MuleSoftMcpRegistrationProvider implements IMcpRegistrationProvider { + + @Override + public CompletableFuture getMcpServerConfigurations() { + if (!MuleSoftMcpSettings.isEnabled()) { + return CompletableFuture.completedFuture(""); + } + String json = MuleSoftMcpConfiguration.buildJson(MuleSoftMcpSettings.getClientId(), + MuleSoftMcpSettings.getClientSecret(), MuleSoftMcpSettings.getRegion()); + return CompletableFuture.completedFuture(json); + } +} diff --git a/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpSettings.java b/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpSettings.java new file mode 100644 index 00000000..8a2c6f23 --- /dev/null +++ b/com.microsoft.copilot.eclipse.anypoint/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpSettings.java @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.anypoint; + +import org.eclipse.equinox.security.storage.ISecurePreferences; +import org.eclipse.equinox.security.storage.SecurePreferencesFactory; +import org.eclipse.equinox.security.storage.StorageException; + +import com.microsoft.copilot.eclipse.core.CopilotCore; + +/** + * Secure preference access for MuleSoft MCP settings. + */ +public final class MuleSoftMcpSettings { + private static final String NODE = "/com.microsoft.copilot.eclipse.anypoint/mulesoftMcp"; + private static final String KEY_ENABLED = "enabled"; + private static final String KEY_CLIENT_ID = "clientId"; + private static final String KEY_CLIENT_SECRET = "clientSecret"; + private static final String KEY_REGION = "region"; + + private MuleSoftMcpSettings() { + } + + /** + * Returns whether the MuleSoft MCP server registration is enabled. + */ + public static boolean isEnabled() { + try { + return node().getBoolean(KEY_ENABLED, false); + } catch (StorageException e) { + CopilotCore.LOGGER.error("Failed to read MuleSoft MCP enabled preference", e); + return false; + } + } + + /** + * Stores whether the MuleSoft MCP server registration is enabled. + */ + public static void setEnabled(boolean enabled) { + try { + node().putBoolean(KEY_ENABLED, enabled, false); + } catch (StorageException e) { + CopilotCore.LOGGER.error("Failed to write MuleSoft MCP enabled preference", e); + } + flush(); + } + + public static String getClientId() { + return getSecure(KEY_CLIENT_ID, System.getenv(MuleSoftMcpConfiguration.ANYPOINT_CLIENT_ID)); + } + + public static String getClientSecret() { + return getSecure(KEY_CLIENT_SECRET, System.getenv(MuleSoftMcpConfiguration.ANYPOINT_CLIENT_SECRET)); + } + + public static String getRegion() { + return getSecure(KEY_REGION, System.getenv(MuleSoftMcpConfiguration.ANYPOINT_REGION)); + } + + /** + * Saves all MuleSoft MCP settings. + */ + public static void save(boolean enabled, String clientId, String clientSecret, String region) { + ISecurePreferences node = node(); + try { + node.putBoolean(KEY_ENABLED, enabled, false); + } catch (StorageException e) { + CopilotCore.LOGGER.error("Failed to write MuleSoft MCP enabled preference", e); + } + putSecure(KEY_CLIENT_ID, clientId); + putSecure(KEY_CLIENT_SECRET, clientSecret); + putSecure(KEY_REGION, region); + flush(); + } + + private static String getSecure(String key, String fallback) { + try { + return node().get(key, fallback == null ? "" : fallback); + } catch (StorageException e) { + CopilotCore.LOGGER.error("Failed to read MuleSoft MCP secure preference: " + key, e); + return fallback == null ? "" : fallback; + } + } + + private static void putSecure(String key, String value) { + try { + node().put(key, value == null ? "" : value.trim(), true); + } catch (StorageException e) { + CopilotCore.LOGGER.error("Failed to write MuleSoft MCP secure preference: " + key, e); + } + } + + private static void flush() { + try { + node().flush(); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to flush MuleSoft MCP secure preferences", e); + } + } + + private static ISecurePreferences node() { + return SecurePreferencesFactory.getDefault().node(NODE); + } +} diff --git a/com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md b/com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md new file mode 100644 index 00000000..8c57bd17 --- /dev/null +++ b/com.microsoft.copilot.eclipse.anypoint/templates/copilot-instructions-mule-template.md @@ -0,0 +1,53 @@ +# Copilot Instructions β€” MuleSoft Mule 4 Project + + + +## Project Context + +- **Mule runtime version**: 4.x (update to match mule-artifact.json `minMuleVersion`) +- **API-led layer**: Experience API / Process API / System API *(choose one)* +- **Deployment target**: CloudHub 1.0 / CloudHub 2.0 / Runtime Fabric / On-premises *(choose one)* +- **Primary connector(s)**: HTTP, Database, Salesforce, Anypoint MQ *(list the connectors in use)* + +## Coding Conventions + +- Flow names use camelCase verb-noun format: `getCustomerByIdFlow`, `processOrderFlow`. +- Sub-flow names use the same convention with a descriptive qualifier: `validateOrderSubFlow`. +- All environment-specific values use `${property.name}` placeholders resolved from `config-.yaml`. +- All sensitive values (passwords, tokens, keys) use `${secure::property.name}` backed by the Mule Secure Configuration Properties module. +- DataWeave scripts declare `output` type on every transform. Optional fields use `default` operator: `payload.name default ""`. + +## Error Handling + +- Every HTTP-facing flow has an `` with typed matchers (e.g., `type="HTTP:CONNECTIVITY"`). +- All error handlers log `correlationId`, `flow.name`, `error.errorType`, and `error.description` in structured JSON format. +- Error responses follow this shape: `{ "code": "...", "message": "...", "correlationId": "..." }` with the correct HTTP status code (400/401/403/404/500/503). +- Correlation IDs are set at the HTTP Listener from the `X-Correlation-ID` header (fallback `uuid()`) and propagated in all outbound HTTP Request headers. + +## Logging + +- INFO level: flow entry/exit with `correlationId`, `flowName`, and key input identifiers. No full payload logging at INFO. +- DEBUG level: connector call details and DataWeave diagnostics. Disabled in production (log4j2.xml root level = INFO). +- Never log passwords, tokens, API keys, or PII fields without masking. +- Log format: structured JSON via DataWeave `output application/json` in Logger `message` expressions. + +## Testing + +- Every public flow (HTTP Listener, Scheduler, MQ listener) has MUnit tests covering: happy path, invalid input (400), connector failure simulation, and error-response contract. +- All external connectors are mocked with `munit-tools:mock-when` by `doc:name`. Sub-flows are not mocked. +- Each `` router branch has its own test, including the otherwise branch. +- Maven command to run all tests: `mvn test` +- Maven command to run a single suite: `mvn test -Dmunit.test=.xml` + +## Connector Preferences + +- HTTP connector version: +- Database connector: +- Anypoint MQ: +- *(Add other connectors and their versions here)* + +## API Specification + +- API spec format: RAML 1.0 / OpenAPI 3.0 *(choose one)* +- API spec location: `src/main/resources/api/api.raml` *(or update path)* +- Security scheme: OAuth 2.0 Client Credentials / API Key / None *(choose one)* diff --git a/com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md b/com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md new file mode 100644 index 00000000..c10745ad --- /dev/null +++ b/com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md @@ -0,0 +1,110 @@ +--- +description: MuleSoft development agent for Anypoint Studio projects +tools: + - mule_project_scan + - api_schema_analyze + - mule_code_review + - mule_security_review + - mule_read_transform + - mule_write_transform + - mule_read_dwl_file + - mule_write_dwl_file + - mule_optimize_dwl + - munit_validate_flow_tests + - munit_full_review + - munit_improvement_suggestions + - summarize_mule_project + - get_mule_project_errors + - run_mule_maven_tests + - mulesoft/create_mule_project + - mulesoft/generate_mule_flow + - mulesoft/run_local_mule_application + - mulesoft/create_api_spec_project + - mulesoft/generate_api_spec + - mulesoft/implement_api_spec + - mulesoft/mock_api_spec + - mulesoft/search_asset + - mulesoft/dataweave_run_script_tool + - mulesoft/dataweave_create_sample_data + - mulesoft/dataweave_get_project_metadata + - mulesoft/dataweave_get_module_metadata + - mulesoft/dataweave_create_documentation + - mulesoft/generate_or_modify_munit_test + - mulesoft/deploy_mule_application + - mulesoft/update_mule_application + - mulesoft/list_applications + - mulesoft/create_and_manage_api_instances + - mulesoft/list_api_instances + - mulesoft/manage_api_instance_policy + - mulesoft/create_and_manage_assets + - mulesoft/get_reuse_metrics + - mulesoft/get_flex_gateway_policy_example + - mulesoft/manage_flex_gateway_policy_project + - mulesoft/create_install_runtime_fabric + - mulesoft/upgrade_runtime_fabric + - mulesoft/delete_runtime_fabric + - mulesoft/create_and_run_task + - mulesoft/get_platform_insights +--- + +Use this agent for MuleSoft and Anypoint Studio work. Prefer MuleSoft MCP tools for API specs, Mule flow generation, +DataWeave, Exchange assets, governance, deployment, policy, monitoring, and agent-network tasks. Use local Studio tools +to inspect Mule XML, understand project structure, read problem markers, and run Maven or MUnit validation. + +Always run `mule_project_scan` before editing flows or making claims about project structure. Treat Mule XML as +executable integration configuration β€” namespace-aware, connector-version-sensitive, and environment-parameterized. + +## API-Led Architecture +Mulesoft applications follow a three-layer architecture. Preserve boundaries; never let a lower layer call a higher layer: +- **Experience API**: Consumer-facing, returns consumer-friendly payloads, handles protocol translation. Calls Process APIs only. +- **Process API**: Orchestrates business logic across multiple System APIs. Handles transformations, error aggregation, and routing. Does not call Experience APIs. +- **System API**: Thin adapter over a single backend system (SAP, Salesforce, DB). Exposes backend capabilities in a standard REST/SOAP contract. Does not call other System APIs. + +When generating flows: identify which layer the request belongs to, confirm the target layer's connectors are appropriate, and enforce that routing logic (flow-refs, HTTP calls) respects the layer hierarchy. + +## Global Configuration Rules +- One global config per logical target: one `` per upstream host, one `` per logical database. +- All sensitive values must use `${secure::property.name}`. All environment-specific values (hosts, ports, paths) must use `${property.name}`. Never hardcode either in XML. +- Connector versions must be compatible with the project's `minMuleVersion` in `mule-artifact.json`. Do not suggest connector versions that are newer than what the declared runtime supports. + +## Error Handling Contract +- Every flow exposed via HTTP Listener or a message source must have an `` error handler with at least one typed error (`type="HTTP:CONNECTIVITY"`, `type="DB:QUERY_EXECUTION"`, etc.). Global catch-all error handlers are a fallback, not a substitute. +- `` is only appropriate when the flow must complete successfully despite the error (e.g., optional enrichment that fails gracefully). Default to ``. +- Error handlers must log: `correlationId`, `flow.name`, `error.errorType`, and `error.description`. Never log full payload in error handlers. +- All HTTP-facing error handlers must return a consistent JSON error shape: `{ "code": "...", "message": "...", "correlationId": "..." }` with the appropriate HTTP status (400, 401, 404, 500 β€” never always 500). +- Correlation ID must be set at the HTTP Listener (from `X-Correlation-ID` header or `uuid()` if absent) and propagated in all outbound calls and log messages. + +## Standalone DataWeave Module Files +- Use `mule_read_dwl_file` to read `.dwl` module files in `src/main/resources/dwl/` before editing or reviewing them. +- Run `mule_optimize_dwl` before rewriting a DWL module to surface performance issues (nested maps, inline regex, round-trip serialization), null-safety gaps, and missing output declarations. +- Use `mule_write_dwl_file` to update a `.dwl` module after confirming the optimized script with the user. +- Always run `mulesoft/dataweave_run_script_tool` after writing to validate the updated script against representative sample data. + +## DataWeave Best Practices +- Always run `mule_read_transform` before modifying any Transform Message component to understand the current script and output type. +- Output directive is mandatory: every script must start with `%dw 2.0` and declare `output application/json` (or appropriate type). +- Null-safe access required: use `default` operator on all optional field accesses (`payload.field default ""`). +- Use `map`, `filter`, `reduce`, `groupBy` over imperative `if/else` loops. Flag nested `map` over large collections β€” pre-index with `groupBy` instead. +- For payloads over 1 MB, use `output application/json streaming=true`. Streaming transforms cannot use `sizeOf()`, `[-1]`, or `reverse()`. +- Repeated DataWeave logic across multiple transforms should be extracted to a `.dwl` module in `src/main/resources/dwl/`. +- After writing a transform, validate with `mule_write_transform` only after confirming the target element (`ee:set-payload`, `ee:set-attributes`, or `ee:set-variable`) and running diagnostics or Maven tests. + +## Logging Discipline +- Log at INFO on entry and exit of public flows. Log message must include: `correlationId`, `flowName`, and key input identifier (e.g., order ID, customer ID). Never log full payloads at INFO. +- Log at DEBUG for connector call details and DataWeave diagnostics. DEBUG must be disabled in production. +- Log at ERROR in every `` with: `correlationId`, `flowName`, `errorType`, `errorDescription`. +- Never log passwords, tokens, API keys, or PII fields. For unavoidable cases, mask: `email[0..2] ++ "***"`. +- Use structured JSON log format in Logger `message` expressions β€” not string concatenation. + +## Connector Governance +- All connector versions must align with the Mule runtime compatibility matrix. Flag deprecated connectors (HTTP v1, File Connector v1, Scripting Module for Groovy). +- Database connector global configs must set `minPoolSize`, `maxPoolSize`, and `maxWait`. HTTP Request configs must set `responseTimeout`. +- All outbound HTTP Request configs must use HTTPS and have TLS context configured. Never set `insecure="true"`. +- Retry strategy: use `reconnect` with finite `count` and `frequency`. Flag `reconnect-forever` in production deployments. + +## MUnit Testing +- Every public flow requires tests covering: happy path, negative/invalid input, connector failure simulation, and error-response contract. +- Use `munit:mock-when` on all external connector calls by `doc:name`. Do not mock sub-flow calls. +- Each `` router branch requires its own test, including the otherwise branch. +- Run `munit_validate_flow_tests` after generating tests to confirm namespace, config, execution, assertion, and coverage completeness. +- Use `mulesoft/generate_or_modify_munit_test` to create or update MUnit suites with full scenario coverage. diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/pom.xml b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/pom.xml index 117088d0..1f14125f 100644 --- a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/pom.xml +++ b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/pom.xml @@ -23,6 +23,10 @@ ${tycho-version} true + + org.eclipse.tycho + tycho-packaging-plugin + - \ No newline at end of file + diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.x64/pom.xml b/com.microsoft.copilot.eclipse.core.agent.linux.x64/pom.xml index ee9bd89b..abc90aa3 100644 --- a/com.microsoft.copilot.eclipse.core.agent.linux.x64/pom.xml +++ b/com.microsoft.copilot.eclipse.core.agent.linux.x64/pom.xml @@ -23,6 +23,10 @@ ${tycho-version} true + + org.eclipse.tycho + tycho-packaging-plugin + - \ No newline at end of file + diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/pom.xml b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/pom.xml index a66d50df..9a042bfa 100644 --- a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/pom.xml +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/pom.xml @@ -23,6 +23,10 @@ ${tycho-version} true + + org.eclipse.tycho + tycho-packaging-plugin + - \ No newline at end of file + diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/pom.xml b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/pom.xml index 08d287fb..51f1f9b0 100644 --- a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/pom.xml +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/pom.xml @@ -23,6 +23,10 @@ ${tycho-version} true + + org.eclipse.tycho + tycho-packaging-plugin + - \ No newline at end of file + diff --git a/com.microsoft.copilot.eclipse.core.agent.win32/pom.xml b/com.microsoft.copilot.eclipse.core.agent.win32/pom.xml index 5ae84249..f7d25ee7 100644 --- a/com.microsoft.copilot.eclipse.core.agent.win32/pom.xml +++ b/com.microsoft.copilot.eclipse.core.agent.win32/pom.xml @@ -23,6 +23,10 @@ ${tycho-version} true + + org.eclipse.tycho + tycho-packaging-plugin + - \ No newline at end of file + diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java index c87449dc..9b3b1c4b 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java @@ -60,6 +60,14 @@ void testGetCopilotDefaultTabCharAndSizeForUnknownLanguage() { assertEquals(PREFERENCE_DEFAULT_TAB_SIZE, formatOptionProvider.getTabSize(mockFile)); } + @Test + void testGetCopilotDefaultTabCharAndSizeForDataWeave() { + when(mockFile.getFileExtension()).thenReturn("dwl"); + + assertTrue(formatOptionProvider.useSpace(mockFile)); + assertEquals(PREFERENCE_DEFAULT_TAB_SIZE, formatOptionProvider.getTabSize(mockFile)); + } + @Test void testGetCopilotDefaultTabCharAndSizeForNoExtensionFile() { when(mockFile.getFileExtension()).thenReturn(null); diff --git a/com.microsoft.copilot.eclipse.core/copilot-agent/copy-binaries.js b/com.microsoft.copilot.eclipse.core/copilot-agent/copy-binaries.js index 4437ace8..f146f746 100644 --- a/com.microsoft.copilot.eclipse.core/copilot-agent/copy-binaries.js +++ b/com.microsoft.copilot.eclipse.core/copilot-agent/copy-binaries.js @@ -73,8 +73,10 @@ function main() { try { copyFile(platform.source, platform.target); } catch (error) { - console.error(`Failed to copy ${platform.name} distribution:`, error.message); - process.exit(1); + // Don't fail the whole install if a platform-specific binary isn't present + // (npm will only install the packages matching the current host or optional deps). + console.warn(`Skipping ${platform.name} distribution: ${error.message}`); + continue; } } } diff --git a/com.microsoft.copilot.eclipse.core/copilot-agent/package.json b/com.microsoft.copilot.eclipse.core/copilot-agent/package.json index 06f8e77a..076db52c 100644 --- a/com.microsoft.copilot.eclipse.core/copilot-agent/package.json +++ b/com.microsoft.copilot.eclipse.core/copilot-agent/package.json @@ -19,4 +19,4 @@ "@github/copilot-language-server-linux-x64": "1.488.0", "@github/copilot-language-server-linux-arm64": "1.488.0" } -} +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.core/plugin.xml b/com.microsoft.copilot.eclipse.core/plugin.xml index 8557c57b..f5065057 100644 --- a/com.microsoft.copilot.eclipse.core/plugin.xml +++ b/com.microsoft.copilot.eclipse.core/plugin.xml @@ -25,5 +25,10 @@ languageId="dummy" contentType="com.microsoft.copilot.eclipse.dummy"> + + diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/AuthStatusManager.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/AuthStatusManager.java index 21284a15..2810ecb5 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/AuthStatusManager.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/AuthStatusManager.java @@ -4,6 +4,7 @@ package com.microsoft.copilot.eclipse.core; import java.util.Objects; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ExecutionException; @@ -89,10 +90,19 @@ public CopilotStatusResult signInConfirm(String userCode) throws InterruptedExce * @throws InterruptedException if the sign out process is interrupted */ public CopilotStatusResult signOut() throws InterruptedException, ExecutionException { - CopilotStatusResult result = connection.signOut().get(); - setCopilotUser(result.getUser()); - setCopilotStatus(result.getStatus()); - return result; + try { + CopilotStatusResult result = connection.signOut().get(); + setCopilotUser(result.getUser()); + setCopilotStatus(result.getStatus()); + return result; + } catch (CancellationException e) { + // LS connection is in a broken/uninitialized state; clear local auth state so the user is signed out. + setCopilotUser(null); + setCopilotStatus(CopilotStatusResult.NOT_SIGNED_IN); + CopilotStatusResult notSignedIn = new CopilotStatusResult(); + notSignedIn.setStatus(CopilotStatusResult.NOT_SIGNED_IN); + return notSignedIn; + } } /** diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java index 176bc245..d83ecfbe 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java @@ -26,6 +26,8 @@ private Constants() { public static final String PROXY_KERBEROS_SP = "proxyKerberosSp"; public static final String GITHUB_ENTERPRISE = "githubEnterprise"; public static final String WORKSPACE_CONTEXT_ENABLED = "workspaceContextEnabled"; + public static final String CONSOLE_CONTEXT_ENABLED = "consoleContextEnabled"; + public static final String TRANSFORM_CONTEXT_ENABLED = "transformContextEnabled"; public static final String SUB_AGENT_ENABLED = "subAgentEnabled"; public static final String AGENT_MAX_REQUESTS = "agentMaxRequests"; public static final String ENABLE_SKILLS = "enableSkills"; diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/BuiltInChatModeManager.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/BuiltInChatModeManager.java index 079628bf..b7624930 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/BuiltInChatModeManager.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/BuiltInChatModeManager.java @@ -5,8 +5,10 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; +import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.chat.service.BuiltInChatModeService; /** @@ -60,10 +62,25 @@ public BuiltInChatMode getBuiltInModeById(String id) { } /** - * Reloads built-in chat modes from the LSP API. This should be called when the user switches - * to ensure the latest modes are available for the current user context. + * Reloads built-in chat modes from the LSP API synchronously. Blocks the calling thread until + * the LSP responds. Prefer {@link #reloadModesAsync()} to avoid blocking the UI thread. */ public void reloadModes() { loadModesSync(); } + + /** + * Reloads built-in chat modes from the LSP API asynchronously. Safe to call from the UI thread. + * The returned future completes once the modes list has been updated. + */ + public CompletableFuture reloadModesAsync() { + return service.loadBuiltInModes().thenAccept(modes -> { + if (modes != null && !modes.isEmpty()) { + this.builtInModes = new CopyOnWriteArrayList<>(modes); + } + }).exceptionally(ex -> { + CopilotCore.LOGGER.error("Failed to reload built-in modes asynchronously", ex); + return null; + }); + } } \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/completion/CompletionProvider.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/completion/CompletionProvider.java index d437aa9f..2731dd4f 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/completion/CompletionProvider.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/completion/CompletionProvider.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.concurrent.CancellationException; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -204,6 +205,8 @@ private IStatus runCompletion(IProgressMonitor monitor) { } this.completions = result.getCompletions(); + } catch (CancellationException e) { + return Status.CANCEL_STATUS; } catch (InterruptedException e) { return Status.CANCEL_STATUS; } catch (ExecutionException e) { diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java index 1e34f256..61637b76 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java @@ -151,6 +151,17 @@ public class CopilotEventConstants { */ public static final String TOPIC_MCP_SERVER_STATE_CHANGE = TOPIC_MCP + "SERVER_STATE_CHANGE"; + /** + * Event when the MCP registration extension-point manager has finished detecting and verifying + * the contributed servers (either at startup or after a policy toggle / user approval) and the + * approved-servers JSON for the language server is ready to be pushed. + * + *

Payload (via {@code IEventBroker.DATA}): the approved-servers JSON {@code String}, or + * {@code null} when no extension-contributed servers are approved. + */ + public static final String TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED = TOPIC_MCP + + "EXTENSION_POINT_REGISTRATION_COMPLETED"; + /** * Event when NES suggestion is accepted. */ @@ -176,4 +187,14 @@ public class CopilotEventConstants { * conversation templates on receipt. */ public static final String TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES = TOPIC_CHAT + "DID_CHANGE_CUSTOMIZATION_FILES"; + + /** + * Event when automatic conversation compression starts. + */ + public static final String TOPIC_CHAT_COMPRESSION_STARTED = TOPIC_CHAT + "COMPRESSION_STARTED"; + + /** + * Event when automatic conversation compression completes. + */ + public static final String TOPIC_CHAT_COMPRESSION_COMPLETED = TOPIC_CHAT + "COMPRESSION_COMPLETED"; } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java index 8b06a164..1a79fba2 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java @@ -26,6 +26,7 @@ public class FormatOptionProvider { private static final String CPP_LANGUAGE_ID = "cpp"; private static final String[] CPP_LANGUAGE_EXTENSIONS = new String[] { "cpp", "c++", "cc", "cp", "cxx", "h", "h++", "hh", ".hpp", ".hxx", ".inc", ".inl", ".ipp", ".tcc", ".tpp" }; + private static final String DATAWEAVE_LANGUAGE_ID = "dataweave"; private static final boolean DEFAULT_USE_SPACE = LanguageFormatReader.PREFERENCE_DEFAULT_TAB_CHAR.equals("space"); private static final int DEFAULT_TAB_SIZE = LanguageFormatReader.PREFERENCE_DEFAULT_TAB_SIZE; @@ -46,6 +47,7 @@ private void initializeLanguageExtensionToIdMap() { for (String extension : CPP_LANGUAGE_EXTENSIONS) { languageExtensionToIdMap.put(extension, CPP_LANGUAGE_ID); } + languageExtensionToIdMap.put("dwl", DATAWEAVE_LANGUAGE_ID); } /** diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java index 1e40287a..da7aaee9 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java @@ -43,6 +43,8 @@ import com.microsoft.copilot.eclipse.core.lsp.mcp.McpOauthRequest; import com.microsoft.copilot.eclipse.core.lsp.mcp.McpRuntimeLog; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompressionCompletedParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompressionStartedParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationCapabilities; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationContextParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.CurrentEditorContext; @@ -366,6 +368,26 @@ public void onQuotaWarning(QuotaWarningParams params) { } } + /** + * Notify when automatic conversation compression starts. + */ + @JsonNotification("$/copilot/compressionStarted") + public void onCompressionStarted(CompressionStartedParams params) { + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_STARTED, params); + } + } + + /** + * Notify when automatic conversation compression completes. + */ + @JsonNotification("$/copilot/compressionCompleted") + public void onCompressionCompleted(CompressionCompletedParams params) { + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_COMPLETED, params); + } + } + /** * Reads the contents and stats of a file given its URI. */ diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionCompletedParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionCompletedParams.java new file mode 100644 index 00000000..28869330 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionCompletedParams.java @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Parameters for the {@code $/copilot/compressionCompleted} notification sent by the language server when automatic + * conversation compression finishes. The {@code contextInfo} field is optional and may be {@code null}. + */ +public record CompressionCompletedParams( + String conversationId, + int archivedPartitionId, + int newPartitionId, + int summaryLength, + int turnCount, + int durationMs, + ContextSizeInfo contextInfo) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionStartedParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionStartedParams.java new file mode 100644 index 00000000..247826bd --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionStartedParams.java @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Parameters for the {@code $/copilot/compressionStarted} notification sent by the language server when automatic + * conversation compression begins. + */ +public record CompressionStartedParams(String conversationId, int partitionId, String reason) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java index e9412e2f..5423c6d3 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.core.lsp.protocol; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -17,6 +18,10 @@ public class CopilotAgentSettings { @SerializedName("maxToolCallingLoop") private int agentMaxRequests; private boolean enableSkills; + private boolean autoCompress; + + @SerializedName("toolConfirmAutoApprove") + private List toolConfirmAutoApprove = List.of(); private String transcriptDirectory; @@ -32,6 +37,12 @@ public class CopilotAgentSettings { private ToolsSettings tools; + /** + * Setting shape expected by the Copilot language server for MCP tool auto-approval configuration. + */ + public record McpAutoApproveSetting(String serverName, boolean isServerAllowed, List allowedTools) { + } + /** Nested tools settings matching CLS agent.tools structure. */ public static class ToolsSettings { private TerminalSettings terminal; @@ -178,10 +189,26 @@ public String getTranscriptDirectory() { return transcriptDirectory; } + public boolean isAutoCompress() { + return autoCompress; + } + + public void setAutoCompress(boolean autoCompress) { + this.autoCompress = autoCompress; + } + public void setTranscriptDirectory(String transcriptDirectory) { this.transcriptDirectory = transcriptDirectory; } + public List getToolConfirmAutoApprove() { + return toolConfirmAutoApprove; + } + + public void setToolConfirmAutoApprove(List toolConfirmAutoApprove) { + this.toolConfirmAutoApprove = toolConfirmAutoApprove == null ? List.of() : List.copyOf(toolConfirmAutoApprove); + } + public boolean isEditorHandlesAllConfirmation() { return editorHandlesAllConfirmation; } @@ -212,7 +239,7 @@ public ToolsSettings getTools() { @Override public int hashCode() { - return Objects.hash(agentMaxRequests, enableSkills, transcriptDirectory, + return Objects.hash(agentMaxRequests, enableSkills, autoCompress, toolConfirmAutoApprove, transcriptDirectory, editorHandlesAllConfirmation, autoApproveUnmatchedTerminal, autoApproveUnmatchedFileOp, tools); } @@ -229,6 +256,8 @@ public boolean equals(Object obj) { } CopilotAgentSettings other = (CopilotAgentSettings) obj; return agentMaxRequests == other.agentMaxRequests && enableSkills == other.enableSkills + && autoCompress == other.autoCompress + && Objects.equals(toolConfirmAutoApprove, other.toolConfirmAutoApprove) && Objects.equals(transcriptDirectory, other.transcriptDirectory) && editorHandlesAllConfirmation == other.editorHandlesAllConfirmation && autoApproveUnmatchedTerminal == other.autoApproveUnmatchedTerminal @@ -241,6 +270,8 @@ public String toString() { ToStringBuilder builder = new ToStringBuilder(this); builder.append("agentMaxRequests", agentMaxRequests); builder.append("enableSkills", enableSkills); + builder.append("autoCompress", autoCompress); + builder.append("toolConfirmAutoApprove", toolConfirmAutoApprove); builder.append("transcriptDirectory", transcriptDirectory); builder.append("editorHandlesAllConfirmation", editorHandlesAllConfirmation); builder.append("autoApproveUnmatchedTerminal", autoApproveUnmatchedTerminal); diff --git a/com.microsoft.copilot.eclipse.repository/category.xml b/com.microsoft.copilot.eclipse.repository/category.xml index b9b1f538..32f2077d 100644 --- a/com.microsoft.copilot.eclipse.repository/category.xml +++ b/com.microsoft.copilot.eclipse.repository/category.xml @@ -1,7 +1,7 @@ - + - - \ No newline at end of file + + diff --git a/com.microsoft.copilot.eclipse.repository/pom.xml b/com.microsoft.copilot.eclipse.repository/pom.xml index b3fcef3d..56b0a0fa 100644 --- a/com.microsoft.copilot.eclipse.repository/pom.xml +++ b/com.microsoft.copilot.eclipse.repository/pom.xml @@ -11,27 +11,35 @@ eclipse-repository ${base.name} :: Repository - - - - org.eclipse.tycho - tycho-p2-director-plugin - ${tycho-version} - - - materialize-products - - materialize-products - - - - archive-products - - archive-products - - - - - - - \ No newline at end of file + + + + org.eclipse.tycho + tycho-p2-repository-plugin + ${tycho-version} + + true + + + + org.eclipse.tycho + tycho-p2-director-plugin + ${tycho-version} + + + materialize-products + + materialize-products + + + + archive-products + + archive-products + + + + + + + diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/context-compress/context-compress.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/context-compress/context-compress.md new file mode 100644 index 00000000..e3b52987 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/context-compress/context-compress.md @@ -0,0 +1,163 @@ +# Auto Context Compression + +## Overview +Verifies the **Auto Compress** feature that automatically compresses long +conversations to keep context usage within the model's limit. Auto Compress +is always enabled (no user-facing preference). While compression is in +progress, the chat view shows a "Compacting conversation..." spinner below +the latest Copilot turn, and the context size donut updates once it +completes. + +Entry points: +- **Copilot Chat view** β†’ latest Copilot turn (spinner banner appears here). +- **Copilot Chat view** β†’ control bar **Context Size Donut** (updates after + compression completes). + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed (built from + the branch containing the staged Auto Compress changes). +- A valid GitHub Copilot subscription is active (authentication completed). +- A model that supports a finite context window is selected (so the donut and + compression can be exercised β€” e.g. Claude Sonnet 4.6 or GPT-4.1). +- The Copilot Chat view is open and visible. + +--- + +## Test Cases + +### TC-001: Compacting banner appears when compression starts + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Copilot Chat view is open with a new conversation. + +#### Steps +1. Start a conversation and drive the context usage toward the model limit β€” + for example, attach several large files and/or run multiple tool-heavy + turns until the **Context Size Donut** approaches its warning threshold + (β‰₯90 %). +2. Continue sending messages until the conversation goes over the threshold + so the server initiates automatic compression. +3. Observe the latest Copilot turn while the server processes the request. + +#### Expected Result +- A small banner appears **below the latest Copilot turn** containing: + - An animated spinner. + - The status text **"Compacting conversation..."**. +- The chat view layout refreshes so the banner is fully visible (not clipped). +- No error dialogs are shown. + +#### πŸ“Έ Key Screenshots +- [ ] **Compacting banner** β€” spinner + "Compacting conversation..." text + rendered under the latest Copilot turn. + +--- + +### TC-002: Compacting banner is dismissed and context donut updates on completion + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- TC-001 has been executed and the "Compacting conversation..." banner is + currently visible. + +#### Steps +1. Wait for the server to finish compression (typically a few seconds). +2. Observe the latest Copilot turn after compression completes. +3. Hover the **Context Size Donut** in the chat view control bar. + +#### Expected Result +- The "Compacting conversation..." banner is removed from the Copilot turn. +- The chat view scroller relayouts cleanly (no leftover blank space, no + clipping). +- The Context Size Donut updates to reflect the new, smaller token usage + (the ring's filled portion shrinks). +- The **Context Window** popup shows the post-compression token breakdown + consistent with the new total. +- The subsequent reply continues to stream normally on top of the freshly + compressed history. + +#### πŸ“Έ Key Screenshots +- [ ] **After completion** β€” Copilot turn without the banner. +- [ ] **Donut after compression** β€” Context Size Donut showing reduced usage. +- [ ] **Context Window popup** β€” Token breakdown after compression. + +--- + +### TC-003: Cancelling a chat hides the compacting banner + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- A conversation is set up so the next send will trigger compression + (as in TC-001). + +#### Steps +1. Send the message that triggers compression and wait for the + "Compacting conversation..." banner to appear. +2. While the banner is showing, click the **Cancel** (stop) button in the + chat input action bar. + +#### Expected Result +- The send button is restored from its stop/cancel state back to its normal + send state. +- The "Compacting conversation..." banner is removed from the latest Copilot + turn. +- Any buffered reply text that arrived just before cancellation is rendered + (no missing trailing line). +- The chat view relayouts cleanly so the flushed reply is fully visible. +- The user can immediately send a new message in the same conversation. + +#### πŸ“Έ Key Screenshots +- [ ] **After cancel** β€” banner gone, send button reset, any buffered reply + visible. + +--- + +### TC-004: Compacting banner only updates the matching conversation + +**Type:** `Edge Case` +**Priority:** `P2` + +#### Preconditions +- Two conversations exist in chat history: *Conversation A* (about to + trigger compression) and *Conversation B* (short, well under the limit). + +#### Steps +1. In *Conversation A*, send a message that triggers compression and wait + for the "Compacting conversation..." banner to appear. +2. Without waiting for completion, open chat history and switch to + *Conversation B*. +3. Inspect *Conversation B* for any compaction banner. +4. Switch back to *Conversation A*. + +#### Expected Result +- *Conversation B* never shows a "Compacting conversation..." banner β€” the + compaction status is scoped to *Conversation A* only. +- When you return to *Conversation A*, its state is consistent with the + compression outcome (banner cleared if it completed in the meantime; new + reply continues to stream if still in progress). +- No errors or stale spinners are left behind in either conversation. + +#### πŸ“Έ Key Screenshots +- [ ] **Conversation B during A's compaction** β€” no banner shown. + +--- + +## Screenshots Checklist +> Consolidated list of all key screenshot moments. + +- [ ] `TC-001` Compacting banner under latest Copilot turn. +- [ ] `TC-002` Copilot turn after compaction completes (banner gone). +- [ ] `TC-002` Context Size Donut after compaction (reduced usage). +- [ ] `TC-002` Context Window popup with post-compaction token breakdown. +- [ ] `TC-003` State after cancel β€” banner gone, send button reset, buffered + reply visible. +- [ ] `TC-004` Conversation B during Conversation A's compaction (no banner). diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md new file mode 100644 index 00000000..ae46796c --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md @@ -0,0 +1,194 @@ +# Support Editing and Creating Local Files Outside the Workspace + +## Overview +Verify that Copilot Agent mode can edit and create local filesystem files that are outside the Eclipse workspace, and +that those changes are surfaced through the file change summary bar with the same review actions users expect for +workspace files. + +This covers the user-visible flow for the `insert_edit_into_file` and `create_file` tools when the target is an +absolute local path rather than an Eclipse `IFile`. + +Entry points: +- Window -> Show View -> Other... -> Copilot -> Copilot Chat -> Agent mode + +Not exercised: +- Direct unit-level invocation of the file tools. +- Workspace-file edit coverage. +- Low-level compare editor APIs; this plan verifies the Compare UI through the summary bar. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and activated. +- The user is signed in to GitHub Copilot and Agent mode is available in the Copilot Chat view. +- A writable local directory outside the Eclipse workspace is available, for example: + - Windows: `%TEMP%\\copilot-eclipse-local-file-tools` + - macOS/Linux: `/tmp/copilot-eclipse-local-file-tools` +- The local directory contains an existing text file named `existing-local-file.txt` with this content: + `before local edit` +- The local directory does not contain `created-local-file.txt` before the create-file test starts. + +--- + +## 1. Edit an existing local file outside the workspace + +### TC-001: Agent edits a local file and exposes the change in the summary bar + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in a fresh or cleared conversation. +- `existing-local-file.txt` exists outside the workspace and contains `before local edit`. + +#### Steps +1. Open **Copilot Chat** from `Window -> Show View -> Other... -> Copilot -> Copilot Chat`. +2. Switch the chat mode selector to **Agent**. +3. Send a prompt that asks Agent mode to edit the external local file by absolute path, for example: + `Edit so its entire content is exactly "after local edit".` +4. If Copilot asks for tool confirmation, approve the file edit operation. +5. Wait for the Agent turn to complete. +6. Verify the file change summary bar appears in the Chat view. +7. Verify the summary bar includes `existing-local-file.txt` and displays a local filesystem path for that file. +8. Click **View Diff** for `existing-local-file.txt`. +9. Verify the Compare editor opens and shows the original content `before local edit` against the modified content + `after local edit`. +10. Close the Compare editor. + +#### Expected Result +- Copilot completes the edit without reporting that the file is outside the workspace or cannot be edited. +- The local file on disk contains `after local edit`. +- The summary bar lists `existing-local-file.txt` even though it is not an Eclipse workspace file. +- The Compare editor opens from **View Diff** and shows the correct before/after content. +- No error dialog is shown. The Eclipse error log has no uncaught exception from `insert_edit_into_file`, local file + path handling, or compare editor creation. + +#### Key Screenshots +- [ ] **Agent edit prompt** -- Copilot Chat in Agent mode with the absolute local file path visible. +- [ ] **Summary bar after local edit** -- The changed local file appears in the file change summary bar. +- [ ] **Local file Compare editor** -- The Compare editor shows `before local edit` vs. `after local edit`. + +#### Notes on failure modes +- The edit succeeds on disk but the file is missing from the summary bar -- the local `Path` change may not be tracked + by the summary bar model. +- **View Diff** does nothing or throws an error -- local files may not be routed through the local Compare input path. +- The diff baseline shows the modified content on both sides -- the original content may not have been cached before + applying the edit. + +### TC-002: Keep clears the local file change and later edits use a new baseline + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in a fresh or cleared conversation. +- `existing-local-file.txt` exists outside the workspace and contains `before local edit`. +- Agent mode has edited `existing-local-file.txt` so it contains `after local edit`, and the file is listed in the + summary bar. + +#### Steps +1. Click **Keep** for `existing-local-file.txt` in the file change summary bar. +2. Verify the file is removed from the summary bar. +3. Send another Agent prompt to edit the same absolute file path so its entire content is exactly `second local edit`. +4. Approve the edit if prompted and wait for the turn to complete. +5. Click **View Diff** for `existing-local-file.txt`. +6. Verify the Compare editor shows `after local edit` as the original content and `second local edit` as the modified + content. + +#### Expected Result +- **Keep** accepts the current local file content and clears the tracked change. +- The next edit of the same local file starts a new diff baseline from the kept content. +- The file remains accessible through the summary bar and Compare editor after the second edit. + +#### Key Screenshots +- [ ] **After Keep** -- The summary bar no longer lists the local file. +- [ ] **Second local diff** -- The Compare editor shows the kept content as the new baseline. + +### TC-003: Undo restores the original local file content + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in a fresh or cleared conversation. +- `existing-local-file.txt` exists outside the workspace and contains `before local edit`. +- Agent mode has edited `existing-local-file.txt` so it contains `after local edit`, and the file is listed in the + summary bar. + +#### Steps +1. Click **Undo** for `existing-local-file.txt` in the file change summary bar. +2. Verify the file is removed from the summary bar. +3. Open `existing-local-file.txt` from the local filesystem and inspect its content. + +#### Expected Result +- **Undo** restores the file to the original content captured before the tracked edit. +- The file is removed from the summary bar after undo completes. +- No error dialog is shown and the Eclipse error log has no local file undo exception. + +#### Key Screenshots +- [ ] **Before Undo** -- The summary bar lists the edited local file. +- [ ] **After Undo** -- The summary bar no longer lists the local file and the file content is restored. + +--- + +## 2. Create a new local file outside the workspace + +### TC-004: Agent creates a local file and opens it from the summary bar + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- `created-local-file.txt` does not exist in the local test directory. +- Copilot Chat is open in Agent mode. + +#### Steps +1. Send a prompt that asks Agent mode to create the external local file by absolute path, for example: + `Create with the exact content "created local content".` +2. If Copilot asks for tool confirmation, approve the file create operation. +3. Wait for the Agent turn to complete. +4. Verify `created-local-file.txt` exists on disk and contains `created local content`. +5. Verify the file change summary bar lists `created-local-file.txt`. +6. Click **View Diff** for `created-local-file.txt`. +7. Verify Eclipse opens `created-local-file.txt` in an editor and shows `created local content`. + +#### Expected Result +- Copilot creates the local file without requiring it to be inside an Eclipse workspace project. +- The created file is listed in the summary bar. +- The created local file can be opened from the summary bar. +- No error dialog is shown and the Eclipse error log has no local file create or editor-open exception. + +#### Key Screenshots +- [ ] **Agent create prompt** -- Copilot Chat in Agent mode with the absolute create path visible. +- [ ] **Summary bar after local create** -- The created local file appears in the file change summary bar. +- [ ] **Created local file editor** -- The external local file opens in an editor with the created content. + +### TC-005: Undo removes a created local file + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in Agent mode. +- `created-local-file.txt` does not exist in the local test directory. +- Agent mode has created `created-local-file.txt` with content `created local content`, and the file is listed in the + summary bar. + +#### Steps +1. Click **Undo** for `created-local-file.txt` in the file change summary bar. +2. Verify the file is removed from the summary bar. +3. Verify `created-local-file.txt` no longer exists on disk. + +#### Expected Result +- **Undo** for a created local file deletes the file, matching the create-file semantics. +- The summary bar no longer lists the created file after undo completes. +- No error dialog is shown and the Eclipse error log has no local file deletion exception. + +#### Key Screenshots +- [ ] **Before created-file Undo** -- The summary bar lists `created-local-file.txt`. +- [ ] **After created-file Undo** -- The summary bar is clear and the file is absent from disk. diff --git a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF index 763af993..cb7ac817 100644 --- a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF @@ -16,7 +16,9 @@ Require-Bundle: org.mockito.mockito-core;bundle-version="5.14.2", org.mockito.junit-jupiter;bundle-version="5.10.2", com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", com.microsoft.copilot.eclipse.ui;bundle-version="0.15.0", + com.microsoft.copilot.eclipse.anypoint;bundle-version="0.15.0", org.eclipse.ui;bundle-version="3.205.0", + org.eclipse.ui.console, org.eclipse.ui.ide, org.eclipse.ui.workbench.texteditor, org.eclipse.ui.editors, diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpConfigurationTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpConfigurationTest.java new file mode 100644 index 00000000..1573824f --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/anypoint/MuleSoftMcpConfigurationTest.java @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.anypoint; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Tests for MuleSoft MCP configuration generation. + */ +class MuleSoftMcpConfigurationTest { + + @Test + void buildJsonReturnsEmptyWithoutCredentials() { + assertEquals("", MuleSoftMcpConfiguration.buildJson("", "secret", "PROD_US")); + assertEquals("", MuleSoftMcpConfiguration.buildJson("client", "", "PROD_US")); + } + + @Test + void buildJsonIncludesOfficialServerCommandAndEnvironment() { + String json = MuleSoftMcpConfiguration.buildJson("client", "secret", "PROD_US"); + + assertTrue(json.contains("\"mulesoft\"")); + assertTrue(json.contains("\"command\":\"npx\"")); + assertTrue(json.contains("\"-y\"")); + assertTrue(json.contains("\"mulesoft-mcp-server\"")); + assertTrue(json.contains("\"start\"")); + assertTrue(json.contains("\"ANYPOINT_CLIENT_ID\":\"client\"")); + assertTrue(json.contains("\"ANYPOINT_CLIENT_SECRET\":\"secret\"")); + assertTrue(json.contains("\"ANYPOINT_REGION\":\"PROD_US\"")); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidgetPartialRenderTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidgetPartialRenderTest.java new file mode 100644 index 00000000..b2e0df9b --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidgetPartialRenderTest.java @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +import java.lang.reflect.Field; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatFontService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * Verifies that {@link BaseTurnWidget#appendMessage} eagerly renders trailing partial lines via + * {@code renderPartialBuffer}, while deferring rendering for code-fence prefixes and inside code + * blocks where fence detection requires a complete line. + */ +@ExtendWith(MockitoExtension.class) +class BaseTurnWidgetPartialRenderTest { + + private static final String TURN_ID = "turn-1"; + + private Shell shell; + private MockedStatic copilotUiMock; + private CopilotUi mockPlugin; + + @Mock + private ChatServiceManager mockChatServiceManager; + @Mock + private AvatarService mockAvatarService; + @Mock + private ChatFontService mockChatFontService; + + @BeforeEach + void setUp() { + lenient().when(mockChatServiceManager.getAvatarService()).thenReturn(mockAvatarService); + lenient().when(mockChatServiceManager.getChatFontService()).thenReturn(mockChatFontService); + lenient().when(mockAvatarService.getAvatarForCopilot()).thenReturn(null); + + SwtUtils.invokeOnDisplayThread(() -> { + shell = new Shell(Display.getDefault()); + copilotUiMock = mockStatic(CopilotUi.class); + mockPlugin = mock(CopilotUi.class); + copilotUiMock.when(CopilotUi::getPlugin).thenReturn(mockPlugin); + lenient().when(mockPlugin.getChatServiceManager()).thenReturn(mockChatServiceManager); + }); + } + + @AfterEach + void tearDown() { + SwtUtils.invokeOnDisplayThread(() -> { + if (copilotUiMock != null) { + copilotUiMock.close(); + copilotUiMock = null; + } + if (shell != null && !shell.isDisposed()) { + shell.dispose(); + } + }); + } + + @Test + void appendMessage_partialLineWithoutNewline_rendersImmediately() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendMessage("Hello world"); + + ChatMarkupViewer viewer = getMarkupViewer(widget); + assertNotNull(viewer, "Partial text without newline should be rendered eagerly to a text block"); + assertTrue(viewer.getTextWidget().getText().contains("Hello world"), + "Expected partial text to be visible, got: '" + viewer.getTextWidget().getText() + "'"); + }); + } + + @Test + void appendMessage_partialAfterCompleteLine_rendersBothCommittedAndPartial() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // First chunk: a complete line plus a trailing partial fragment without a newline. + widget.appendMessage("First line\nSecond "); + + ChatMarkupViewer viewer = getMarkupViewer(widget); + assertNotNull(viewer, "Text block should be created"); + String rendered = viewer.getTextWidget().getText(); + assertTrue(rendered.contains("First line"), + "Committed line should be rendered, got: '" + rendered + "'"); + assertTrue(rendered.contains("Second"), + "Trailing partial fragment should be rendered eagerly, got: '" + rendered + "'"); + + // Append more text completing the partial line β€” final render must show full content. + widget.appendMessage("line\n"); + rendered = viewer.getTextWidget().getText(); + assertTrue(rendered.contains("First line"), + "First line should still be present after appending more text, got: '" + rendered + "'"); + assertTrue(rendered.contains("Second line"), + "Completed second line should be rendered, got: '" + rendered + "'"); + }); + } + + @Test + void appendMessage_partialIsTripleBacktickFence_defersRendering() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // A confirmed code-fence prefix β€” must wait for the newline before deciding whether + // to render markup or open a code block. Otherwise the literal backticks would flash + // into the markup viewer. + widget.appendMessage("```"); + + assertNull(getField(widget, "currentTextBlock"), + "Text block must not be created for a confirmed fence prefix"); + assertNull(getField(widget, "currentCodeBlock"), + "Code block must not be created until the fence newline is received"); + }); + } + + @Test + void appendMessage_partialIsTripleBacktickWithLanguage_defersRendering() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendMessage("```java"); + + assertNull(getField(widget, "currentTextBlock"), + "Text block must not be created while a fence-with-language is still being streamed"); + assertNull(getField(widget, "currentCodeBlock"), + "Code block must not be created until the fence newline is received"); + }); + } + + @Test + void appendMessage_partialIsSingleBacktick_defersRendering() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // A lone backtick could grow into ``` on the next chunk β€” defer rendering. + widget.appendMessage("`"); + + assertNull(getField(widget, "currentTextBlock"), + "Text block must not be created for an ambiguous single backtick"); + }); + } + + @Test + void appendMessage_partialIsDoubleBacktick_defersRendering() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendMessage("``"); + + assertNull(getField(widget, "currentTextBlock"), + "Text block must not be created for an ambiguous double backtick"); + }); + } + + @Test + void appendMessage_partialBacktickFollowedByText_rendersImmediately() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // A single backtick followed by non-backtick content is inline code, not a fence β€” + // render eagerly so the user sees the streamed token immediately. + widget.appendMessage("`code"); + + ChatMarkupViewer viewer = getMarkupViewer(widget); + assertNotNull(viewer, "Inline-code partial should be rendered eagerly"); + assertTrue(viewer.getTextWidget().getText().contains("code"), + "Inline-code content should be visible, got: '" + viewer.getTextWidget().getText() + "'"); + }); + } + + @Test + void appendMessage_partialResolvesIntoFence_doesNotLeaveStaleText() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // First send some markdown so a text block exists. + widget.appendMessage("intro text\n"); + ChatMarkupViewer viewer = getMarkupViewer(widget); + assertNotNull(viewer); + assertTrue(viewer.getTextWidget().getText().contains("intro text")); + + // Then start streaming a fence β€” must NOT append "```" into the markup viewer. + widget.appendMessage("```"); + assertTrue(!viewer.getTextWidget().getText().contains("```"), + "Fence prefix must not leak into the markup viewer, got: '" + viewer.getTextWidget().getText() + "'"); + + // Complete the fence: the code block should open and the markup viewer should not gain + // the fence characters. + widget.appendMessage("java\n"); + assertNotNull(getField(widget, "currentCodeBlock"), + "Code block must open once the fence newline is received"); + assertTrue(!viewer.getTextWidget().getText().contains("```"), + "Fence characters must never appear in the markup viewer"); + }); + } + + @Test + void appendMessage_partialInsideCodeBlock_isNotRenderedAsMarkup() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // Open a code block. + widget.appendMessage("```java\n"); + assertNotNull(getField(widget, "currentCodeBlock"), + "Code block should be open after the opening fence line"); + assertNull(getField(widget, "currentTextBlock"), + "Text block should not exist while we are inside a code block"); + + // Stream a partial code line without a newline β€” the partial must NOT be rendered + // to the markup viewer, since partial code text has to go through the source viewer + // once a complete line arrives. + widget.appendMessage("int x = 1;"); + + assertNull(getField(widget, "currentTextBlock"), + "Partial text inside a code block must not create a markup text block"); + }); + } + + @Test + void appendMessage_emptyString_doesNotRender() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendMessage(""); + + assertNull(getField(widget, "currentTextBlock"), + "Empty message must be a no-op and must not create a text block"); + StringBuilder buffer = (StringBuilder) getField(widget, "messageBuffer"); + assertEquals(0, buffer.length(), "Empty message must not accumulate into the buffer"); + }); + } + + private static ChatMarkupViewer getMarkupViewer(BaseTurnWidget widget) { + Object textBlock = getField(widget, "currentTextBlock"); + return textBlock instanceof ChatMarkupViewer markup ? markup : null; + } + + private static Object getField(Object target, String name) { + Class cls = target.getClass(); + while (cls != null) { + try { + Field f = cls.getDeclaredField(name); + f.setAccessible(true); + return f.get(target); + } catch (NoSuchFieldException e) { + cls = cls.getSuperclass(); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + throw new RuntimeException("Field '" + name + "' not found on " + target.getClass()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessorTest.java new file mode 100644 index 00000000..1b903806 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessorTest.java @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import org.eclipse.jface.text.contentassist.ICompletionProposal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.microsoft.copilot.eclipse.ui.chat.services.ChatCompletionService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.services.UserPreferenceService; + +@ExtendWith(MockitoExtension.class) +class ChatAssistProcessorTest { + @Mock + private ChatServiceManager chatServiceManager; + + @Mock + private ChatCompletionService chatCompletionService; + + @Mock + private UserPreferenceService userPreferenceService; + + private ChatAssistProcessor processor; + + @BeforeEach + void setUp() { + when(chatServiceManager.getChatCompletionService()).thenReturn(chatCompletionService); + when(chatServiceManager.getUserPreferenceService()).thenReturn(userPreferenceService); + processor = new ChatAssistProcessor(null, chatServiceManager); + } + + @Test + void consoleProposalIsAvailableInBuiltInAskAgentAndPlanModes() { + assertConsoleProposal("Ask"); + assertConsoleProposal("Agent"); + assertConsoleProposal("Plan"); + } + + @Test + void consoleProposalIsNotAvailableForCustomAgents() { + String customModeId = "file:///workspace/.github/agents/custom.agent.md"; + when(userPreferenceService.getActiveModeNameOrId()).thenReturn(customModeId); + when(chatCompletionService.isConsoleContextCommandAvailable(customModeId)).thenReturn(false); + when(chatCompletionService.isAgentsReady()).thenReturn(false); + + ICompletionProposal[] proposals = processor.createCopilotCompletionAgentProposals("con"); + + assertEquals(0, proposals.length); + } + + @Test + void consoleProposalIsNotAvailableWhenPreferenceIsDisabled() { + when(userPreferenceService.getActiveModeNameOrId()).thenReturn("Ask"); + when(chatCompletionService.isConsoleContextCommandAvailable("Ask")).thenReturn(false); + when(chatCompletionService.isAgentsReady()).thenReturn(false); + + ICompletionProposal[] proposals = processor.createCopilotCompletionAgentProposals("con"); + + assertEquals(0, proposals.length); + } + + private void assertConsoleProposal(String modeName) { + when(userPreferenceService.getActiveModeNameOrId()).thenReturn(modeName); + when(chatCompletionService.isConsoleContextCommandAvailable(modeName)).thenReturn(true); + when(chatCompletionService.isAgentsReady()).thenReturn(false); + + ICompletionProposal[] proposals = processor.createCopilotCompletionAgentProposals("con"); + + assertEquals(1, proposals.length); + assertEquals("@console", proposals[0].getDisplayString()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java index d44508a3..3f5a288c 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java @@ -38,6 +38,7 @@ import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.tools.ChangedFile; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService.FileChangeProperty; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; @@ -103,7 +104,7 @@ private void setupMocks() { void testNoScrollForFewFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(3, false); + Map filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -122,7 +123,7 @@ void testNoScrollForFewFiles() { void testNoScrollForExactlyMaxFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(5, false); + Map filesMap = createMockFilesMap(5); workingSetBar.buildSummaryBarFor(filesMap); @@ -141,7 +142,7 @@ void testNoScrollForExactlyMaxFiles() { void testScrollCreatedForManyFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(10, false); + Map filesMap = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(filesMap); @@ -164,7 +165,7 @@ void testScrollCreatedForManyFiles() { void testScrollHeightHintForManyFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(8, false); + Map filesMap = createMockFilesMap(8); workingSetBar.buildSummaryBarFor(filesMap); @@ -190,7 +191,7 @@ void testAllFileRowsRenderedWithScroll() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); int fileCount = 7; - Map filesMap = createMockFilesMap(fileCount, false); + Map filesMap = createMockFilesMap(fileCount); workingSetBar.buildSummaryBarFor(filesMap); @@ -215,7 +216,7 @@ void testAllFileRowsRenderedWithScroll() { void testContentAreaSetInScrolledComposite() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(8, false); + Map filesMap = createMockFilesMap(8); workingSetBar.buildSummaryBarFor(filesMap); @@ -242,7 +243,7 @@ void testContentAreaSetInScrolledComposite() { void testMinHeightSetForScrolledComposite() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(10, false); + Map filesMap = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(filesMap); @@ -266,7 +267,7 @@ void testRebuildSummaryBarChangesScrollBehavior() { workingSetBar = new WorkingSetBar(parent, SWT.NONE); // First build with few files (no scroll) - Map fewFiles = createMockFilesMap(3, false); + Map fewFiles = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(fewFiles); Object changedFiles1 = getFieldValue(workingSetBar, "changedFiles"); @@ -275,7 +276,7 @@ void testRebuildSummaryBarChangesScrollBehavior() { assertNull(scroll1, "No scroll should exist for 3 files"); // Rebuild with many files (should have scroll) - Map manyFiles = createMockFilesMap(10, false); + Map manyFiles = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(manyFiles); Object changedFiles2 = getFieldValue(workingSetBar, "changedFiles"); @@ -294,7 +295,7 @@ void testRebuildSummaryBarChangesScrollBehavior() { void testExpandIconImageWhenExpanded() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(3, false); + Map filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -322,7 +323,7 @@ void testExpandIconImageWhenExpanded() { void testExpandIconImageWhenCollapsed() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(3, false); + Map filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -354,7 +355,7 @@ void testExpandIconImageWhenCollapsed() { void testTooltipTextWhenExpanded() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(3, false); + Map filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -395,7 +396,7 @@ void testTooltipTextWhenExpanded() { void testTooltipTextWhenCollapsed() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(5, false); + Map filesMap = createMockFilesMap(5); workingSetBar.buildSummaryBarFor(filesMap); @@ -436,7 +437,7 @@ void testTooltipTextWhenCollapsed() { void testTooltipAndImageToggleBehavior() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map filesMap = createMockFilesMap(4, false); + Map filesMap = createMockFilesMap(4); workingSetBar.buildSummaryBarFor(filesMap); @@ -476,7 +477,7 @@ void testTooltipContainsCorrectFileCount() { workingSetBar = new WorkingSetBar(parent, SWT.NONE); // Test with 1 file - Map oneFile = createMockFilesMap(1, false); + Map oneFile = createMockFilesMap(1); workingSetBar.buildSummaryBarFor(oneFile); Object titleBar = getFieldValue(workingSetBar, "titleBar"); @@ -488,7 +489,7 @@ void testTooltipContainsCorrectFileCount() { "Tooltip should contain 'file' (singular)"); // Test with 10 files - Map tenFiles = createMockFilesMap(10, false); + Map tenFiles = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(tenFiles); titleBar = getFieldValue(workingSetBar, "titleBar"); @@ -508,7 +509,7 @@ void testTooltipContainsCorrectFileCount() { void testEmptyFilesMapDoesNotCreateChangedFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map emptyMap = new LinkedHashMap<>(); + Map emptyMap = new LinkedHashMap<>(); workingSetBar.buildSummaryBarFor(emptyMap); @@ -524,11 +525,11 @@ void testEmptyFilesMapDoesNotCreateChangedFiles() { /** * Creates a map of mock files with the specified count. */ - private Map createMockFilesMap(int count, boolean isHandled) { - Map filesMap = new LinkedHashMap<>(); + private Map createMockFilesMap(int count) { + Map filesMap = new LinkedHashMap<>(); for (int i = 0; i < count; i++) { IFile mockFile = createMockFile("TestFile" + i + ".java"); - filesMap.put(mockFile, new FileChangeProperty(FileChangeType.Created)); + filesMap.put(ChangedFile.workspace(mockFile), new FileChangeProperty(FileChangeType.Created)); } return filesMap; } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java index 79f9c3df..3c80a6be 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java @@ -20,6 +20,7 @@ import org.eclipse.ui.PlatformUI; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -44,6 +45,7 @@ class ChatCompletionServiceTest { private static ChatCompletionService chatCompletionService; private static MockedStatic copilotUiMock; private static MockedStatic platformUiMock; + private static IPreferenceStore mockPreferenceStore; @BeforeAll static void setUp() { @@ -53,7 +55,7 @@ static void setUp() { // Mock CopilotUi.getPlugin() so the constructor can register its preference listener CopilotUi mockPlugin = mock(CopilotUi.class); - IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class); + mockPreferenceStore = mock(IPreferenceStore.class); LanguageServerSettingManager mockSettingManager = mock(LanguageServerSettingManager.class); when(mockPlugin.getLanguageServerSettingManager()).thenReturn(mockSettingManager); when(mockPlugin.getPreferenceStore()).thenReturn(mockPreferenceStore); @@ -82,6 +84,11 @@ static void setUp() { } } + @BeforeEach + void resetConsoleContextPreference() { + when(mockPreferenceStore.getBoolean(Constants.CONSOLE_CONTEXT_ENABLED)).thenReturn(false); + } + @AfterAll static void tearDown() { if (chatCompletionService != null) { @@ -122,4 +129,34 @@ void testIsCommand() { void testGetFilteredTemplates() { assertNotNull(chatCompletionService.getFilteredTemplates(ChatMode.Ask)); } -} \ No newline at end of file + + @Test + void testConsoleContextCommandDisabledByDefault() { + assertFalse(chatCompletionService.isCommand("@console", "Ask")); + } + + @Test + void testConsoleContextCommandEnabledForBuiltInModes() { + when(mockPreferenceStore.getBoolean(Constants.CONSOLE_CONTEXT_ENABLED)).thenReturn(true); + + assertTrue(chatCompletionService.isCommand("@console", "Ask")); + assertTrue(chatCompletionService.isCommand("@console", "Agent")); + assertTrue(chatCompletionService.isCommand("@console", "Plan")); + } + + @Test + void testConsoleContextCommandUnavailableForCustomAgents() { + when(mockPreferenceStore.getBoolean(Constants.CONSOLE_CONTEXT_ENABLED)).thenReturn(true); + + assertFalse(chatCompletionService.isCommand("@console", "file:///workspace/.github/agents/custom.agent.md")); + } + + @Test + void testConsoleContextCommandUnavailableWhenModeIsNull() { + // When the active mode is null (unknown), @console should not be available even if pref is enabled, + // since we cannot verify that the mode supports it. + when(mockPreferenceStore.getBoolean(Constants.CONSOLE_CONTEXT_ENABLED)).thenReturn(true); + + assertFalse(chatCompletionService.isCommand("@console", null)); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextPromptProcessorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextPromptProcessorTest.java new file mode 100644 index 00000000..4530bfbb --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextPromptProcessorTest.java @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextPromptProcessor.ProcessedMessage; +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextService.ConsoleSnapshot; + +class ConsoleContextPromptProcessorTest { + + @Test + void processAddsConsoleContextWhenLeadingCommandIsEnabledAndSupported() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console explain the failure", true, true, + () -> ConsoleSnapshot.available("Maven", "BUILD FAILURE", false)); + + assertTrue(result.consoleContextRequested()); + assertTrue(result.serverMessage().startsWith("explain the failure")); + assertTrue(result.serverMessage().contains("[Console Context]")); + assertTrue(result.serverMessage().contains("Console: Maven")); + assertTrue(result.serverMessage().contains("Truncated: no")); + assertTrue(result.serverMessage().contains("BUILD FAILURE")); + assertTrue(result.serverMessage().contains("[Maven Build Summary]")); + assertFalse(result.serverMessage().contains("@console")); + } + + @Test + void processUsesMavenParserWhenConsoleNameContainsMaven() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console check build", true, true, + () -> ConsoleSnapshot.available("Maven Build", "[INFO] BUILD SUCCESS", false)); + + assertTrue(result.serverMessage().contains("[Maven Build Summary]")); + assertTrue(result.serverMessage().contains("Result: BUILD SUCCESS")); + assertFalse(result.serverMessage().contains("[Mule Error Summary]")); + } + + @Test + void processUsesMuleParserForNonMavenConsoleName() { + String muleExceptionOutput = "org.mule.runtime.core.internal.exception.MessagingException\n" + + "error type: EXPRESSION:INVALID_EXPRESSION\nFlow name: myFlow"; + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console check error", true, true, + () -> ConsoleSnapshot.available("Mule Application", muleExceptionOutput, false)); + + assertTrue(result.serverMessage().contains("[Mule Error Summary]")); + assertFalse(result.serverMessage().contains("[Maven Build Summary]")); + } + + @Test + void processOnlyConsumesLeadingConsoleCommand() { + AtomicBoolean supplierCalled = new AtomicBoolean(false); + + ProcessedMessage result = ConsoleContextPromptProcessor.process("please inspect @console output", true, true, () -> { + supplierCalled.set(true); + return ConsoleSnapshot.available("Console", "output", false); + }); + + assertFalse(result.consoleContextRequested()); + assertEquals("please inspect @console output", result.serverMessage()); + assertFalse(supplierCalled.get()); + } + + @Test + void processDoesNotMatchWhenCommandFollowedByNonWhitespace() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console-output explain", true, true, + () -> ConsoleSnapshot.available("Console", "output", false)); + + assertFalse(result.consoleContextRequested()); + assertEquals("@console-output explain", result.serverMessage()); + } + + @Test + void processLeavesMessageUnchangedWhenFeatureDisabled() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console explain", false, true, + () -> ConsoleSnapshot.available("Console", "output", false)); + + assertFalse(result.consoleContextRequested()); + assertEquals("@console explain", result.serverMessage()); + } + + @Test + void processLeavesMessageUnchangedWhenModeUnsupported() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console explain", true, false, + () -> ConsoleSnapshot.available("Console", "output", false)); + + assertFalse(result.consoleContextRequested()); + assertEquals("@console explain", result.serverMessage()); + } + + @Test + void processAddsUnavailableNoteInsteadOfFailing() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console explain", true, true, + () -> ConsoleSnapshot.unavailable("No active console is selected.")); + + assertTrue(result.consoleContextRequested()); + assertTrue(result.serverMessage().contains("Console context unavailable: No active console is selected.")); + } + + @Test + void processAddsEmptyOutputNote() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console explain", true, true, + () -> ConsoleSnapshot.available("Console", "", false)); + + assertTrue(result.consoleContextRequested()); + assertTrue(result.serverMessage().contains("Output: Console output is empty.")); + } + + @Test + void processHandlesConsoleCommandAloneWithNoPrompt() { + ProcessedMessage result = ConsoleContextPromptProcessor.process("@console", true, true, + () -> ConsoleSnapshot.available("Build", "BUILD FAILURE", false)); + + assertTrue(result.consoleContextRequested()); + // After stripping @console, the prompt is empty, so the server message is just the context block + assertTrue(result.serverMessage().contains("[Console Context]")); + assertTrue(result.serverMessage().contains("Console: Build")); + assertTrue(result.serverMessage().contains("BUILD FAILURE")); + // No leading user prompt, just the context block + assertTrue(result.serverMessage().startsWith("[Console Context]")); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextServiceTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextServiceTest.java new file mode 100644 index 00000000..40bf7a87 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextServiceTest.java @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import org.eclipse.ui.console.IConsole; +import org.eclipse.ui.console.MessageConsole; +import org.junit.jupiter.api.Test; + +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextService.ConsoleSnapshot; + +class ConsoleContextServiceTest { + + @Test + void captureConsoleReturnsTextConsoleOutput() { + MessageConsole console = new MessageConsole("Build", null); + console.getDocument().set("line 1\nline 2"); + + ConsoleSnapshot snapshot = new ConsoleContextService().captureConsole(console, + ConsoleContextService.DEFAULT_MAX_CHARS); + + assertTrue(snapshot.isAvailable()); + assertEquals("Build", snapshot.consoleName()); + assertEquals("line 1\nline 2", snapshot.output()); + assertFalse(snapshot.truncated()); + } + + @Test + void captureConsoleReturnsEmptySnapshotForEmptyTextConsole() { + MessageConsole console = new MessageConsole("Empty", null); + console.getDocument().set(""); + + ConsoleSnapshot snapshot = new ConsoleContextService().captureConsole(console, + ConsoleContextService.DEFAULT_MAX_CHARS); + + assertTrue(snapshot.isAvailable()); + assertTrue(snapshot.isEmpty()); + assertFalse(snapshot.truncated()); + } + + @Test + void captureConsoleTruncatesAtLineBoundary() { + MessageConsole console = new MessageConsole("Long", null); + console.getDocument().set("line 1\nline 2\nline 3"); + + ConsoleSnapshot snapshot = new ConsoleContextService().captureConsole(console, 10); + + assertTrue(snapshot.isAvailable()); + assertEquals("line 3", snapshot.output()); + assertTrue(snapshot.truncated()); + } + + @Test + void captureConsoleReturnsUnavailableForMissingConsole() { + ConsoleSnapshot snapshot = new ConsoleContextService().captureConsole(null, + ConsoleContextService.DEFAULT_MAX_CHARS); + + assertFalse(snapshot.isAvailable()); + assertTrue(snapshot.unavailableReason().contains("No active console")); + } + + @Test + void captureConsoleReturnsUnavailableForNonTextConsole() { + ConsoleSnapshot snapshot = new ConsoleContextService().captureConsole(mock(IConsole.class), + ConsoleContextService.DEFAULT_MAX_CHARS); + + assertFalse(snapshot.isAvailable()); + assertTrue(snapshot.unavailableReason().contains("not text-backed")); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/MavenConsoleParserTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/MavenConsoleParserTest.java new file mode 100644 index 00000000..1e92920e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/MavenConsoleParserTest.java @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class MavenConsoleParserTest { + + @Test + void enrich_returnsOriginalWhenNoMavenOutput() { + String input = "some generic console output\nno maven markers here"; + assertEquals(input, MavenConsoleParser.enrich(input)); + } + + @Test + void enrich_returnsNullForNullInput() { + assertNull(MavenConsoleParser.enrich(null)); + } + + @Test + void enrich_returnsBlankForBlankInput() { + assertEquals(" ", MavenConsoleParser.enrich(" ")); + } + + @Test + void enrich_prependsSummaryForBuildSuccess() { + String input = "[INFO] Scanning for projects...\n[INFO] BUILD SUCCESS\n[INFO] Total time: 2.5 s"; + + String result = MavenConsoleParser.enrich(input); + + assertTrue(result.startsWith("[Maven Build Summary]")); + assertTrue(result.contains("Result: BUILD SUCCESS")); + assertTrue(result.contains("Errors: 0")); + assertTrue(result.contains(input)); + } + + @Test + void enrich_prependsSummaryForBuildFailure() { + String input = "[INFO] Scanning for projects...\n[ERROR] Compilation failure\n[INFO] BUILD FAILURE"; + + String result = MavenConsoleParser.enrich(input); + + assertTrue(result.startsWith("[Maven Build Summary]")); + assertTrue(result.contains("Result: BUILD FAILURE")); + assertTrue(result.contains("Errors: 1")); + assertTrue(result.contains(input)); + } + + @Test + void enrich_countsMultipleErrorsAndWarnings() { + String input = "[ERROR] src/Foo.java:10: error\n" + + "[ERROR] src/Bar.java:20: error\n" + + "[WARNING] deprecated API\n" + + "[WARNING] unchecked cast\n" + + "[WARNING] unused import\n" + + "[INFO] BUILD FAILURE"; + + String result = MavenConsoleParser.enrich(input); + + assertTrue(result.contains("Errors: 2")); + assertTrue(result.contains("Warnings: 3")); + } + + @Test + void enrich_isCaseInsensitiveForMarkers() { + String input = "[info] Scanning...\n[error] Compilation failure\nbuild failure"; + + String result = MavenConsoleParser.enrich(input); + + assertTrue(result.startsWith("[Maven Build Summary]")); + assertTrue(result.contains("Errors: 1")); + } + + @Test + void enrich_doesNotDoubleWrapAlreadyEnrichedOutput() { + String input = "[INFO] BUILD SUCCESS"; + String enrichedOnce = MavenConsoleParser.enrich(input); + + assertTrue(enrichedOnce.startsWith("[Maven Build Summary]")); + assertFalse(enrichedOnce.substring(1).contains("[Maven Build Summary]")); + } + + @Test + void enrich_includesRawOutputAfterSummary() { + String input = "[INFO] BUILD SUCCESS\n[INFO] Total time: 1 s"; + + String result = MavenConsoleParser.enrich(input); + + assertTrue(result.contains(input)); + int summaryEnd = result.indexOf(input); + assertTrue(result.substring(0, summaryEnd).contains("[Maven Build Summary]")); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManagerTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManagerTest.java index 3f60a488..69c3e784 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManagerTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManagerTest.java @@ -4,10 +4,13 @@ package com.microsoft.copilot.eclipse.ui.chat.services; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -207,4 +210,151 @@ void testUpdateApprovedMcpServerStringWithNullServers() throws Exception { Map resultServers = (Map) result.get("servers"); assertTrue(resultServers.isEmpty(), "Servers map should be empty when MCP servers are null"); } + + @Test + void testDetectChangesDropsApprovedServerWhenPluginUninstalled() throws Exception { + // Regression test for https://github.com/microsoft/copilot-for-eclipse/issues/153 scenario 1: + // a previously approved plugin is no longer providing any MCP server. + IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class); + when(mockCopilotUi.getPreferenceStore()).thenReturn(mockPreferenceStore); + + Map previouslyApprovedServers = new HashMap<>(); + previouslyApprovedServers.put("server-X", Map.of("url", "http://localhost:9000")); + McpRegistrationInfo persistedInfo = createMcpRegistrationInfo(true, true, "Test Plugin", + previouslyApprovedServers); + Map persisted = new HashMap<>(); + persisted.put("com.example.plugin", persistedInfo); + + // Live extension scan returned nothing (plugin uninstalled or no longer provides servers). + setExtMcpInfoMap(new HashMap<>()); + + invokeDetectChanges(persisted); + + String approvedServers = manager.getApprovedExtMcpServers(); + assertNotNull(approvedServers, "Approved servers JSON should be non-null after detect"); + Map result = gson.fromJson(approvedServers, Map.class); + Map resultServers = (Map) result.get("servers"); + assertTrue(resultServers.isEmpty(), + "Stale approved server must be dropped when the live extension scan returns nothing"); + + // No new approval prompt should be raised because there is no incoming registration to approve. + verify(mockMcpConfigService, never()).setNewExtMcpRegFound(true); + + // The verified state must be persisted so subsequent startups do not resurrect the stale entry. + ArgumentCaptor persistedJson = ArgumentCaptor.forClass(String.class); + verify(mockPreferenceStore, atLeastOnce()).setValue(eq(Constants.MCP_EXTENSION_POINT_CONTRIB), + persistedJson.capture()); + assertEquals("{}", persistedJson.getValue(), + "Persisted contribution map should be empty after the plugin is gone"); + } + + @Test + void testDetectChangesDropsApprovalAndFlagsRedNoticeWhenConfigChanges() throws Exception { + // Regression test for https://github.com/microsoft/copilot-for-eclipse/issues/153 scenario 2: + // the contributing plugin returns a different config than what was previously approved. + IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class); + when(mockCopilotUi.getPreferenceStore()).thenReturn(mockPreferenceStore); + + Map oldServers = new HashMap<>(); + oldServers.put("server-X", Map.of("url", "http://localhost:9000")); + McpRegistrationInfo persistedInfo = createMcpRegistrationInfo(true, true, "Test Plugin", oldServers); + Map persisted = new HashMap<>(); + persisted.put("com.example.plugin", persistedInfo); + + // Live extension scan returned the same plugin but with a different server config (port change). + Map newServers = new HashMap<>(); + newServers.put("server-X", Map.of("url", "http://localhost:9999")); + McpRegistrationInfo currentInfo = createMcpRegistrationInfo(true, false, "Test Plugin", newServers); + Map currentMap = new HashMap<>(); + currentMap.put("com.example.plugin", currentInfo); + setExtMcpInfoMap(currentMap); + + invokeDetectChanges(persisted); + + String approvedServers = manager.getApprovedExtMcpServers(); + assertNotNull(approvedServers); + Map result = gson.fromJson(approvedServers, Map.class); + Map resultServers = (Map) result.get("servers"); + assertTrue(resultServers.isEmpty(), + "Changed config must not be auto-applied; LSP must not receive the (now unapproved) entry"); + + assertFalse(currentInfo.isApproved(), + "Previously approved entry whose config changed must be marked as unapproved until the user re-approves"); + verify(mockMcpConfigService).setNewExtMcpRegFound(true); + verify(mockPreferenceStore, atLeastOnce()).setValue(eq(Constants.MCP_EXTENSION_POINT_CONTRIB), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void testDetectChangesPreservesApprovalWhenConfigUnchanged() throws Exception { + // Companion test: when the live extension scan reports the same config as the persisted cache, + // the previous approval must be carried over so the explicit LSP sync at the end of + // doRegistration() can push the verified servers to the language server. + IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class); + when(mockCopilotUi.getPreferenceStore()).thenReturn(mockPreferenceStore); + + Map approvedServers = new HashMap<>(); + approvedServers.put("server-X", Map.of("url", "http://localhost:9000")); + McpRegistrationInfo persistedInfo = createMcpRegistrationInfo(true, true, "Test Plugin", approvedServers); + Map persisted = new HashMap<>(); + persisted.put("com.example.plugin", persistedInfo); + + // Live extension scan returned the same servers; default isApproved=false until carry-over runs. + Map sameServers = new HashMap<>(); + sameServers.put("server-X", Map.of("url", "http://localhost:9000")); + McpRegistrationInfo currentInfo = createMcpRegistrationInfo(true, false, "Test Plugin", sameServers); + Map currentMap = new HashMap<>(); + currentMap.put("com.example.plugin", currentInfo); + setExtMcpInfoMap(currentMap); + + invokeDetectChanges(persisted); + + assertTrue(currentInfo.isApproved(), + "When the contributed config matches the persisted JSON, the previous approval must be carried over"); + + String approvedJson = manager.getApprovedExtMcpServers(); + assertNotNull(approvedJson); + Map result = gson.fromJson(approvedJson, Map.class); + Map resultServers = (Map) result.get("servers"); + assertEquals(1, resultServers.size(), + "Carried-over approved server must be present in the approved servers JSON for the LSP sync"); + + // No red-notice should be raised because nothing has changed for the user to re-review. + verify(mockMcpConfigService, never()).setNewExtMcpRegFound(true); + } + + /** + * Reflectively replace the manager's private {@code extMcpInfoMap} so tests can simulate the + * outcome of {@code loadMcpRegistrationExtensionPoint()} without requiring a live OSGi + * extension registry. + */ + private void setExtMcpInfoMap(Map map) throws Exception { + Field field = McpExtensionPointManager.class.getDeclaredField("extMcpInfoMap"); + field.setAccessible(true); + field.set(manager, map); + } + + private void invokeDetectChanges(Map persisted) throws Exception { + // Get the scanned map that was previously set via setExtMcpInfoMap + Field field = McpExtensionPointManager.class.getDeclaredField("extMcpInfoMap"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map scannedMap = (Map) field.get(manager); + + // detectChangesInMcpContribs now takes (scannedMap, persistedMap) and no longer + // calls updateApprovedMcpServerString / persistExtMcpInfo (those moved to doRegistration). + Method detectMethod = McpExtensionPointManager.class.getDeclaredMethod("detectChangesInMcpContribs", Map.class, + Map.class); + detectMethod.setAccessible(true); + detectMethod.invoke(manager, scannedMap, persisted); + + // Mirror what doRegistration() does after detectChanges: swap the field and update/persist. + field.set(manager, scannedMap); + Method updateMethod = McpExtensionPointManager.class.getDeclaredMethod("updateApprovedMcpServerString", Map.class); + updateMethod.setAccessible(true); + updateMethod.invoke(manager, scannedMap); + Method persistMethod = McpExtensionPointManager.class.getDeclaredMethod("persistExtMcpInfo", Map.class); + persistMethod.setAccessible(true); + persistMethod.invoke(manager, scannedMap); + } } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextPromptProcessorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextPromptProcessorTest.java new file mode 100644 index 00000000..77b90f4f --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextPromptProcessorTest.java @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextPromptProcessor.ProcessedMessage; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService.TransformEditorSnapshot; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService.TransformEditorSnapshot.ScriptEntry; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService.TransformEditorSnapshot.TransformEntry; + +class TransformEditorContextPromptProcessorTest { + + private static TransformEditorSnapshot singleTransformSnapshot() { + ScriptEntry payloadScript = new ScriptEntry("payload", "application/json", + "%dw 2.0\noutput application/json\n---\n{ id: payload.customerId }"); + TransformEntry entry = new TransformEntry("MyTransform [id=abc-123]", "MyTransform", "abc-123", + List.of(payloadScript)); + return TransformEditorSnapshot.available("/project/src/main/mule/main.xml", 1, List.of(entry), false); + } + + @Test + void processAddsTransformContextWhenLeadingCommandIsEnabledAndSupported() { + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform help me improve this mapping", true, true, () -> singleTransformSnapshot()); + + assertTrue(result.transformContextRequested()); + assertTrue(result.serverMessage().startsWith("help me improve this mapping")); + assertTrue(result.serverMessage().contains("[Transform Context]")); + assertTrue(result.serverMessage().contains("file: /project/src/main/mule/main.xml")); + assertTrue(result.serverMessage().contains("transforms: 1")); + assertTrue(result.serverMessage().contains("--- Transform: MyTransform [id=abc-123] ---")); + assertTrue(result.serverMessage().contains("target: payload")); + assertTrue(result.serverMessage().contains("outputType: application/json")); + assertTrue(result.serverMessage().contains("{ id: payload.customerId }")); + assertFalse(result.serverMessage().contains("@transform")); + } + + @Test + void processOnlyConsumesLeadingTransformCommand() { + AtomicBoolean supplierCalled = new AtomicBoolean(false); + + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "please look at @transform output", true, true, () -> { + supplierCalled.set(true); + return singleTransformSnapshot(); + }); + + assertFalse(result.transformContextRequested()); + assertEquals("please look at @transform output", result.serverMessage()); + assertFalse(supplierCalled.get()); + } + + @Test + void processDoesNotMatchWhenCommandFollowedByNonWhitespace() { + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform-mapper improve", true, true, () -> singleTransformSnapshot()); + + assertFalse(result.transformContextRequested()); + assertEquals("@transform-mapper improve", result.serverMessage()); + } + + @Test + void processLeavesMessageUnchangedWhenFeatureDisabled() { + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform explain", false, true, () -> singleTransformSnapshot()); + + assertFalse(result.transformContextRequested()); + assertEquals("@transform explain", result.serverMessage()); + } + + @Test + void processLeavesMessageUnchangedWhenModeUnsupported() { + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform explain", true, false, () -> singleTransformSnapshot()); + + assertFalse(result.transformContextRequested()); + assertEquals("@transform explain", result.serverMessage()); + } + + @Test + void processAddsUnavailableNoteInsteadOfFailing() { + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform explain", true, true, + () -> TransformEditorSnapshot.unavailable("No active Mule XML editor is open.")); + + assertTrue(result.transformContextRequested()); + assertTrue(result.serverMessage() + .contains("Transform context unavailable: No active Mule XML editor is open.")); + } + + @Test + void processAddsEmptyTransformNote() { + TransformEditorSnapshot emptySnapshot = TransformEditorSnapshot.available( + "/project/src/main/mule/main.xml", 0, List.of(), false); + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform explain", true, true, () -> emptySnapshot); + + assertTrue(result.transformContextRequested()); + assertTrue(result.serverMessage().contains("No ee:transform elements found in this file.")); + } + + @Test + void processHandlesTransformCommandAloneWithNoPrompt() { + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform", true, true, () -> singleTransformSnapshot()); + + assertTrue(result.transformContextRequested()); + assertTrue(result.serverMessage().contains("[Transform Context]")); + assertTrue(result.serverMessage().contains("MyTransform")); + // No leading user prompt; context block starts the server message + assertTrue(result.serverMessage().startsWith("[Transform Context]")); + } + + @Test + void processTruncatedFlagAppearsInOutput() { + ScriptEntry payloadScript = new ScriptEntry("payload", "application/json", "...large script..."); + TransformEntry entry = new TransformEntry("T", "T", "t1", List.of(payloadScript)); + TransformEditorSnapshot truncatedSnapshot = TransformEditorSnapshot.available( + "/project/src/main/mule/main.xml", 1, List.of(entry), true); + + ProcessedMessage result = TransformEditorContextPromptProcessor.process( + "@transform check this", true, true, () -> truncatedSnapshot); + + assertTrue(result.transformContextRequested()); + assertTrue(result.serverMessage().contains("truncated due to length")); + } + + @Test + void processAutoInject_appendsContextWhenEnabledAndSnapshotAvailable() { + ProcessedMessage result = TransformEditorContextPromptProcessor.processAutoInject( + "explain the error", true, true, () -> singleTransformSnapshot()); + + assertTrue(result.transformContextRequested()); + assertTrue(result.serverMessage().contains("explain the error")); + assertTrue(result.serverMessage().contains("[Transform Context]")); + assertTrue(result.serverMessage().contains("target: payload")); + assertTrue(result.serverMessage().contains("@transform") == false); + } + + @Test + void processAutoInject_doesNotInjectWhenDisabled() { + ProcessedMessage result = TransformEditorContextPromptProcessor.processAutoInject( + "explain the error", false, true, () -> singleTransformSnapshot()); + + assertFalse(result.transformContextRequested()); + assertEquals("explain the error", result.serverMessage()); + } + + @Test + void processAutoInject_doesNotInjectWhenModeUnsupported() { + ProcessedMessage result = TransformEditorContextPromptProcessor.processAutoInject( + "explain the error", true, false, () -> singleTransformSnapshot()); + + assertFalse(result.transformContextRequested()); + assertEquals("explain the error", result.serverMessage()); + } + + @Test + void processAutoInject_doesNotInjectWhenSnapshotUnavailable() { + ProcessedMessage result = TransformEditorContextPromptProcessor.processAutoInject( + "explain the error", true, true, + () -> TransformEditorSnapshot.unavailable("No active Mule XML editor is open.")); + + assertFalse(result.transformContextRequested()); + assertEquals("explain the error", result.serverMessage()); + assertFalse(result.serverMessage().contains("unavailable")); + } + + @Test + void processAutoInject_doesNotInjectWhenSnapshotEmpty() { + TransformEditorSnapshot emptySnapshot = TransformEditorSnapshot.available( + "/project/src/main/mule/main.xml", 0, List.of(), false); + + ProcessedMessage result = TransformEditorContextPromptProcessor.processAutoInject( + "explain the error", true, true, () -> emptySnapshot); + + assertFalse(result.transformContextRequested()); + assertEquals("explain the error", result.serverMessage()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java index 4bd2c9ed..428d7297 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java @@ -5,10 +5,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -19,12 +24,14 @@ import org.eclipse.core.resources.IWorkspace; import org.eclipse.core.resources.IWorkspaceRoot; import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.lsp4j.FileChangeType; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Shell; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @@ -52,6 +59,9 @@ class CreateFileToolTest { @Mock private FileToolService mockFileToolService; + @TempDir + private Path tempDir; + private MockedStatic mockedCopilotUi; @BeforeEach @@ -78,6 +88,7 @@ void tearDown() throws Exception { // Clean up test project cleanupTestProject(); + FileToolCacheAccessor.clearCaches(); } private IProject setupTestProject() throws Exception { @@ -251,11 +262,92 @@ void testInvokeWithNullContentReturnsSuccessStatus() throws Exception { assertTrue(newFile.exists()); } + @Test + void testInvokeWithExternalLocalFilePathCreatesFile() throws Exception { + setupMocks(); + Path newFile = tempDir.resolve("external-file.txt"); + + Map input = new HashMap<>(); + input.put("filePath", newFile.toString()); + input.put("content", "test content"); + + CompletableFuture future = createFileTool.invoke(input, null); + LanguageModelToolResult[] results = future.get(); + + assertSuccessResult(results, "File created at"); + assertTrue(Files.exists(newFile)); + assertEquals("test content", Files.readString(newFile)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(newFile), FileChangeType.Created); + } + + @Test + void testInvokeWithExternalLocalFileUriCreatesFile() throws Exception { + setupMocks(); + Path newFile = tempDir.resolve("external-file-uri.txt"); + + Map input = new HashMap<>(); + input.put("filePath", newFile.toUri().toString()); + input.put("content", "test content"); + + CompletableFuture future = createFileTool.invoke(input, null); + LanguageModelToolResult[] results = future.get(); + + assertSuccessResult(results, "File created at"); + assertTrue(Files.exists(newFile)); + assertEquals("test content", Files.readString(newFile)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(newFile), FileChangeType.Created); + } + + @Test + void testOnKeepChangeWithWorkspaceFileClearsOriginalContentCache() { + IFile newFile = mock(IFile.class); + FileToolCacheAccessor.putWorkspaceFileContentCache(newFile, ""); + + createFileTool.onKeepChange(ChangedFile.workspace(newFile)); + + assertNull(FileToolCacheAccessor.getWorkspaceFileContentCache(newFile)); + } + + @Test + void testOnUndoChangeWithWorkspaceFileDeletesFileAndClearsOriginalContentCache() throws Exception { + IProject project = setupTestProject(); + IFile newFile = project.getFile("workspace-file-to-undo.txt"); + newFile.create(new java.io.ByteArrayInputStream("test content".getBytes()), true, null); + FileToolCacheAccessor.putWorkspaceFileContentCache(newFile, ""); + + createFileTool.onUndoChange(ChangedFile.workspace(newFile)); + + assertTrue(!newFile.exists()); + assertNull(FileToolCacheAccessor.getWorkspaceFileContentCache(newFile)); + } + + @Test + void testOnKeepChangeWithExternalLocalFileClearsOriginalContentCache() { + Path newFile = tempDir.resolve("external-file-to-keep.txt"); + FileToolCacheAccessor.putFileContentCache(newFile, ""); + + createFileTool.onKeepChange(ChangedFile.local(newFile)); + + assertNull(FileToolCacheAccessor.getFileContentCache(newFile)); + } + + @Test + void testOnUndoChangeWithExternalLocalFileDeletesFile() throws Exception { + Path newFile = tempDir.resolve("external-file-to-undo.txt"); + Files.writeString(newFile, "test content"); + FileToolCacheAccessor.putFileContentCache(newFile, ""); + + createFileTool.onUndoChange(ChangedFile.local(newFile)); + + assertTrue(Files.notExists(newFile)); + assertNull(FileToolCacheAccessor.getFileContentCache(newFile)); + } + @Test void testInvokeWithInvalidPathReturnsErrorStatus() throws InterruptedException, ExecutionException { // Arrange Map input = new HashMap<>(); - input.put("filePath", "/invalid/path/that/does/not/exist.txt"); + input.put("filePath", "relative/path/that/does/not/exist.txt"); input.put("content", "test content"); // Act @@ -263,7 +355,7 @@ void testInvokeWithInvalidPathReturnsErrorStatus() throws InterruptedException, LanguageModelToolResult[] results = future.get(); // Assert - assertErrorResult(results, "Error creating file"); + assertErrorResult(results, "does not exist in the workspace"); } @Test @@ -286,4 +378,26 @@ void testToolName() { * Note: CoreException and IOException scenarios are difficult to test in unit tests * without complex mocking and would be better covered by integration tests. */ + + private static final class FileToolCacheAccessor extends CreateFileTool { + private static void clearCaches() { + fileContentCache.clear(); + } + + private static void putWorkspaceFileContentCache(IFile file, String content) { + fileContentCache.put(ChangedFile.workspace(file), content); + } + + private static String getWorkspaceFileContentCache(IFile file) { + return fileContentCache.get(ChangedFile.workspace(file)); + } + + private static void putFileContentCache(Path file, String content) { + fileContentCache.put(ChangedFile.local(file), content); + } + + private static String getFileContentCache(Path file) { + return fileContentCache.get(ChangedFile.local(file)); + } + } } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java new file mode 100644 index 00000000..4ffabcbd --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.lsp4j.FileChangeType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; + +@ExtendWith(MockitoExtension.class) +class EditFileToolTest { + + @TempDir + Path tempDir; + + @Mock + private CopilotUi mockCopilotUi; + @Mock + private ChatServiceManager mockChatServiceManager; + @Mock + private FileToolService mockFileToolService; + + private MockedStatic mockedCopilotUi; + + private void setupMocks() { + mockedCopilotUi = mockStatic(CopilotUi.class); + mockedCopilotUi.when(CopilotUi::getPlugin).thenReturn(mockCopilotUi); + when(mockCopilotUi.getChatServiceManager()).thenReturn(mockChatServiceManager); + when(mockChatServiceManager.getFileToolService()).thenReturn(mockFileToolService); + } + + @AfterEach + void tearDown() { + if (mockedCopilotUi != null) { + mockedCopilotUi.close(); + } + FileToolCacheAccessor.clearCaches(); + } + + @Test + void testInvoke_withExternalLocalFilePath_editsFile() throws Exception { + setupMocks(); + Path file = tempDir.resolve("target.txt"); + Files.writeString(file, "original"); + + LanguageModelToolResult[] results = invokeEdit(file.toString(), "updated"); + + assertSuccess(results, "updated"); + assertEquals("updated", Files.readString(file)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(file), FileChangeType.Changed); + } + + @Test + void testInvoke_withExternalLocalFileUri_editsFile() throws Exception { + setupMocks(); + Path file = tempDir.resolve("target.patch"); + Files.writeString(file, "old patch content"); + + LanguageModelToolResult[] results = invokeEdit(file.toUri().toString(), "new patch content"); + + assertSuccess(results, "new patch content"); + assertEquals("new patch content", Files.readString(file)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(file), FileChangeType.Changed); + } + + @Test + void testOnUndoChange_withExternalLocalFile_restoresOriginalContent() throws Exception { + setupMocks(); + Path file = tempDir.resolve("target-to-undo.txt"); + Files.writeString(file, "original"); + + EditFileTool editFileTool = new EditFileTool(); + LanguageModelToolResult[] results = invokeEdit(editFileTool, file.toString(), "updated"); + assertSuccess(results, "updated"); + + editFileTool.onUndoChange(ChangedFile.local(file)); + + assertEquals("original", Files.readString(file)); + } + + @Test + void testInvoke_createThenEditExternalLocalFile_preservesEmptyBaseline() throws Exception { + setupMocks(); + Path file = tempDir.resolve("created-then-edited.txt"); + Path normalizedPath = file.toAbsolutePath().normalize(); + + CreateFileTool createFileTool = new CreateFileTool(); + LanguageModelToolResult[] createResults = invokeCreate(createFileTool, file.toString(), "created content"); + assertSuccess(createResults, "File created at: " + normalizedPath); + assertEquals("", FileToolCacheAccessor.getFileContentCache(normalizedPath)); + + EditFileTool editFileTool = new EditFileTool(); + LanguageModelToolResult[] editResults = invokeEdit(editFileTool, file.toString(), "edited content"); + + assertSuccess(editResults, "edited content"); + assertEquals("edited content", Files.readString(file)); + assertEquals("", FileToolCacheAccessor.getFileContentCache(normalizedPath)); + } + + @Test + void testInvoke_withMissingExternalLocalFile_returnsError() throws Exception { + LanguageModelToolResult[] results = invokeEdit(tempDir.resolve("missing.txt").toString(), "updated"); + + assertNotNull(results); + assertEquals(1, results.length); + assertEquals(ToolInvocationStatus.error, results[0].getStatus()); + } + + private LanguageModelToolResult[] invokeEdit(String filePath, String code) throws Exception { + return invokeEdit(new EditFileTool(), filePath, code); + } + + private LanguageModelToolResult[] invokeEdit(EditFileTool editFileTool, String filePath, String code) + throws Exception { + Map input = new HashMap<>(); + input.put("filePath", filePath); + input.put("code", code); + input.put("explanation", "test edit"); + + return editFileTool.invoke(input, null).get(); + } + + private LanguageModelToolResult[] invokeCreate(CreateFileTool createFileTool, String filePath, String content) + throws Exception { + Map input = new HashMap<>(); + input.put("filePath", filePath); + input.put("content", content); + + return createFileTool.invoke(input, null).get(); + } + + private void assertSuccess(LanguageModelToolResult[] results, String expectedContent) throws IOException { + assertNotNull(results); + assertEquals(1, results.length); + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + assertEquals(expectedContent, results[0].getContent().get(0).getValue()); + } + + private static final class FileToolCacheAccessor extends EditFileTool { + private static void clearCaches() { + fileContentCache.clear(); + } + + private static String getFileContentCache(Path file) { + return fileContentCache.get(ChangedFile.local(file)); + } + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleAgentToolsTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleAgentToolsTest.java new file mode 100644 index 00000000..fecca4db --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleAgentToolsTest.java @@ -0,0 +1,523 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; + +class MuleAgentToolsTest { + + @TempDir + Path tempDir; + + @Test + void muleProjectScanReturnsStructuredProjectInventory() throws Exception { + Path project = createMuleProject(); + LanguageModelToolResult[] results = new MuleProjectScanTool() + .invoke(Map.of("projectPath", project.toString()), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("\"status\": \"success\"")); + assertTrue(output.contains("muleXmlFiles=1")); + assertTrue(output.contains("mule-http-connector")); + assertTrue(output.contains("mule-maven-plugin")); + assertTrue(output.contains("propertyPlaceholders=http.port")); + } + + @Test + void apiSchemaAnalyzeFindsRamlGovernanceGaps() throws Exception { + Path raml = tempDir.resolve("api.raml"); + Files.writeString(raml, """ + #%RAML 1.0 + /accounts: + get: + responses: + 200: + body: + application/json: + """); + + LanguageModelToolResult[] results = new ApiSchemaAnalyzeTool() + .invoke(Map.of("schemaPath", raml.toString(), "schemaType", "raml"), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("RAML contract is missing title")); + assertTrue(output.contains("RAML contract does not declare security")); + } + + @Test + void muleSecurityReviewFindsSecretsAndInsecureHttp() throws Exception { + Path project = createMuleProject(); + LanguageModelToolResult[] results = new MuleSecurityReviewTool() + .invoke(Map.of("projectPath", project.toString(), "apiExposure", "public"), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("Possible hardcoded secret")); + assertTrue(output.contains("HTTP protocol is configured without TLS")); + } + + @Test + void muleCodeReviewFindsMissingMunitAndErrorHandler() throws Exception { + Path project = createMuleProject(); + LanguageModelToolResult[] results = new MuleCodeReviewTool() + .invoke(Map.of("projectPath", project.toString(), "reviewType", "full"), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("No MUnit suites were found")); + assertTrue(output.contains("No error-handler was detected")); + } + + @Test + void munitValidationFindsNoPurposeMissingStructureAndCoverage() throws Exception { + Path project = createMuleProjectWithWeakMunit(); + LanguageModelToolResult[] results = new MunitValidateFlowTestsTool() + .invoke(Map.of("projectPath", project.toString(), "flowName", "accounts-get-flow"), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("MUnit suite is missing the munit-tools namespace")); + assertTrue(output.contains("MUnit test appears to have no logical validation purpose")); + assertTrue(output.contains("Not all flow components are explicitly mocked, spied, verified, or asserted")); + assertTrue(output.contains("External connector calls are not mocked")); + } + + @Test + void munitFullReviewFindsScenarioAndAssertionQualityGaps() throws Exception { + Path project = createMuleProjectWithWeakMunit(); + LanguageModelToolResult[] results = new MunitFullReviewTool() + .invoke(Map.of("projectPath", project.toString(), "flowName", "accounts-get-flow"), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("Completed full MUnit review")); + assertTrue(output.contains("MUnit coverage is too shallow for the flow complexity")); + assertTrue(output.contains("MUnit tests do not obviously assert the response payload")); + assertTrue(output.contains("MUnit test name does not communicate the scenario")); + } + + @Test + void munitImprovementSuggestionsReturnsCadenceGuidance() throws Exception { + Path project = createMuleProjectWithWeakMunit(); + LanguageModelToolResult[] results = new MunitImprovementSuggestionsTool() + .invoke(Map.of("projectPath", project.toString(), "flowName", "accounts-get-flow"), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("recommendedCadence=positive, negative, edge, connector-failure")); + assertTrue(output.contains("Some flows have fewer than two MUnit scenarios")); + assertTrue(output.contains("For each external connector, mock success and failure")); + } + + @Test + void transformReadMatchesByNameCaseInsensitiveAndSubstring() throws Exception { + Path xml = createMuleProjectWithTransform(false); + + // Case-insensitive match + LanguageModelToolResult[] caseResult = new MuleTransformReadTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "map accounts"), null).get(); + assertEquals(ToolInvocationStatus.success, caseResult[0].getStatus()); + assertTrue(caseResult[0].getContent().get(0).getValue().contains("target=payload")); + + // Substring match (partial name) + LanguageModelToolResult[] subResult = new MuleTransformReadTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Accounts"), null).get(); + assertEquals(ToolInvocationStatus.success, subResult[0].getStatus()); + assertTrue(subResult[0].getContent().get(0).getValue().contains("target=payload")); + + // Non-matching name still fails + LanguageModelToolResult[] noMatch = new MuleTransformReadTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Non Existent Transform"), null).get(); + assertEquals(ToolInvocationStatus.error, noMatch[0].getStatus()); + assertTrue(noMatch[0].getContent().get(0).getValue().contains("No ee:transform element matched")); + } + + @Test + void dwlReadToolReturnsFileContentAndLineCount() throws Exception { + Path dwl = tempDir.resolve("normalize.dwl"); + Files.writeString(dwl, "%dw 2.0\noutput application/json\n---\npayload map (item -> item)"); + + LanguageModelToolResult[] results = new MuleDwlReadTool() + .invoke(Map.of("dwlFilePath", dwl.toString()), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("lines=4")); + assertTrue(output.contains("script:")); + assertTrue(output.contains("%dw 2.0")); + assertTrue(output.contains("payload map")); + } + + @Test + void dwlReadToolRejectsNonDwlFile() throws Exception { + Path xml = tempDir.resolve("test.xml"); + Files.writeString(xml, ""); + + LanguageModelToolResult[] results = new MuleDwlReadTool() + .invoke(Map.of("dwlFilePath", xml.toString()), null).get(); + + assertEquals(ToolInvocationStatus.error, results[0].getStatus()); + assertTrue(results[0].getContent().get(0).getValue().contains(".dwl")); + } + + @Test + void dwlWriteToolWritesScriptToFile() throws Exception { + Path dwl = tempDir.resolve("transform.dwl"); + Files.writeString(dwl, "%dw 2.0\noutput application/json\n---\npayload"); + String newScript = "%dw 2.0\noutput application/json\n---\npayload map (item -> { id: item.id })"; + + LanguageModelToolResult[] results = new MuleDwlWriteTool() + .invoke(Map.of("dwlFilePath", dwl.toString(), "dwlScript", newScript), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + assertEquals(newScript, Files.readString(dwl)); + } + + @Test + void dwlOptimizeToolDetectsMissingOutputDirective() throws Exception { + Path dwl = tempDir.resolve("bad.dwl"); + Files.writeString(dwl, "%dw 2.0\n---\npayload"); + + LanguageModelToolResult[] results = new MuleDwlOptimizeTool() + .invoke(Map.of("dwlFilePath", dwl.toString(), "includeComments", false, "applyFixes", false), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("missing-output-directive")); + assertTrue(output.contains("output application/json")); + } + + @Test + void dwlOptimizeToolDetectsNestedMapFilter() throws Exception { + Path dwl = tempDir.resolve("nested.dwl"); + Files.writeString(dwl, "%dw 2.0\noutput application/json\n---\npayload.a map (x -> payload.b filter (y -> y.id == x.id))"); + + LanguageModelToolResult[] results = new MuleDwlOptimizeTool() + .invoke(Map.of("dwlFilePath", dwl.toString(), "includeComments", false, "applyFixes", false), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("nested-map-filter")); + assertTrue(output.contains("groupBy")); + } + + @Test + void dwlOptimizeToolAppliesFixesWhenRequested() throws Exception { + Path dwl = tempDir.resolve("nocomment.dwl"); + String original = "%dw 2.0\noutput application/json\n---\nfun greet(name) = \"Hello \" ++ name\ngreet(payload.name)"; + Files.writeString(dwl, original); + + LanguageModelToolResult[] results = new MuleDwlOptimizeTool() + .invoke(Map.of("dwlFilePath", dwl.toString(), "includeComments", true, "applyFixes", true), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String written = Files.readString(dwl); + assertTrue(written.contains("// greet")); + } + + @Test + void dwlToolsExposeExpectedToolMetadata() { + assertEquals("mule_read_dwl_file", new MuleDwlReadTool().getToolInformation().getName()); + assertEquals("mule_write_dwl_file", new MuleDwlWriteTool().getToolInformation().getName()); + assertEquals("mule_optimize_dwl", new MuleDwlOptimizeTool().getToolInformation().getName()); + assertTrue(new MuleDwlReadTool().getToolInformation().getDescription().contains("read-only")); + assertTrue(new MuleDwlWriteTool().getToolInformation().getDescription().contains("%dw 2.0")); + assertTrue(new MuleDwlOptimizeTool().getToolInformation().getDescription().contains("groupBy")); + } + + @Test + void transformToolsExposeExpectedToolMetadata() { + assertEquals("mule_read_transform", new MuleTransformReadTool().getToolInformation().getName()); + assertEquals("mule_write_transform", new MuleTransformWriteTool().getToolInformation().getName()); + assertTrue(new MuleTransformReadTool().getToolInformation().getDescription().contains("set-attributes")); + assertTrue(new MuleTransformWriteTool().getToolInformation().getDescription().contains("variable:name")); + } + + @Test + void transformReadReturnsPayloadAttributesVariablesAndExternalDwl() throws Exception { + Path xml = createMuleProjectWithTransform(false); + LanguageModelToolResult[] results = new MuleTransformReadTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Map Accounts"), null).get(); + + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + String output = results[0].getContent().get(0).getValue(); + assertTrue(output.contains("target=payload")); + assertTrue(output.contains("target=attributes")); + assertTrue(output.contains("target=variable:customerId")); + assertTrue(output.contains("target=variable:externalVar")); + assertTrue(output.contains("resource=dw/external.dwl")); + assertTrue(output.contains("resourceStatus=resolved")); + assertTrue(output.contains("externalValue")); + } + + @Test + void transformWriteUpdatesPayloadAttributesAndVariables() throws Exception { + Path xml = createMuleProjectWithTransform(false); + String payloadScript = """ + %dw 2.0 + output application/json + --- + { updatedPayload: true } + """; + String attributesScript = """ + %dw 2.0 + output application/java + --- + attributes ++ { source: "test" } + """; + String variableScript = """ + %dw 2.0 + output application/java + --- + "updated-variable" + """; + + assertEquals(ToolInvocationStatus.success, new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Map Accounts", + "target", "payload", "dwlScript", payloadScript), null).get()[0].getStatus()); + assertEquals(ToolInvocationStatus.success, new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Map Accounts", + "target", "attributes", "dwlScript", attributesScript), null).get()[0].getStatus()); + assertEquals(ToolInvocationStatus.success, new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Map Accounts", + "target", "variable:customerId", "dwlScript", variableScript), null).get()[0].getStatus()); + + String updated = Files.readString(xml); + assertTrue(updated.contains("updatedPayload")); + assertTrue(updated.contains("attributes ++")); + assertTrue(updated.contains("updated-variable")); + } + + @Test + void transformWriteErrorsDoNotModifyXml() throws Exception { + Path xml = createMuleProjectWithTransform(true); + String original = Files.readString(xml); + String script = """ + %dw 2.0 + output application/json + --- + payload + """; + + LanguageModelToolResult[] ambiguous = new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "dwlScript", script), null).get(); + assertEquals(ToolInvocationStatus.error, ambiguous[0].getStatus()); + assertTrue(ambiguous[0].getContent().get(0).getValue().contains("Multiple ee:transform")); + assertEquals(original, Files.readString(xml)); + + LanguageModelToolResult[] missingTransform = new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Missing", + "target", "payload", "dwlScript", script), null).get(); + assertEquals(ToolInvocationStatus.error, missingTransform[0].getStatus()); + assertEquals(original, Files.readString(xml)); + + LanguageModelToolResult[] missingTarget = new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Map Accounts", + "target", "missingVar", "dwlScript", script), null).get(); + assertEquals(ToolInvocationStatus.error, missingTarget[0].getStatus()); + assertEquals(original, Files.readString(xml)); + + LanguageModelToolResult[] blankScript = new MuleTransformWriteTool() + .invoke(Map.of("xmlFilePath", xml.toString(), "transformName", "Map Accounts", + "target", "payload", "dwlScript", " "), null).get(); + assertEquals(ToolInvocationStatus.error, blankScript[0].getStatus()); + assertEquals(original, Files.readString(xml)); + } + + @Test + void mulesoftAgentAssetsExposeLocalTransformAndMcpTools() throws Exception { + Path repo = findRepoRoot(); + String anypointTemplate = Files.readString( + repo.resolve("com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md")); + String bundledAgent = Files.readString(repo.resolve( + "com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md")); + String munitPrompt = Files.readString(repo.resolve( + "com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/generate-munit-tests.prompt.md")); + + assertTrue(anypointTemplate.contains("- mule_read_transform")); + assertTrue(anypointTemplate.contains("- mule_write_transform")); + assertTrue(anypointTemplate.contains("- mulesoft/generate_or_modify_munit_test")); + assertFalse(anypointTemplate.contains("generate_or_modify_munit\n")); + assertTrue(bundledAgent.contains("- mule_read_transform")); + assertTrue(bundledAgent.contains("- mule_write_transform")); + assertTrue(bundledAgent.contains("- mulesoft/generate_or_modify_munit_test")); + assertTrue(munitPrompt.contains("- mulesoft/generate_or_modify_munit_test")); + } + + private Path createMuleProject() throws Exception { + Path project = tempDir.resolve("mule-app"); + Files.createDirectories(project.resolve("src/main/mule")); + Files.createDirectories(project.resolve("src/main/resources")); + Files.writeString(project.resolve("pom.xml"), """ + + 4.0.0 + example + mule-app + 1.0.0 + + + org.mule.connectors + mule-http-connector + 1.9.0 + + + + + + mule-maven-plugin + + + + + """); + Files.writeString(project.resolve("mule-artifact.json"), """ + {"minMuleVersion":"4.6.0"} + """); + Files.writeString(project.resolve("src/main/mule/api.xml"), """ + + + + + + + + + + """); + Files.writeString(project.resolve("src/main/resources/dev.properties"), """ + http.port=8081 + client.secret=literalSecret + """); + return project; + } + + private Path createMuleProjectWithWeakMunit() throws Exception { + Path project = createMuleProject(); + Files.createDirectories(project.resolve("src/test/munit")); + Files.writeString(project.resolve("src/main/mule/api.xml"), """ + + + + + + + + + + + + """); + Files.writeString(project.resolve("src/test/munit/accounts-test.xml"), """ + + + + + + + + + + """); + return project; + } + + private Path createMuleProjectWithTransform(boolean includeSecondTransform) throws Exception { + Path project = tempDir.resolve(includeSecondTransform ? "mule-transforms-two" : "mule-transforms-one"); + Files.createDirectories(project.resolve("src/main/mule")); + Files.createDirectories(project.resolve("src/main/resources/dw")); + Files.writeString(project.resolve("pom.xml"), """ + + 4.0.0 + example + mule-transform-app + 1.0.0 + + """); + Files.writeString(project.resolve("src/main/resources/dw/external.dwl"), """ + %dw 2.0 + output application/java + --- + "externalValue" + """); + String secondTransform = includeSecondTransform ? """ + + + + + + """ : ""; + Path xml = project.resolve("src/main/mule/api.xml"); + Files.writeString(xml, """ + + + + + + + + + + + + + ${SECOND_TRANSFORM} + + """.replace("${SECOND_TRANSFORM}", secondTransform)); + return xml; + } + + private Path findRepoRoot() { + Path current = Path.of("").toAbsolutePath(); + while (current != null) { + if (Files.isDirectory(current.resolve("com.microsoft.copilot.eclipse.anypoint/templates")) + && Files.isDirectory(current.resolve("com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github"))) { + return current; + } + current = current.getParent(); + } + throw new IllegalStateException("Unable to locate repository root from test runtime."); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializerTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializerTest.java index cf9c2db4..ddcc7da4 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializerTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializerTest.java @@ -39,4 +39,13 @@ void testInitializeDefaultPreferences_WhenAutoShowWhatsNewSetToFalse_ShouldRemai boolean autoShowWhatsNew = configPrefs.getBoolean(Constants.AUTO_SHOW_WHAT_IS_NEW, true); assertFalse(autoShowWhatsNew); } -} \ No newline at end of file + + @Test + void testInitializeDefaultPreferences_ShouldDisableConsoleContextByDefault() { + initializer.initializeDefaultPreferences(); + + boolean consoleContextEnabled = CopilotUi.getPlugin().getPreferenceStore() + .getDefaultBoolean(Constants.CONSOLE_CONTEXT_ENABLED); + assertFalse(consoleContextEnabled); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java index 2892f521..8cf28098 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java @@ -58,6 +58,7 @@ void testNoProxy() { settings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent().setAutoCompress(true); settings.getGithubSettings().getCopilotSettings().getAgent() .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); settings.getGithubSettings().getCopilotSettings().getAgent() @@ -92,6 +93,7 @@ void testBasicProxy() { settings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent().setAutoCompress(true); settings.getGithubSettings().getCopilotSettings().getAgent() .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); settings.getGithubSettings().getCopilotSettings().getAgent() @@ -130,6 +132,7 @@ void testUpdateConfigShouldBeCalledWhenWorkspaceInstructionsEnabledWithContent() CopilotSettings copilotSettings = new CopilotSettings(); copilotSettings.setWorkspaceCopilotInstructions("Test instructions"); copilotSettings.getAgent().setEnableSkills(PreferencesUtils.isSkillsEnabled()); + copilotSettings.getAgent().setAutoCompress(true); CopilotLanguageServerSettings settings = new CopilotLanguageServerSettings(); settings.getGithubSettings().setCopilotSettings(copilotSettings); settings.getGithubSettings().getCopilotSettings().getAgent() @@ -169,6 +172,7 @@ void testUpdateConfigShouldBeCalledWithoutInstructionWhenWorkspaceInstructionsDi expectedSettings.getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()) .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + expectedSettings.getGithubSettings().getCopilotSettings().getAgent().setAutoCompress(true); expectedSettings.getGithubSettings().getCopilotSettings().getAgent() .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); expectedSettings.getGithubSettings().getCopilotSettings().getAgent() diff --git a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF index 066f4400..39975d00 100644 --- a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF @@ -44,9 +44,9 @@ Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", org.eclipse.tm4e.ui;bundle-version="0.7.1", org.eclipse.tm4e.core;bundle-version="0.6.1", org.eclipse.tm4e.registry;bundle-version="0.6.5", - org.eclipse.mylyn.wikitext;bundle-version="3.0.48", - org.eclipse.mylyn.wikitext.markdown;bundle-version="3.0.48", - org.eclipse.mylyn.wikitext.ui;bundle-version="3.0.48", + org.eclipse.mylyn.wikitext;bundle-version="[3.0.48,4.5.0)", + org.eclipse.mylyn.wikitext.markdown;bundle-version="[3.0.48,4.5.0)", + org.eclipse.mylyn.wikitext.ui;bundle-version="[3.0.48,4.5.0)", org.eclipse.core.databinding.observable;bundle-version="1.13.100", org.eclipse.jface.databinding;bundle-version="1.15.100", com.google.gson;bundle-version="2.10.1", diff --git a/com.microsoft.copilot.eclipse.ui/build.properties b/com.microsoft.copilot.eclipse.ui/build.properties index 5ba08abe..c2b3cd06 100644 --- a/com.microsoft.copilot.eclipse.ui/build.properties +++ b/com.microsoft.copilot.eclipse.ui/build.properties @@ -8,4 +8,5 @@ bin.includes = META-INF/,\ css/,\ intro/,\ terminal-bundles/*.jar,\ - contexts.xml + contexts.xml,\ + mulesoft-copilot/ diff --git a/com.microsoft.copilot.eclipse.ui/css/dark.css b/com.microsoft.copilot.eclipse.ui/css/dark.css index 528204cb..86541db2 100644 --- a/com.microsoft.copilot.eclipse.ui/css/dark.css +++ b/com.microsoft.copilot.eclipse.ui/css/dark.css @@ -53,6 +53,8 @@ #chat-container > LoadingViewer *, #chat-container > BeforeLoginWelcomeViewer, #chat-container > BeforeLoginWelcomeViewer *, +#chat-container > NoSubscriptionViewer, +#chat-container > NoSubscriptionViewer *, #chat-content-wrapper, #chat-content-wrapper > ChatHistoryViewer, #chat-content-wrapper > ChatHistoryViewer *, @@ -72,14 +74,24 @@ #chat-action-bar, #chat-action-bar * { color: #DEE1E5; - background-color: #48484C; + background-color: #1E1F22; } #chat-action-bar CurrentReferencedFile > Label.text-secondary { color: #A4A4A4; } +StyledText.chat-input-text { + color: #DEE1E5; + background-color: #1E1F22; +} + +Text.copilot-preference-text-input { + color: #FFFFFF; + background-color: #1E1F22; +} + #chat-container > HandoffContainer, -#chat-container > HandoffContainer > * { +#chat-container > HandoffContainer * { background-color: #1E1F22; } #chat-container > HandoffContainer > Label.text-secondary { @@ -91,18 +103,18 @@ background-color: #3584F1; } -#chat-content-viewer > Composite > CopilotTurnWidget > InvokeToolConfirmationDialog > ScrolledComposite > .bg-command-panel { - background-color: #48484C; +#chat-content-viewer > Composite > CopilotTurnWidget > InvokeToolConfirmationDialog > .bg-command-panel { + background-color: #1E1F22; } #chat-content-viewer > Composite > CopilotTurnWidget > .subagent-message-block { - background-color: #48484C; + background-color: #1E1F22; } #chat-content-viewer > Composite > CopilotTurnWidget > .subagent-message-block > Composite, #chat-content-viewer > Composite > CopilotTurnWidget > .subagent-message-block > Composite > SubagentTurnWidget, #chat-content-viewer > Composite > CopilotTurnWidget > .subagent-message-block > Composite > SubagentTurnWidget * { - background-color: #48484C; + background-color: #1E1F22; } /* Override button and command panel styles inside subagent message block */ @@ -133,7 +145,7 @@ #chat-content-viewer > Composite > UserTurnWidget, #chat-content-viewer > Composite > UserTurnWidget * { color: #DEE1E5; - background-color: #26282B; + background-color: #1E1F22; } /* Chat history list items - dark theme */ diff --git a/com.microsoft.copilot.eclipse.ui/css/light.css b/com.microsoft.copilot.eclipse.ui/css/light.css index 25c39464..0e1f225d 100644 --- a/com.microsoft.copilot.eclipse.ui/css/light.css +++ b/com.microsoft.copilot.eclipse.ui/css/light.css @@ -45,7 +45,7 @@ #chat-top-banner, #chat-top-banner * { color: #808080; - background-color: #F8F8F8; + background-color: #FFFFFF; } #chat-container, @@ -53,6 +53,8 @@ #chat-container > LoadingViewer *, #chat-container > BeforeLoginWelcomeViewer, #chat-container > BeforeLoginWelcomeViewer *, +#chat-container > NoSubscriptionViewer, +#chat-container > NoSubscriptionViewer *, #chat-content-wrapper, #chat-content-wrapper > ChatHistoryViewer, #chat-content-wrapper > ChatHistoryViewer *, @@ -61,12 +63,12 @@ #chat-content-wrapper > AgentModeViewer, #chat-content-wrapper > AgentModeViewer *{ color: #808080; - background-color: #F8F8F8; + background-color: #FFFFFF; } #chat-action-bar-wrapper, #chat-action-bar-wrapper * { - background-color: #F8F8F8; + background-color: #FFFFFF; } #chat-action-bar, @@ -79,8 +81,8 @@ } #chat-container > HandoffContainer, -#chat-container > HandoffContainer > * { - background-color: #F8F8F8; +#chat-container > HandoffContainer * { + background-color: #FFFFFF; } #chat-container > HandoffContainer > Label.text-secondary { color: #808080; @@ -127,20 +129,25 @@ #chat-content-viewer > Composite, #chat-content-viewer > Composite * { color: #000000; - background-color: #F8F8F8; + background-color: #F1F1F2; } #chat-content-viewer > Composite > UserTurnWidget, #chat-content-viewer > Composite > UserTurnWidget * { color: #000000; - background-color: #EFEFEE; + background-color: #F1F1F2; +} + +#chat-content-viewer StyledText.chat-message-text, +#chat-content-viewer .chat-message-text { + background-color: #F1F1F2; } /* Chat history list items - light theme */ #chat-history-viewer Composite.chat-history-item, #chat-history-viewer Composite.chat-history-item * { color: #000000; /* default/hover exit foreground */ - background-color: #F8F8F8;/* default/hover exit background */ + background-color: #FFFFFF;/* default/hover exit background */ } /* Dropdown popup default state */ @@ -175,7 +182,7 @@ #file-row, #file-row * { color: #000000; /* default foreground */ - background-color: #F8F8F8;/* default background */ + background-color: #FFFFFF;/* default background */ } /* Hover state for file rows */ diff --git a/com.microsoft.copilot.eclipse.ui/css/macosx-light.css b/com.microsoft.copilot.eclipse.ui/css/macosx-light.css index 8c745e6d..ab0753d4 100644 --- a/com.microsoft.copilot.eclipse.ui/css/macosx-light.css +++ b/com.microsoft.copilot.eclipse.ui/css/macosx-light.css @@ -2,6 +2,11 @@ /* Licensed under the MIT license. */ /* macOS Light theme overrides */ +#chat-content-viewer StyledText.chat-message-text, +#chat-content-viewer .chat-message-text { + background-color: #F1F1F2; +} + #chat-history-viewer Label.chat-history-item-current-label { color: #FFFFFF; } @@ -14,4 +19,3 @@ color: #FFFFFF; background-color: #3786F6; } - diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md new file mode 100644 index 00000000..e0db9491 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md @@ -0,0 +1,108 @@ +--- +description: MuleSoft engineering assistant for API-led Mule 4 design, review, security, performance, and MUnit workflows. +tools: + - mule_project_scan + - api_schema_analyze + - mule_code_review + - mule_security_review + - mule_read_transform + - mule_write_transform + - mule_read_dwl_file + - mule_write_dwl_file + - mule_optimize_dwl + - munit_validate_flow_tests + - munit_full_review + - munit_improvement_suggestions + - summarize_mule_project + - get_mule_project_errors + - run_mule_maven_tests + - mulesoft/create_mule_project + - mulesoft/generate_mule_flow + - mulesoft/run_local_mule_application + - mulesoft/create_api_spec_project + - mulesoft/generate_api_spec + - mulesoft/implement_api_spec + - mulesoft/mock_api_spec + - mulesoft/search_asset + - mulesoft/dataweave_run_script_tool + - mulesoft/dataweave_create_sample_data + - mulesoft/dataweave_get_project_metadata + - mulesoft/dataweave_get_module_metadata + - mulesoft/dataweave_create_documentation + - mulesoft/generate_or_modify_munit_test + - mulesoft/deploy_mule_application + - mulesoft/update_mule_application + - mulesoft/list_applications + - mulesoft/create_and_manage_api_instances + - mulesoft/list_api_instances + - mulesoft/manage_api_instance_policy + - mulesoft/create_and_manage_assets + - mulesoft/get_reuse_metrics + - mulesoft/get_flex_gateway_policy_example + - mulesoft/manage_flex_gateway_policy_project + - mulesoft/create_install_runtime_fabric + - mulesoft/upgrade_runtime_fabric + - mulesoft/delete_runtime_fabric + - mulesoft/create_and_run_task + - mulesoft/get_platform_insights +--- +# MuleSoft Engineer + +You are assisting with a Mule 4 application in Anypoint Studio. Treat every suggestion as production integration code subject to integration contract, security, and performance requirements. + +Always run `mule_project_scan` before making claims about project structure. Use `api_schema_analyze` for RAML, OpenAPI, WSDL, XSD, JSON Schema, Avro, CSV, GraphQL, OData, and AsyncAPI contracts. Run `mule_code_review` and `mule_security_review` before recommending implementation changes. Prefer MuleSoft MCP tools for Anypoint Platform actions and local Studio tools for XML/project inspection. + +## API-Led Architecture +Three-layer model β€” preserve boundaries strictly: +- **Experience API**: Consumer-facing contract. Routes to Process APIs. Handles protocol, format, and consumer-specific transformation. +- **Process API**: Orchestrates business processes across multiple System APIs. Owns retry logic, aggregation, and error correlation. +- **System API**: One-to-one adapter for a single backend. Exposes backend capabilities in a standard REST/SOAP contract. No business logic. + +When generating flows: identify the correct layer, enforce that flow-refs and HTTP calls respect the hierarchy (Experience β†’ Process β†’ System, never upward), and reuse existing sub-flows before creating new ones. + +## Error Handling Contract +- All HTTP-facing flows must have `` with typed error matchers. Catch-all global handlers are a last resort, not the primary handler. +- Use `` only for truly optional, non-blocking steps (e.g., best-effort enrichment). +- Every error handler must log: `correlationId`, `flow.name`, `error.errorType`, `error.description` β€” in structured JSON, not string concatenation. +- Error responses must return a consistent JSON shape `{ "code", "message", "correlationId" }` with correct HTTP status codes. Never always return 500. +- Correlation ID: set at HTTP Listener from `X-Correlation-ID` header (fallback `uuid()`), stored as a flow variable, propagated in all outbound HTTP Request headers and all Logger calls. + +## Standalone DataWeave Module Files +- Use `mule_read_dwl_file` to read `.dwl` module files in `src/main/resources/dwl/` before editing or reviewing them. +- Run `mule_optimize_dwl` before rewriting a DWL module to surface performance issues (nested maps, inline regex, round-trip serialization), null-safety gaps, and missing output declarations. +- Use `mule_write_dwl_file` to update a `.dwl` module after confirming the optimized script with the user. +- Always run `mulesoft/dataweave_run_script_tool` after writing to validate the updated script against representative sample data. + +## DataWeave Standards +- Run `mule_read_transform` before editing any Transform Message. Use `mule_write_transform` only after confirming the target element and validating with diagnostics or Maven tests. +- Every script must declare `output` type. All optional field accesses must use `default`. Prefer `map`/`filter`/`reduce`/`groupBy` over imperative patterns. +- Flag nested maps over large collections (O(nΒ²)). Pre-index with `groupBy` and look up in O(1). +- Streaming: use `output application/json streaming=true` for payloads of unknown or large size. Streaming scripts cannot use `sizeOf()`, `[-1]`, or `reverse()`. +- Extract repeated DataWeave logic to `.dwl` modules in `src/main/resources/dwl/` and import with `import`. + +## Logging and Observability +- Log at INFO: flow entry/exit with `correlationId`, `flowName`, and key input identifiers. No full payloads at INFO. +- Log at ERROR: every `` with `correlationId`, `flowName`, `errorType`, `errorDescription`. No raw payload. +- Log at DEBUG: connector calls, DataWeave diagnostics. Must be disabled in production. +- Never log passwords, tokens, API keys, or PII fields without masking. +- Use structured JSON format in Logger `message` expressions. + +## Connector Governance +- Align connector versions with the Mule runtime compatibility matrix. Do not suggest connectors newer than `minMuleVersion` in `mule-artifact.json`. +- Database global configs: set `minPoolSize`, `maxPoolSize`, `maxWait`. HTTP Request configs: set `responseTimeout`. Flag any missing. +- Outbound HTTP: HTTPS only, TLS context configured, `insecure="true"` never allowed. +- Retry: `reconnect` with finite count/frequency. Flag `reconnect-forever` in production. +- Flag deprecated connectors: HTTP v1, File Connector v1, Scripting Module (Groovy/JS/Python). + +## Security Non-Negotiables +- All sensitive values use `${secure::property.name}`. All environment values use `${property.name}`. No inline values in XML. +- DB connector queries: parameterized only (`:variable` syntax). No string concatenation in query attributes. +- XPath expressions: no user-controlled input without sanitization. +- External XML parsing: secure parser settings required (no XXE). + +## MUnit +- Coverage required per public flow: happy path, invalid input (400), connector failure simulation, and error-response contract. +- Mock all external connectors by `doc:name` using `munit:mock-when`. Do not mock sub-flows. +- Cover every `` branch including otherwise. +- After generating: run `munit_validate_flow_tests`, then `run_mule_maven_tests`. Address all failures before declaring tests complete. +- Use `munit_full_review` for broad suite reviews and `munit_improvement_suggestions` to identify coverage cadence gaps. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-led-architecture-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-led-architecture-review.prompt.md new file mode 100644 index 00000000..62026712 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-led-architecture-review.prompt.md @@ -0,0 +1,47 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - api_schema_analyze +--- +# API-Led Architecture Review + +Run `mule_project_scan` on each project involved. Ask the user which API-led layer this project is intended to implement (Experience, Process, or System) if it is not obvious from the project name or flow names. + +## Layer Definitions +- **Experience API (xAPI)**: Consumer-facing. Exposes tailored endpoints for a specific channel (mobile, web, partner). Orchestrates by calling Process APIs. Must not call System APIs directly or other Experience APIs. +- **Process API (pAPI)**: Orchestration layer. Combines data from multiple System APIs to implement a business process. Must not be called by other Process APIs in a chain β€” flatten the orchestration instead. +- **System API (sAPI)**: One-to-one backend adapter. Exposes a single backend system (Salesforce, SAP, database) via a standard REST/SOAP interface. Contains no business logic. Must not call Process or Experience APIs. + +## Layer Identification +- **Experience API indicators**: APIkit router present, endpoint URLs contain consumer-oriented resources (e.g., `/orders`, `/profile`), response shapes tailored for a channel, HTTP listener on a public port. +- **Process API indicators**: Multiple outbound HTTP Request connectors calling different System APIs, aggregation/transformation logic, no direct backend connector (DB, Salesforce, JMS) calls. +- **System API indicators**: Exactly one backend connector (DB, Salesforce, MQ, SFTP, SAP), thin transformation layer, endpoint URLs closely mirror the backend resource names. + +## Call Direction Violations (Flag as High) +- Experience API calling a System API directly (skipping the Process layer) β€” couples consumer contracts to backend implementation details. +- System API calling another System API β€” creates hidden dependencies between backends. +- System API calling a Process API β€” inverts the dependency graph. +- Circular references: any flow-ref or HTTP call that eventually calls back into the same application. + +## How to Detect Call Direction +- From `mule_project_scan` output: check `connectors` list. If a project claims to be a System API but has multiple outbound HTTP connectors calling different hosts, it may be doing Process API work. +- Outbound HTTP Request configs pointing to internal API base URIs (e.g., `/api/v1/`) rather than backend systems suggest cross-API calls. Flag these for review. +- `mule_code_review` findings on duplicate global configs or duplicate flow logic often signal that System API responsibilities have leaked into Process/Experience layers. + +## Naming Conventions +- Flow names should reflect the layer: Experience APIs use consumer-action naming (`getProductsByCategory`), Process APIs use business-process naming (`processOrderFulfillment`), System APIs use backend-operation naming (`queryCustomerFromSalesforce`). +- API spec `title` and `version` should include the layer indicator: `Customer Experience API v2` vs `Customer System API v1`. +- Project name should follow the pattern: `--api` (e.g., `order-process-api`, `customer-system-api`). + +## Shared Resources +- Global connector configs (DB, Salesforce, MQ) belong in System APIs only. If a Process or Experience API contains connector configs for backend systems, the System API layer is missing. +- Shared DataWeave modules (`.dwl` files) used across layers should live in a separately versioned Exchange asset, not copied between projects. + +## APIkit Validation +- Run `api_schema_analyze` on the API spec. The spec should reflect the layer's consumer contract, not the backend data model. +- System API specs should closely mirror the backend resource vocabulary. Process/Experience API specs should use business vocabulary regardless of how backends name their data. + +## Output +Return: identified layer (or ambiguous if unclear), layer-specific findings (call direction violations, naming issues, connector placement), and recommended refactoring steps to restore layer boundaries. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-spec-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-spec-review.prompt.md new file mode 100644 index 00000000..a1ad2ce4 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-spec-review.prompt.md @@ -0,0 +1,48 @@ +--- +mode: agent +tools: + - api_schema_analyze + - mule_project_scan + - mulesoft/generate_api_spec + - mulesoft/create_and_manage_assets +--- +# API Specification Review + +Run `api_schema_analyze` on the target API spec file. If a Mule project is available, run `mule_project_scan` to cross-reference the spec against APIkit router flows. + +## Required Spec Metadata +- Title, version, and base URI (RAML: `title`, `version`, `baseUri`; OpenAPI: `info.title`, `info.version`, `servers`). +- Contact, license, and description fields should be present for published Exchange assets. +- Flag missing or placeholder values (e.g., `version: "1.0"` with no semantic versioning, `baseUri: http://example.com`). + +## Schema Quality +- Request and response bodies must reference named schema types, not inline anonymous objects. Inline schemas prevent reuse and make clients harder to generate. +- All reusable types should be defined in a `types` section (RAML) or `components/schemas` (OpenAPI) β€” not duplicated across endpoints. +- Required fields must be explicitly declared. Flag schemas with no `required` array (OpenAPI) or all-optional fields (RAML) on POST/PUT request bodies. +- Enums should be used for fields with a fixed value set. Avoid free-string fields where a constrained list is appropriate. + +## Examples +- Every request body and response body must have at least one example. Examples validate the spec is usable and enable mocking. +- Examples must be valid against their schema. Flag examples that do not match the declared types or required fields. + +## Error Responses +- All endpoints must declare at minimum: 400 (bad request), 401 (unauthorized), 404 (not found), 500 (internal error). +- Error response bodies should reference a shared error schema (e.g., `ErrorResponse` type) with fields `code`, `message`, and optionally `details`. +- Flag endpoints with only a 200 response defined β€” partial spec coverage misleads consumers. + +## Security Definitions +- A security scheme must be defined at the spec level: OAuth 2.0, API Key, or HTTP Basic. Flag specs with no security scheme. +- Security scheme must be applied to all non-public endpoints. Flag endpoints with no `securedBy` (RAML) or no `security` (OpenAPI). +- OAuth 2.0 scopes should be listed with descriptions. Generic `read`/`write` scopes are acceptable minimums, but resource-specific scopes are preferred. + +## APIkit Compatibility +- If a Mule project is available: compare spec endpoint list against APIkit router flow names. Flag spec endpoints missing a router flow and router flows missing a spec endpoint. +- RAML: verify that `baseUri` and `version` are compatible with the APIkit router configuration (`api.raml` path in router config). +- Flag RAML `uses:` references to Exchange libraries that are not pinned to a specific version β€” unversioned dependencies can break on Exchange republish. + +## Versioning +- API version must be in the URL path (e.g., `/v1/customers`) for REST APIs. Header-based versioning is acceptable but must be documented and consistently applied. +- Breaking changes (removing fields, changing types, renaming endpoints) require a new major version. Flag any spec that removes or narrows a previously defined field without a version bump. + +## Output +Return: contract summary (endpoints, schemas, security), governance findings by severity, APIkit compatibility issues if project was scanned, and specific recommendations for each finding. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/async-flow-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/async-flow-review.prompt.md new file mode 100644 index 00000000..89045f53 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/async-flow-review.prompt.md @@ -0,0 +1,54 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - munit_validate_flow_tests + - get_mule_project_errors +--- +# Async Flow Review + +Run `mule_project_scan` first. Check `schedulerFlows` for scheduler-triggered flows and `connectors` for VM or Anypoint MQ connectors. If neither is present, async patterns may still exist via async scopes β€” proceed with the review below. + +## Scheduler-Triggered Flows +- Scheduler flows must not block for long periods. All downstream connector calls should have timeouts configured. +- Flag scheduler flows without error handlers β€” an uncaught exception in a scheduler flow produces a `MULE:UNKNOWN` error with no response to return, making it silent unless logged. +- Scheduler flows with correlation IDs: since there is no inbound HTTP request, the correlation ID should be generated with `uuid()` at the start of the flow. +- Check `schedulerFlows` from the scan. For each scheduler flow: verify it has an error handler, a correlation ID set-variable, and a Logger at INFO on entry. + +## Async Scope (``) +- The `` scope runs its processors in a separate thread without blocking the main flow. Use when a side effect (audit log, notification) should not delay the response. +- Flag `` scopes that contain business-critical logic (DB writes, external API calls that the caller depends on) β€” if the async thread fails, the main flow is unaware. +- `` scopes cannot propagate errors to the parent flow. Any error handler inside `` must handle the error completely. Flag `` scopes with no internal error handler. +- Do not use `` to call downstream APIs where the caller needs a response β€” use synchronous flow-ref or HTTP request instead. + +## VM Connector Patterns +- VM queues are in-memory and not clustered by default in CloudHub. For production cross-application messaging, use Anypoint MQ or JMS instead. +- VM `publish` + `consume` within the same application is acceptable for decoupling processing stages. +- Flag VM `publish` without a corresponding VM listener flow β€” published messages will accumulate with no consumer. +- VM listeners must have error handlers. An unhandled error in a VM listener logs an error and discards the message with no retry. +- For reliable messaging: use `transactional="true"` on the VM publish if the source operation should roll back when the consumer fails. + +## Anypoint MQ Patterns +- Anypoint MQ listener flows must have error handlers. Unacknowledged messages return to the queue and will be redelivered (causing duplicate processing) if error handling does not ack or nack explicitly. +- Use `` on success and `` on failure when `acknowledgementMode="MANUAL"` is set. +- Flag MQ listener flows with `acknowledgementMode` not set (defaults to AUTO) where business logic can fail after ack β€” message loss risk. +- Dead-letter queues should be configured on the MQ destination for messages that fail after max redeliveries. + +## Thread Pool Impact +- Async scopes and MQ/VM listeners consume threads from the `IO` or `CPU_LITE` thread pool depending on the operation type. +- Heavy DataWeave transformations inside async scopes should be run in a `CPU_INTENSIVE` pool β€” wrap them in `` (Enterprise Edition) or annotate flows with `processingStrategy`. +- Flag async scopes with nested HTTP calls β€” these block an IO thread while waiting for the response. + +## Graceful Shutdown +- Scheduler flows stop automatically on application shutdown. For VM/MQ listeners, in-flight messages should complete before shutdown. Configure `shutdownTimeout` on the Mule runtime if processing time per message can exceed the default 5-second shutdown window. +- Flag applications with MQ listeners and no `shutdownTimeout` setting where message processing can take more than 5 seconds. + +## Testing Async Flows +- Scheduler-triggered flows: invoke directly with `munit:run-flow` β€” do not rely on the scheduler firing in tests. +- VM publish/consume: publish a test message to the VM queue in `munit:execution`, then use `munit:assert-that` on the side effect (DB record, variable) after a brief wait or `munit:run-flow` on the listener directly. +- Anypoint MQ: mock the MQ connector with `munit:mock-when` matching `doc:name`. Assert the downstream processing result. +- Verify correlation ID appears in Logger calls inside the async/listener flow using `munit-tools:verify-call`. + +## Output +Return findings grouped by pattern (scheduler, async scope, VM, MQ): missing error handlers, correlation ID gaps, thread pool risks, acknowledgement mode issues, and recommended test scenarios for each async entry point. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/batch-job-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/batch-job-review.prompt.md new file mode 100644 index 00000000..bd7dc84f --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/batch-job-review.prompt.md @@ -0,0 +1,55 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - mule_read_transform + - munit_validate_flow_tests + - run_mule_maven_tests +--- +# Batch Job Review + +Run `mule_project_scan` first. Check `hasBatchJob` in the scan output. If `hasBatchJob=false`, this prompt does not apply β€” inform the user there are no batch jobs in the project. If `hasBatchJob=true`, proceed with the review below. + +## Batch Job Structure +Every Mule 4 batch job must have: +- `` with a `name` attribute. +- `` phase: defines the data source (DB query, file read, MQ consumer). Must return an iterable collection β€” flag if the input produces a single non-iterable object. +- At least one `` with a meaningful `name`. +- `` phase: logs job summary (total records, success count, failure count). Flag if missing β€” silent batch completion makes production monitoring impossible. + +## Record Block Sizing (`maxRecordsPerBlock`) +- Default `maxRecordsPerBlock` is 100. For most integrations this is too small (low throughput) or too large (high memory per block). +- Rule of thumb: `maxRecordsPerBlock` Γ— (average record size in bytes) should not exceed 10 MB. + - For small records (< 1 KB): `maxRecordsPerBlock=500–1000` is appropriate. + - For large records (> 10 KB): `maxRecordsPerBlock=10–50` is safer. +- Flag jobs where `maxRecordsPerBlock` is not set (uses default 100 without intentional sizing). + +## Step-Level Error Handling +- Each `` should have an `acceptPolicy` or filter expression to skip records that fail validation before processing. +- Connector failures inside a step mark the record as failed. The job continues processing remaining records by default. Verify this behavior is intentional β€” flag steps where a connector failure should abort the entire job instead. +- Add `` inside the step scope when per-record failures should be tracked but not abort the job. Add `` when a single failure must stop all processing. +- The `` phase receives `batchJobInstanceId`, `loadedRecords`, `successfulRecords`, `failedRecords`, and `elapsedTimeInMillis`. All should be logged. + +## Batch Aggregator +- `` collects records into a buffer before writing. Use for bulk database inserts, bulk API calls, or file writes. +- Set `size` on the aggregator to match the target system's bulk operation limit (e.g., Salesforce upsert max 200 records, DB bulk insert batch size). +- For large aggregated payloads, use `streaming="true"` on the aggregator. Without streaming, the full buffer is materialized in memory. +- Flag aggregators without explicit `size` β€” they default to the full block which may exceed the target system's limit. + +## DataWeave Inside Batch +- Use `mule_read_transform` on Transform Message components inside batch steps. +- DataWeave transforms inside batch steps run per record. Flag: nested map over sub-collections (O(n) per record Γ— n records = O(nΒ²) total), regex compiled inline (compile outside the step via a variable), and `write()`/`read()` round-trips that are unnecessary. +- For large record fields, use `output application/json streaming=true` to avoid materializing the record in heap. + +## Testing Batch Jobs +- Unit-test individual batch steps by invoking the step's flow directly via `munit:run-flow` with a single fixture record. +- Integration-test the full batch job with a small fixture dataset containing: + - One valid record (verifies happy path). + - One record that triggers a step failure (verifies failure counting and `on-complete` logging). + - One boundary record (empty field, null, max-length string). +- Verify `On Complete` phase: assert `failedRecords` count and log output. +- Use `munit_validate_flow_tests` after generating tests. + +## Output +Return: batch job structure findings (missing on-complete, unsized blocks, unsized aggregators), step-level error handling gaps, DataWeave performance risks, and recommended MUnit fixture scenarios. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/connector-governance.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/connector-governance.prompt.md new file mode 100644 index 00000000..20fcd11b --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/connector-governance.prompt.md @@ -0,0 +1,47 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - mulesoft/search_asset +--- +# Connector Governance Review + +Run `mule_project_scan` to collect the Mule runtime version and the full list of connector dependencies from the POM. Then review global connector configurations in Mule XML. + +## Version Compatibility +- Every connector version must be compatible with the project's Mule runtime version. Mulesoft publishes a compatibility matrix on the documentation site. +- Flag connectors pinned to EOL versions: HTTP Connector v1 (use v2+), File Connector v1 (use File Connector v2), FTP Connector v1 (use FTP v2), Database Connector v1 (use DB Connector v8+). +- Minor version mismatches between connectors (e.g., HTTP 1.5.x used with Mule 4.4.x when 1.7.x is available) should be flagged as upgrade opportunities. +- Use `mulesoft/search_asset` to look up the latest patch version of a connector in Exchange when a version upgrade is recommended. + +## Redundant or Duplicate Connectors +- Flag POM dependencies that import two versions of the same connector (e.g., `mule-http-connector` appears twice at different versions). Only one version can be active at runtime. +- Flag Mule XML that defines multiple global HTTP Request configurations pointing to the same base URL/host β€” consolidate into one reusable config. +- Flag duplicate Database global configurations with identical JDBC URL. Each logical database should have exactly one global config. + +## Connection Pooling +- Database connector global configs must set `minPoolSize`, `maxPoolSize`, and `maxWait`. Missing pool config defaults to unlimited connections, which exhausts the DB under concurrent load. + - Recommended baseline: `minPoolSize=2`, `maxPoolSize=10`, `maxWait=5000` (adjust per load profile). +- JMS/ActiveMQ connector should set consumer thread count and prefetch appropriate to the processing throughput. +- HTTP Request config should set `maxConnections` and `connectionIdleTimeout` to prevent connection starvation. + +## Timeout and Retry Strategy +- Every HTTP Request connector config must have `responseTimeout` set. Flag configs with no timeout (defaults to no timeout, blocking threads indefinitely on upstream hang). +- Database operations that may run long (bulk inserts, complex queries) should use `queryTimeout` on the operation, not just rely on connection pool wait. +- Retry: `reconnection-strategy` with `reconnect` (finite retries) is appropriate for transient connectivity loss. Flag `reconnect-forever` in production β€” it can consume a thread indefinitely. +- `until-successful` scope for retry logic: `maxRetries` and `millisBetweenRetries` must always be set. Flag `until-successful` without both attributes. + +## Authentication Method Consistency +- HTTP connectors to the same upstream service should use the same authentication type. Flag a project where one flow uses OAuth Bearer to call Service X and another uses Basic Auth to the same service. +- Prefer OAuth 2.0 Client Credentials over Basic Auth for machine-to-machine integrations. Flag Basic Auth usages to external APIs where OAuth is available. +- API key authentication should use headers, not query parameters β€” query parameters appear in server access logs. Flag `apiKey` passed as a query parameter. +- Salesforce connector: prefer OAuth JWT Bearer (server-to-server) over username/password in production. Flag username/password OAuth flows if the project is production-bound. + +## Deprecated and Risky Connectors +- **Scripting Module (groovy/js/python scripts)**: Scripting components execute arbitrary code and are a security risk. Flag usage and recommend DataWeave or Java Module with a typed interface instead. +- **Java Module with `java:invoke-static`**: Calling static methods on third-party libraries bypasses Mulesoft's connector contract. Flag usage and note that library upgrades can silently break the integration. +- **VM Connector for cross-application communication**: VM queues are in-memory and not clustered by default in CloudHub. Use Anypoint MQ or JMS for reliable cross-application messaging. + +## Output +Return findings grouped by connector: connector name, version in use, recommended version, configuration issues, authentication issues, and specific XML attribute changes needed. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/dataweave-best-practices.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/dataweave-best-practices.prompt.md new file mode 100644 index 00000000..421536ae --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/dataweave-best-practices.prompt.md @@ -0,0 +1,53 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_read_transform + - mulesoft/dataweave_run_script_tool + - mulesoft/dataweave_get_project_metadata + - mulesoft/dataweave_create_documentation +--- +# DataWeave Best Practices Review + +Run `mule_project_scan` to find Transform Message components. Use `mule_read_transform` on each one before reviewing or rewriting DataWeave scripts. + +## Output Type Declaration +- Every DataWeave script must declare an output directive: `output application/json`, `output application/xml`, `output application/java`, etc. +- Missing output directives cause the runtime to infer type, which can produce unexpected results and suppress compile-time errors. +- Input types should be declared when the upstream content-type is ambiguous: `input payload application/json`. + +## Null Safety +- Use the `default` operator for every field access that may be absent: `payload.customer.name default "Unknown"`. +- For nested access chains, each level must be null-safe: `payload.order.items[0].price default 0`. +- Prefer `if (payload.field != null)` over `try(() -> payload.field) default null` β€” the latter silences real errors. +- Flag scripts that access payload fields without null guards when the input schema has optional fields. + +## Functional Patterns (prefer over imperative) +- Use `map` to transform each element of an array. Use `filter` to exclude elements. Use `reduce` to aggregate. Avoid `if/else` inside `map` when `filter` pre-passes the array. +- Use `groupBy` to index an array into a map keyed by a field β€” avoids O(nΒ²) nested `map` with inner `filter` lookups. +- Use `distinctBy` to deduplicate collections before processing. +- Flag `do { var ... }` patterns that re-compute the same expression inside a `map` on every iteration. Pre-compute to a variable outside the `map`. + +## Performance Anti-Patterns +- **Nested maps over large collections**: `arrayA map (a -> arrayB filter (b -> b.id == a.id))` is O(nΓ—m). Replace with `groupBy` on `arrayB` and then lookup inside `map` on `arrayA`. +- **Inline regex**: `payload map (item -> item.name matches /^[A-Z].*/)` compiles the regex on every iteration. Extract: `var namePattern = /^[A-Z].*/` and reference it in the map. +- **Unnecessary serialization**: `write(payload, "application/json")` followed immediately by `read(..., "application/json")` is a no-op round-trip. Remove it. +- **Large `output application/java` objects**: materializing large Java maps/lists loses streaming. Use `output application/json` and let the next connector handle deserialization. + +## Streaming for Large Payloads +- When processing payloads where size is unknown at design time (file processing, DB result sets, API pagination), use DataWeave streaming: `output application/json streaming=true`. +- Streaming transforms cannot use `sizeOf()`, `[-1]` (last element), or `reverse()` since these require the full collection in memory. Flag these operations in streaming transforms. +- Batch jobs processing large files should use `` with `` reading from a streaming source rather than loading the full file into payload. + +## Modularity and Reuse +- Repeated DataWeave logic (date formatting, error response building, field masking) should be extracted to a DataWeave module (`.dwl` file in `src/main/resources/dwl/`) and imported with `import` directive. +- Flag copy-pasted DataWeave snippets that appear in 3 or more Transform Message components β€” these are candidates for a shared module. +- Use `mulesoft/dataweave_create_documentation` to document complex module functions. + +## Type Safety and Documentation +- Complex scripts should document the expected input type as an inline comment: `// Input: { orderId: String, items: Array<{sku: String, qty: Number}> }`. +- Use named types in DataWeave type system for shared structures: `type OrderItem = { sku: String, qty: Number }`. +- When using `mulesoft/dataweave_run_script_tool` to test a script, always test with: a valid input, a null/empty input, and a malformed input to verify null-safety and error handling. + +## Output +Return findings per Transform Message component: component name/ID, file reference, issues found, corrected DataWeave snippet, and a test command using `mulesoft/dataweave_run_script_tool` to validate the fix. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/dataweave-optimize.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/dataweave-optimize.prompt.md new file mode 100644 index 00000000..d3bc2a97 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/dataweave-optimize.prompt.md @@ -0,0 +1,68 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_read_dwl_file + - mule_write_dwl_file + - mule_optimize_dwl + - mule_read_transform + - mule_write_transform + - mulesoft/dataweave_run_script_tool + - mulesoft/dataweave_create_documentation +--- +# DataWeave Optimization + +Scan, analyze, and optimize all DataWeave scripts in the project β€” both inline Transform Message +components and standalone `.dwl` module files. + +## Workflow + +1. Run `mule_project_scan` to inventory the project. Identify: + - All Mule XML files with `ee:transform` components + - All `.dwl` module files in `src/main/resources/dwl/` + +2. For each **standalone `.dwl` module**: + - Read with `mule_read_dwl_file` + - Run `mule_optimize_dwl` (includeComments=true, applyFixes=false) to preview issues + - Present findings to the user with type, line, description, and suggested fix + - If the user approves, apply with `mule_optimize_dwl` (applyFixes=true) or `mule_write_dwl_file` + - Validate with `mulesoft/dataweave_run_script_tool` using valid, null, and malformed sample inputs + +3. For each **inline Transform Message** component: + - Read with `mule_read_transform` (use transformName or transformId to target specific components) + - Apply the same optimization checks + - If issues found and user approves, write with `mule_write_transform` + - Validate with `mulesoft/dataweave_run_script_tool` + +## Optimization Priorities (in order) + +1. **Performance** β€” most impactful changes first: + - Nested `map`+`filter` β†’ pre-index with `groupBy`, look up in O(1) + - Inline regex literals inside `map`/`filter` β†’ extract to `var` before the map + - Round-trip `write()`/`read()` serialization β†’ remove the no-op pair + +2. **Null safety** β€” prevents runtime `NullPointerException` equivalents: + - Every optional field access must use `default`: `payload.field default ""` + - Nested chains: each level must be guarded: `payload.order.items[0].price default 0` + +3. **Output directive** β€” prevents runtime type inference issues: + - Every script must start with `%dw 2.0` and declare `output application/json` (or the correct type) + - Input types should be declared when upstream content-type is ambiguous + +4. **Streaming** β€” for large or unknown-size payloads: + - Suggest `output application/json streaming=true` when the script maps over a potentially large array + - Warn if `sizeOf()`, `[-1]`, or `reverse()` are used in a streaming context + +5. **Documentation** β€” for maintainability: + - Add `//` or `/** */` comments before undocumented `fun` declarations + - Describe: purpose, parameters, return type + - Flag copy-pasted logic appearing in 3+ transforms as a module extraction candidate + +## Output Format + +For each file reviewed, report: +- File path and component name/ID +- Number of issues found +- For each issue: type, line number, description, suggested fix +- The optimized script (preview or applied) +- Validation command to run with `mulesoft/dataweave_run_script_tool` diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/deployment-readiness.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/deployment-readiness.prompt.md new file mode 100644 index 00000000..10fa43c6 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/deployment-readiness.prompt.md @@ -0,0 +1,55 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - mule_security_review + - run_mule_maven_tests + - mulesoft/list_applications + - mulesoft/deploy_mule_application + - mulesoft/update_mule_application + - mulesoft/get_platform_insights +--- +# MuleSoft Deployment Readiness + +Run `mule_project_scan` to establish the baseline. Run `mule_code_review` and `mule_security_review`. Then run `run_mule_maven_tests` to confirm MUnit status. Ask the user for the target platform (CloudHub 1.0, CloudHub 2.0 / Runtime Fabric, or standalone/on-prem) to tailor checklist items. + +## Universal Prerequisites +- `mule-artifact.json` present with correct `minMuleVersion` and `classLoaderModelLoaderDescriptor`. +- `pom.xml` has the correct Mule Maven Plugin version compatible with the target runtime. Flag `mule-maven-plugin` versions that do not match the runtime major version. +- All MUnit tests pass (`run_mule_maven_tests`). Blocking failures must be resolved before deployment. +- No hardcoded secrets in any Mule XML, property file, or POM (confirmed by `mule_security_review`). +- All environment-specific properties externalized to `config-.yaml` or `.properties` with `${property}` placeholders. Flag any `config-default.yaml` values that look environment-specific (URLs, ports, hostnames). +- Log level set to `INFO` or `WARN` in the production logging profile. Flag `DEBUG` or `TRACE` in the default log4j2 config. + +## Health Endpoints +- Every application must expose a health check endpoint (e.g., `GET /health` or `GET /status`) returning HTTP 200 with at minimum `{"status": "UP", "version": ""}`. +- The health endpoint must respond within 2 seconds under normal load. Flag health implementations that call downstream services synchronously without a timeout guard. +- Flag missing health endpoints β€” deployment platforms use them for liveness and readiness probes. + +## CloudHub 1.0 Specific +- Worker type and count must be configured in the deployment descriptor or Anypoint Platform. Recommend minimum `Medium` (1 vCore) for production flows. Flag `Micro` for anything other than dev/test. +- Persistent queues should be enabled for flows using VM connector or Anypoint MQ when message loss is unacceptable. +- Static IP should be requested in advance if the app connects to IP-allowlisted upstream services. + +## CloudHub 2.0 / Runtime Fabric Specific +- `resources.cpu.reserved`, `resources.cpu.limit`, `resources.memory.reserved`, `resources.memory.limit` must be set in the deployment descriptor. Flag missing resource specifications. +- Replicas should be 2+ for HA in production. Flag single-replica production deployments. +- Liveness and readiness probe paths should point to the health endpoint. Flag if not configured. +- Ingress TLS must be terminated at the ingress controller. Flag HTTP-only ingress configurations. + +## Standalone / On-Premises Specific +- Mule runtime installed at the correct version matching `minMuleVersion`. Flag version mismatches. +- Cluster configuration required for HA: `` settings in `wrapper.conf` or via Management Center. +- JVM heap sizing: `-Xms` and `-Xmx` configured appropriate to worker memory. Flag default JVM settings (256 MB) for production workloads. +- Application hot-deployment path confirmed writable by the Mule process user. + +## Smoke Test Checklist +After deployment: +1. Health endpoint returns `{"status": "UP"}` β€” verify manually or via curl. +2. Main API endpoint returns expected response to a known-good test request. +3. Log output shows application startup completion without ERROR lines. +4. Anypoint Monitoring or CloudWatch shows response time < SLA threshold within 5 minutes of startup. + +## Output +Return a deployment readiness score (ready / conditional / blocked), a checklist of passed and failed items grouped by category, and any blocking issues that must be resolved before deployment proceeds. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/error-handling-contract.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/error-handling-contract.prompt.md new file mode 100644 index 00000000..19843a08 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/error-handling-contract.prompt.md @@ -0,0 +1,57 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - get_mule_project_errors +--- +# Error Handling Contract Review + +Run `mule_project_scan` first. The scan now returns `flowErrorHandlerTypes` (typed/catch-all/none per flow) and `flowsWithCorrelationId`. Use these to focus the review on flows that are missing typed handlers or correlation IDs. + +## Required Error Handler Presence +- Every flow exposed via HTTP Listener, Anypoint MQ listener, or Scheduler must have its own `` or ``. A global default error handler is a fallback, not a substitute. +- Flag flows where `flowErrorHandlerTypes` shows `"none"` β€” these will expose raw Mule stack traces on failure. +- Flag flows where `flowErrorHandlerTypes` shows `"catch-all"` β€” catch-all handlers mask errors and make debugging difficult. Typed handlers are required. + +## On Error Propagate vs. On Error Continue +- ``: re-throws the error after the handler runs. Use for all HTTP-facing flows β€” the caller needs a proper error response. +- ``: swallows the error and lets the flow complete "successfully." Use only when failure of a step is truly optional (e.g., best-effort audit logging, non-critical enrichment). Never use as a default catch-all. +- Flag any `` without a `type` attribute β€” this is a catch-all that silences all errors. + +## Typed Error Matching +- Error handlers must declare typed matchers: `type="HTTP:CONNECTIVITY"`, `type="DB:QUERY_EXECUTION"`, `type="MULE:EXPRESSION"`, etc. +- Multiple types can be combined with a comma: `type="HTTP:CONNECTIVITY, HTTP:RESPONSE_VALIDATION"`. +- Flag `` or `` elements with no `type` attribute on HTTP-facing flows. +- Common Mule 4 error type namespaces: `HTTP`, `DB`, `SALESFORCE`, `JMS`, `VM`, `MULE`, `APIKIT`, `VALIDATION`. + +## Correlation ID in Error Handlers +- Every error handler in an HTTP-facing flow must log the correlation ID. Without it, production incidents cannot be traced across systems. +- Check that `flowsWithCorrelationId` from the scan includes all public flows. If a flow is missing from that set, flag it. +- Required Logger format in error handlers: + ``` + #[output application/json --- { + "event": "flowError", + "flowName": flow.name, + "correlationId": vars.correlationId default "none", + "errorType": error.errorType, + "errorMessage": error.description + }] + ``` + +## Consistent Error Response Shape +- All HTTP-facing `` handlers must set the HTTP status code explicitly via `` or via an `` that sets the appropriate status variable. +- Error responses must follow a consistent JSON shape: + ```json + { "code": "ERROR_TYPE", "message": "Human-readable message", "correlationId": "..." } + ``` +- Flag flows that return raw Mule error descriptions (`error.description`) directly as the response body β€” these expose internal stack trace fragments to API consumers. +- HTTP status code mapping: validation errors β†’ 400, auth failures β†’ 401/403, not found β†’ 404, connector failures β†’ 503, unexpected β†’ 500. Never always return 500. + +## Global Error Handlers +- Global `` elements are acceptable as a last-resort fallback (catches errors not caught by flow-level handlers). +- The global handler should log with correlation ID and return a 500 response. It should NOT be the primary handler for known error types. +- Flag projects where the only error handler is a global one β€” this indicates no per-flow error handling exists. + +## Output +Return findings grouped by flow: flow name, current error handler type (from scan data), missing typed matchers, correlation ID gap, and the corrected error handler XML snippet. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/generate-munit-tests.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/generate-munit-tests.prompt.md new file mode 100644 index 00000000..03c505fb --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/generate-munit-tests.prompt.md @@ -0,0 +1,61 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - mule_read_transform + - munit_validate_flow_tests + - munit_full_review + - munit_improvement_suggestions + - get_mule_project_errors + - run_mule_maven_tests + - mulesoft/generate_or_modify_munit_test +--- +# Generate MUnit Tests + +Run `mule_project_scan` to identify flows and existing MUnit suites. Run `munit_validate_flow_tests` on existing suites first to understand current coverage gaps. Then use `mulesoft/generate_or_modify_munit_test` to create or update tests. + +## Required Coverage for Every Flow +- **Happy path**: Valid input, all connectors succeed, expected payload/variable in response. +- **Negative path**: Invalid or missing input returning correct error response (correct HTTP status and error body). +- **Error path**: Simulated connector failure (e.g., `munit:mock-when` with `thenFail`) verifying On Error Propagate behavior and error response shape. +- **Boundary/edge data**: Empty collections, null optional fields, maximum length strings, zero-value numerics. + +## Mocking Strategy +- Mock every external connector call: HTTP Request, Database, Salesforce, MQ, etc. Use `munit:mock-when` with `munit:with-attributes` to match the specific processor by `doc:name` or flow path. +- Do NOT mock sub-flow calls β€” test sub-flows through the parent flow invocation. Mock only connectors that reach outside the Mule runtime. +- For scheduler-triggered flows, use `munit:run-flow` to invoke the flow directly; do not rely on scheduler firing in tests. + +## Choice Router Branch Coverage +- Each `` router requires one test per when-condition plus one test for the otherwise branch. +- Flag test suites that cover the default route only or only a subset of branches. + +## Scatter-Gather Testing +- Each route in a scatter-gather must be independently mocked. Verify the aggregated payload contains contributions from all routes. +- Add one test with a failing route to confirm the scatter-gather error handler behavior. + +## Batch Job Testing +- Unit-test individual Batch Step flows in isolation via `munit:run-flow`. +- Integration-test the full batch job with a small fixture dataset (3–5 records) including: one valid record, one record that triggers a step failure, and one boundary record. +- Verify the On Complete phase logging and output variables. + +## Async Flow Testing +- Flows using VM Publish-Consume or async scopes: use `munit:run-flow` and then poll/assert the VM queue or output variable with a reasonable timeout. +- For purely async flows (VM Publish with no response), assert side effects: DB records written, MQ messages published (via mock verify-call), or variables set. + +## Transactional Flows +- Test rollback: mock the second connector in a try scope to fail, verify the first connector's write was rolled back (assert mock was called, DB record not committed). +- Verify the error handler returns the correct HTTP status and body when a transaction rolls back. + +## Correlation ID Propagation +- Every test that simulates an inbound HTTP request should set a `MULE_CORRELATION_ID` attribute on the mock message source. +- Assert that Logger calls within the flow include the correlation ID in structured output. + +## Test Naming Convention +- Use descriptive test names that state intent: `given_validRequest_when_getCustomer_then_returns200`, or shorter `getCustomer_validId_returns200`. +- Avoid names like `test1`, `happyPath`, or the flow name alone. + +## Validation +- After generating, validate the suite with `munit_validate_flow_tests`. Address any missing MUnit namespaces, munit:config, execution, assertion, or mock issues before finalizing. +- Run `run_mule_maven_tests` to confirm all tests pass. +- Include the Maven command to run only this suite: `mvn test -Dmunit.test=.xml`. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/logging-observability.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/logging-observability.prompt.md new file mode 100644 index 00000000..b5be5b73 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/logging-observability.prompt.md @@ -0,0 +1,54 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - mulesoft/get_platform_insights +--- +# Logging and Observability Review + +Run `mule_project_scan` to identify flows, connectors, and log4j2 configuration. Run `mule_code_review` with reviewType `logging`. Use `mulesoft/get_platform_insights` to check available monitoring metrics in Anypoint Platform. + +## Correlation ID Strategy +- Correlation IDs enable distributed tracing across systems. Every inbound HTTP Listener must set a correlation ID at the source: use the `X-Correlation-ID` request header if present, otherwise generate one with `uuid()`. + - Example: `` +- The correlation ID must be propagated in all outbound HTTP Request calls as a request header: `X-Correlation-ID: #[vars.correlationId]`. +- Log the correlation ID at flow entry and in all error handlers. Flag Logger components inside main flows that do not include `correlationId`. + +## Log Levels +- **ERROR**: Unexpected exceptions that terminate a flow or cause data loss. Includes connector failures after retries exhausted, unhandled exceptions. +- **WARN**: Recoverable issues that may indicate misconfiguration or degraded behavior: retry attempts, missing optional headers, slow upstream responses. +- **INFO**: Flow lifecycle events: entry and exit of public flows with key metadata (correlation ID, input record count, operation name). Should NOT include full payloads. +- **DEBUG**: Connector call details, DataWeave input/output for diagnostic purposes. Must be disabled in production. +- Flag Logger components using `INFO` inside ``, ``, or Batch Steps β€” these produce one log entry per record at high volume. Log at entry/exit of the outer flow instead with count metadata. + +## Structured Logging Format +- Log messages should be structured JSON strings rather than free text, to enable log aggregation and search. + - Preferred: `{"event":"flowEntry","flowName":"getCustomerFlow","correlationId":"#[vars.correlationId]","inputId":"#[payload.customerId]"}` + - Avoid: `"Processing customer " ++ payload.customerId ++ " for request " ++ vars.correlationId` +- All Logger `message` expressions should use DataWeave to build a JSON object, not string concatenation. +- Flag Logger messages that include raw `payload` or `attributes` objects β€” these log the entire request/response body including potentially sensitive fields. + +## PII and Secrets in Logs +- Flag Logger components that log `payload` fields containing: names, email addresses, phone numbers, SSNs, credit card numbers, passwords, tokens, or API keys. +- Use field masking in the log message DataWeave: `output application/json --- { "email": payload.email[0..2] ++ "***" }`. +- The `mule_security_review` tool flags hardcoded secrets; this review focuses on runtime log data. + +## Error Handler Logging +- Every `` and `` should include a Logger at ERROR or WARN level with: correlation ID, flow name, error type, and a summary message. Do NOT log the full error payload. +- Flag error handlers with no Logger component β€” silent error handling makes production diagnosis impossible. +- Recommended error log format: `{"event":"flowError","flowName":"#[flow.name]","correlationId":"#[vars.correlationId default 'none']","errorType":"#[error.errorType]","errorMessage":"#[error.description]"}` + +## Log4j2 Configuration +- Production `log4j2.xml` should set root logger to `INFO`. Flag `DEBUG` or `TRACE` at root level β€” these flood logs with Mule internals. +- CloudHub log forwarding to external aggregators (Splunk, ELK) requires the async appender to be configured. Flag missing async appender for high-throughput applications. +- JSON layout appender preferred for machine-parseable logs: `` or similar. + +## Anypoint Monitoring and Metrics +- Use `mulesoft/get_platform_insights` to verify that the application has Anypoint Monitoring enabled in the target environment. +- Flag applications deployed without Anypoint Monitoring β€” no visibility into response times, error rates, or connector health. +- Custom metrics can be emitted from Mule flows using the Anypoint Monitoring Custom Metrics feature. Recommend adding custom metrics for: processing time per record, error counts by type, integration payload sizes. +- CloudHub 2 and Runtime Fabric deployments should also expose a `/metrics` endpoint compatible with Prometheus scraping if the operations team uses Prometheus/Grafana. + +## Output +Return findings grouped by category: correlation ID gaps, log level violations, PII/secrets exposure risks, missing error handler logging, log4j2 configuration issues, and monitoring gaps. Include the specific Logger or flow element reference, the issue, and the corrected Logger message expression. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-code-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-code-review.prompt.md new file mode 100644 index 00000000..2227206e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-code-review.prompt.md @@ -0,0 +1,40 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - mule_read_transform + - get_mule_project_errors +--- +# MuleSoft Code Review + +Run `mule_project_scan` first to establish the project baseline. Then run `mule_code_review` across Mule XML, DataWeave, properties, API specs, MUnit suites, and POM metadata. + +## Flow Structure +- Flows should use camelCase verb-noun naming (e.g., `getCustomerByIdFlow`, `postOrderFlow`). Sub-flows use the same convention with a descriptive qualifier. +- Prefer sub-flows for reusable logic called from multiple flows. Use private flows only for asynchronous branching (async scope, VM). +- Every flow exposed via HTTP or a message source must have an On Error Propagate at the flow level with at least one specific error type. Do not rely solely on global default error handlers. +- On Error Continue is appropriate only when the flow must complete successfully despite the error (e.g., optional enrichment steps). On Error Propagate re-throws and should be the default for public-facing flows. +- Correlation IDs must be set at the HTTP Listener or message source (e.g., `correlationId` attribute), logged at flow entry, and propagated through all flow-refs and async calls. + +## Global Configuration +- No duplicate global configs. One ``, one ``, etc. per logical target. Duplicates cause confusion and runtime precedence issues. +- All sensitive values (passwords, tokens, client secrets) must use `${secure::property.name}` β€” never plain `${property.name}` and never hardcoded values in XML. +- All environment-specific values (hosts, ports, paths) must use `${property.name}` with corresponding `config-.yaml` or `.properties` files. + +## DataWeave +- Read DataWeave scripts with `mule_read_transform` before recommending changes. +- Output type must be declared (`output application/json`, `output application/xml`, etc.). +- Null-safe patterns required: use `default` operator for optional fields (e.g., `payload.name default "Unknown"`). +- Prefer `map`, `filter`, `reduce` over `if/else` imperative patterns. Flag nested `map` calls on large collections as performance risks. + +## MUnit Coverage +- Every public flow (HTTP listener, scheduler, connector source) should have at least one MUnit test. +- Flag flows with zero test coverage. Flag suites that test only happy-path without any error-path or connector-failure scenario. + +## APIkit +- If APIkit router is present, verify every endpoint in the RAML/OpenAPI spec has a corresponding router flow (`get:\resource:api-config` naming pattern). +- Flag router flows that exist in XML but have no corresponding spec endpoint (orphaned routes). + +## Output +Prioritize findings as critical, high, medium, low. For each finding: file reference, line or element, issue, recommended fix, and a validation command (e.g., Maven test or Studio validation). diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-performance-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-performance-review.prompt.md new file mode 100644 index 00000000..238fe828 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-performance-review.prompt.md @@ -0,0 +1,48 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_code_review + - mule_read_transform +--- +# MuleSoft Performance Review + +Run `mule_project_scan` first to identify the runtime version, connectors, and batch jobs. Then run `mule_code_review` with reviewType `performance`. Use `mule_read_transform` to inspect DataWeave scripts in Transform Message components. + +## DataWeave and Payload Handling +- Payloads larger than 1 MB should use streaming rather than materializing the full payload in memory. Flag any `output application/json` or `output application/xml` transforms that do not set `streaming=true` where size is unknown at design time. +- Flag nested `map` calls over large collections. These are O(nΒ²) or worse. Prefer a single-pass `map` with a nested `reduce` or a lookup map pre-built with `groupBy`. +- `write()` and `read()` calls that convert between formats unnecessarily inflate memory. Flag DataWeave that serializes then immediately re-parses. +- Flag inline regex patterns inside `map` loops β€” compile regex outside the loop via a variable. + +## Batch Processing +- Batch job step size (records per block) should balance memory pressure and throughput. The default (100) is often too small for high-volume jobs and too large for memory-constrained runtimes. Recommend reviewing `maxRecordsPerBlock` against the payload size per record. +- Flag Batch Aggregator steps without explicit `streaming="true"` when processing large result sets. +- Flag Batch jobs that have no `On Complete` phase logging β€” without it, failures are silent. + +## Concurrency and Threading +- `maxConcurrency` on a flow defaults to the listener thread pool. For CPU-intensive DataWeave, set `maxConcurrency` to number of CPU cores; for IO-bound flows (HTTP, DB), allow higher values. +- Scatter-gather with `maxConcurrency` equal to the number of routes is fine for small sets. Flag scatter-gather where `maxConcurrency` is not set and route count is dynamic or could exceed 20. +- `until-successful` without `maxRetries` and `millisBetweenRetries` defaults can cause threads to block indefinitely. Flag unconfigured `until-successful`. + +## Connector Configuration +- HTTP Request configs without explicit `responseTimeout` and `connectionIdleTimeout` will hold threads open on slow upstreams. Both should be set. +- Database connector configs should have `minPoolSize`, `maxPoolSize`, and `maxWait` configured. Default unlimited pooling exhausts DB connections under load. +- JMS/ActiveMQ connector should have prefetch and consumer count tuned to match processing throughput. +- Flag any connector using `reconnect-forever` without a `blocking="false"` strategy β€” this can deadlock the flow dispatcher thread. + +## Database Queries +- Flag N+1 query patterns: a `` inside a `` or `` over a collection. Prefer a single bulk query with `IN (...)` or a join. +- Flag missing pagination on `` queries that could return unbounded row counts. Use `LIMIT`/`OFFSET` or cursor-based pagination. +- Flag `fetchSize` not set on large result sets β€” defaults can cause full result materialization in the JDBC driver. + +## Logging Volume +- Logger components in tight loops (inside ``, ``, Batch steps) at INFO or DEBUG level produce enormous log volume under load. Log entry/exit of the outer flow instead. +- Flag full payload logging at INFO β€” use DEBUG and structured field extraction instead. + +## Caching +- Flag repeated calls to the same external API or DB within a single request that return static or slowly-changing data. Recommend Mule Cache Scope (``) with an appropriate TTL. +- Distributed cache (e.g., Redis via Object Store v2) should be used for clustered deployments. In-memory cache is invalidated on worker restart and inconsistent across CloudHub workers. + +## Output +Return prioritized findings (critical, high, medium, low), tuning recommendations with specific configuration values, key metrics to monitor (response time, GC pressure, thread pool saturation, connector pool wait), and a suggested load test scenario for the highest-risk flow. diff --git a/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-security-review.prompt.md b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-security-review.prompt.md new file mode 100644 index 00000000..c0a9c8f1 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-security-review.prompt.md @@ -0,0 +1,44 @@ +--- +mode: agent +tools: + - mule_project_scan + - mule_security_review + - api_schema_analyze + - mulesoft/list_api_instances + - mulesoft/manage_api_instance_policy +--- +# MuleSoft Security Review + +Run `mule_project_scan` first, then `mule_security_review`. Analyze Mule XML, property files, POM metadata, and API contracts. + +## Credentials and Secrets +- All sensitive values (passwords, tokens, client secrets, API keys, certificates) must use `${secure::property.name}` with the Mule Secure Configuration Properties module. Never `${plain.property}` for secrets, never inline values. +- Check `mule-artifact.json` and POM for the `mule-secure-configuration-property-module` dependency. Flag if missing. +- Flag any property file (`.yaml`, `.properties`) that contains values matching secret patterns (password, secret, token, apikey, clientsecret, privatekey). These should be encrypted or externalized. + +## Injection Attacks (Mule-Specific) +- **XPath injection**: Any flow using XPATH expressions (e.g., `xpath3()` function, XSLT, XQuery) with user-controlled input must sanitize or parameterize. Hardcoded XPath is safe; concatenated XPath with `attributes.queryParams` or `payload` fields is not. +- **XML External Entity (XXE)**: Flows that parse XML using DataWeave or Java invoke must use secure parser settings. Flag any `java:invoke` or `java:new` calls on XML parsers without explicit `FEATURE_SECURE_PROCESSING` or equivalent. +- **SQL injection**: All Database connector operations must use parameterized queries (`:variable` syntax). Flag any `` or `` where the query attribute concatenates flow variables or payload fields as strings. +- **DataWeave deserialization**: Flag `readUrl()` or `read()` calls on untrusted input without schema validation or content-type enforcement. + +## Transport and Network Security +- All HTTP Listener configs for non-internal flows must use HTTPS (``). Flag plain HTTP on public-facing endpoints. +- All HTTP Request configs calling external services must use HTTPS. Flag any `http://` URLs in request configs. +- Outbound HTTP Request configs must have `tlsContext` set and not disable certificate validation (no `insecure="true"`). +- Check for hardcoded IP addresses or internal hostnames β€” these should use property placeholders. + +## Authentication and Authorization +- Public flows receiving external requests must include authentication validation: API key policy, OAuth 2.0, JWT validation, or Basic Auth connector with credential store. Flag flows with no authentication mechanism. +- Authorization: presence of authentication does not imply authorization. If role-based access is required, verify it exists in the flow or via Anypoint policy. +- Flag flows that return 200 on auth failure instead of 401/403. + +## API Policies +- Use `mulesoft/list_api_instances` and `mulesoft/manage_api_instance_policy` to verify that deployed API instances have at minimum: Rate Limiting or SLA-based throttling, Client ID Enforcement or OAuth, and IP allowlist where applicable. + +## Logging Safety +- Flag any Logger components that log `payload`, `attributes`, or variables containing passwords, tokens, or PII fields without masking. +- Structured logging with field masking is preferred over full payload logging. + +## Output +Classify each finding as critical, high, medium, or low. Include: file/element reference, attack vector, remediation step, and secure property migration note where applicable. diff --git a/com.microsoft.copilot.eclipse.ui/plugin.xml b/com.microsoft.copilot.eclipse.ui/plugin.xml index a3d4a20a..8613d381 100644 --- a/com.microsoft.copilot.eclipse.ui/plugin.xml +++ b/com.microsoft.copilot.eclipse.ui/plugin.xml @@ -197,6 +197,27 @@ + + + + + + + + + + + + @@ -209,6 +230,16 @@ + + + + 0) { this.processMessageLine(messageBuffer.toString()); messageBuffer.setLength(0); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java index 0b961438..945d5ae9 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java @@ -170,13 +170,32 @@ private int getMatchPriority(ConversationTemplate template, String lowerPrefix) public ICompletionProposal[] createCopilotCompletionAgentProposals(String prefix) { List proposals = new ArrayList<>(); ChatCompletionService commandService = chatServiceManager.getChatCompletionService(); + String activeModeNameOrId = chatServiceManager.getUserPreferenceService().getActiveModeNameOrId(); + if (StringUtils.isBlank(activeModeNameOrId)) { + ChatMode activeChatMode = chatServiceManager.getUserPreferenceService().getActiveChatMode(); + activeModeNameOrId = activeChatMode != null ? activeChatMode.toString() : null; + } + + if (commandService.isConsoleContextCommandAvailable(activeModeNameOrId) + && (prefix.isEmpty() || ChatCompletionService.CONSOLE_CONTEXT_COMMAND.startsWith(prefix))) { + proposals.add(new ChatCompletionProposal(ChatCompletionService.AGENT_MARK, + ChatCompletionService.CONSOLE_CONTEXT_COMMAND, ChatCompletionService.CONSOLE_CONTEXT_DESCRIPTION)); + } + + if (commandService.isTransformContextCommandAvailable(activeModeNameOrId) + && (prefix.isEmpty() || ChatCompletionService.TRANSFORM_CONTEXT_COMMAND.startsWith(prefix))) { + proposals.add(new ChatCompletionProposal(ChatCompletionService.AGENT_MARK, + ChatCompletionService.TRANSFORM_CONTEXT_COMMAND, ChatCompletionService.TRANSFORM_CONTEXT_DESCRIPTION)); + } + if (!commandService.isAgentsReady()) { - return new ICompletionProposal[0]; + return proposals.toArray(new ICompletionProposal[0]); } - // So far no template supports agent mode. + // So far no language-server agent command supports agent mode. Local context commands above may still apply. if (Objects.equals(chatServiceManager.getUserPreferenceService().getActiveChatMode(), ChatMode.Agent)) { - return new ICompletionProposal[0]; + return proposals.toArray(new ICompletionProposal[0]); } + ConversationAgent[] agents = commandService.getAgents(); for (ConversationAgent agent : agents) { if (prefix.isEmpty() || agent.getSlug().startsWith(prefix)) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java index 7ef32077..86a0bbf7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java @@ -68,7 +68,7 @@ public class ChatContentViewer extends ScrolledComposite { private Composite errorWidget; private BaseTurnWidget latestUserTurn; - private BaseTurnWidget latestCopilotTurn; + private CopilotTurnWidget latestCopilotTurn; private BaseTurnWidget latestTurnWidget; // Auto-scroll state management private boolean autoScrollEnabled; @@ -133,7 +133,7 @@ public void controlResized(ControlEvent e) { public void startNewTurn(String workDoneToken, String message) { BaseTurnWidget turnWidget = getLatestOrCreateNewTurnWidget(workDoneToken, false, true); turnWidget.appendMessage(message); - turnWidget.notifyTurnEnd(); + turnWidget.flushMessageBuffer(); refreshScrollerLayout(); scrollToLatestUserTurn(); @@ -158,9 +158,11 @@ public BaseTurnWidget getLatestOrCreateNewTurnWidget(String workDoneToken, boole turnWidget = latestTurnWidget; } else if (isCopilot) { // Create new Copilot turn widget - turnWidget = new CopilotTurnWidget(cmpContent, SWT.NONE, serviceManager, workDoneToken); - latestCopilotTurn = turnWidget; - latestTurnWidget = turnWidget; + CopilotTurnWidget copilotTurnWidget = new CopilotTurnWidget(cmpContent, SWT.NONE, serviceManager, + workDoneToken); + latestCopilotTurn = copilotTurnWidget; + latestTurnWidget = copilotTurnWidget; + turnWidget = copilotTurnWidget; } else { // Create new User turn widget turnWidget = new UserTurnWidget(cmpContent, SWT.NONE, serviceManager, workDoneToken); @@ -235,7 +237,7 @@ public void processTurnEvent(ChatProgressValue value) { thinkingTurn.sealThinking(); updateActiveThinkingBlockId(value.getTurnId(), thinkingTurn); } - turnWidget.notifyTurnEnd(); + turnWidget.flushMessageBuffer(); } refreshScrollerLayout(); @@ -379,6 +381,36 @@ private void processTodoListFromToolCall(ChatServiceManager chatServiceManager, } } + /** + * Shows the compacting status on the latest Copilot turn after flushing any buffered reply text. + */ + public void showCompactingStatusOnLatestCopilotTurn() { + if (latestCopilotTurn == null || latestCopilotTurn.isDisposed()) { + return; + } + // Flush any buffered reply text from the previous round so it is rendered + // above the compacting spinner; otherwise it would be concatenated with + // the next round's reply and produce a single garbled line. + latestCopilotTurn.flushMessageBuffer(); + latestCopilotTurn.showCompactingStatus(); + refreshScrollerLayout(); + } + + /** + * Hides the compacting status on the latest Copilot turn, flushing any buffered reply text + * first as a guard against buffered content that was not flushed by an end progress event. + */ + public void hideCompactingStatusOnLatestCopilotTurn() { + if (latestCopilotTurn == null || latestCopilotTurn.isDisposed()) { + return; + } + // Always flush before hiding; the buffer should be empty at this point, but flush as a guard + // in case a cancel path did not receive an end progress event to flush it. + latestCopilotTurn.flushMessageBuffer(); + latestCopilotTurn.hideCompactingStatus(); + refreshScrollerLayout(); + } + /** * Get an existed turn widget by turn ID. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatInputTextViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatInputTextViewer.java index adec34dc..f29db684 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatInputTextViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatInputTextViewer.java @@ -14,6 +14,7 @@ import org.eclipse.jface.text.TextEvent; import org.eclipse.jface.text.contentassist.ContentAssistant; import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.LineBackgroundEvent; import org.eclipse.swt.custom.StyleRange; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.events.KeyAdapter; @@ -45,6 +46,7 @@ */ public class ChatInputTextViewer extends UndoableTextViewer implements PaintListener { private static final int MAX_INPUT_ROWS = 5; + private static final String CHAT_INPUT_CSS_CLASS = "chat-input-text"; private Composite parent; private Consumer sendMessageHandler; @@ -57,6 +59,8 @@ public class ChatInputTextViewer extends UndoableTextViewer implements PaintList private int lastCursorLineOffset = 0; private Color placeholderColor; + private Color darkInputBackground; + private Color darkInputForeground; private Runnable layoutRefreshCallback; @@ -102,6 +106,22 @@ public String getContent() { public void setContent(String content) { this.getDocument().set(content); this.getTextWidget().setSelection(content.length()); + applyDarkInputBackground(); + } + + @Override + public void refresh() { + super.refresh(); + applyDarkInputBackground(); + } + + /** + * Reapplies the theme background for the chat input. Use this before redraw-only updates such as mode placeholder + * refreshes, where Eclipse may have reapplied native defaults. + */ + public void refreshInputBackground() { + applyDarkInputBackground(); + applyDarkInputBackgroundAsync(); } @Override @@ -123,6 +143,14 @@ private void init() { tvw.setLayout(new GridLayout(1, false)); tvw.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); tvw.setAlwaysShowScrollBars(false); + appendCssClass(tvw, CHAT_INPUT_CSS_CLASS); + applyDarkInputBackground(); + SwtUtils.invokeOnDisplayThreadAsync(this::applyDarkInputBackground, tvw); + tvw.addListener(SWT.Settings, e -> applyDarkInputBackground()); + tvw.addListener(SWT.FocusIn, e -> applyDarkInputBackgroundAsync()); + tvw.addListener(SWT.FocusOut, e -> applyDarkInputBackgroundAsync()); + tvw.addListener(SWT.Modify, e -> applyDarkInputBackgroundAsync()); + tvw.addLineBackgroundListener(this::applyDarkInputLineBackground); tvw.addModifyListener(new ModifyListener() { @Override @@ -169,9 +197,74 @@ public void keyPressed(KeyEvent e) { // Unregister callback on dispose tvw.addDisposeListener(e -> { this.chatServiceManager.getChatFontService().unregisterCallback(fontChangeCallback); + disposeColor(this.placeholderColor); + disposeColor(this.darkInputBackground); + disposeColor(this.darkInputForeground); }); } + private void applyDarkInputBackground(StyledText textWidget) { + if (textWidget == null || textWidget.isDisposed() || !UiUtils.isDarkTheme()) { + return; + } + if (parent == null || parent.isDisposed()) { + return; + } + Color background = getDarkInputBackground(textWidget); + Color foreground = getDarkInputForeground(textWidget); + parent.setBackground(background); + textWidget.setBackground(background); + textWidget.setForeground(foreground); + } + + private void applyDarkInputBackground() { + applyDarkInputBackground(this.getTextWidget()); + } + + private void applyDarkInputBackgroundAsync() { + StyledText textWidget = this.getTextWidget(); + SwtUtils.invokeOnDisplayThreadAsync(this::applyDarkInputBackground, textWidget); + } + + private void applyDarkInputLineBackground(LineBackgroundEvent event) { + if (parent == null || parent.isDisposed() || !UiUtils.isDarkTheme()) { + return; + } + event.lineBackground = getDarkInputBackground((StyledText) event.widget); + } + + private Color getDarkInputBackground(StyledText textWidget) { + if (darkInputBackground == null || darkInputBackground.isDisposed()) { + darkInputBackground = CssConstants.getChatBackgroundColor(textWidget.getDisplay()); + } + return darkInputBackground; + } + + private Color getDarkInputForeground(StyledText textWidget) { + if (darkInputForeground == null || darkInputForeground.isDisposed()) { + darkInputForeground = CssConstants.getChatForegroundColor(textWidget.getDisplay()); + } + return darkInputForeground; + } + + private void appendCssClass(StyledText textWidget, String className) { + Object currentClassNames = textWidget.getData(CssConstants.CSS_CLASS_NAME_KEY); + if (currentClassNames instanceof String names && !names.isBlank()) { + if (!(" " + names + " ").contains(" " + className + " ")) { + textWidget.setData(CssConstants.CSS_CLASS_NAME_KEY, names + " " + className); + } + return; + } + + textWidget.setData(CssConstants.CSS_CLASS_NAME_KEY, className); + } + + private void disposeColor(Color color) { + if (color != null && !color.isDisposed()) { + color.dispose(); + } + } + private void clearFormat(int start, int end) { this.getTextWidget().setStyleRange(new StyleRange(start, end - start, null, null, SWT.NORMAL)); } @@ -268,8 +361,10 @@ private void onKeyPressed(KeyEvent e) { } clearFormat(0, text.length()); String firstWord = text.substring(begin, end); + String activeModeNameOrId = userPreferenceService.getActiveModeNameOrId(); if (e.keyCode == SWT.BS - && chatCompletionService.isBrokenCommand(firstWord, this.getTextWidget().getCaretOffset() - begin)) { + && chatCompletionService.isBrokenCommand(firstWord, this.getTextWidget().getCaretOffset() - begin, + activeModeNameOrId)) { try { getDocument().replace(begin, end - begin, StringUtils.EMPTY); } catch (BadLocationException ex) { @@ -279,7 +374,7 @@ private void onKeyPressed(KeyEvent e) { } // we may need to highlight the command if user removed leading character before a command // user is typing - if (chatCompletionService.isCommand(firstWord)) { + if (chatCompletionService.isCommand(firstWord, activeModeNameOrId)) { this.getTextWidget().setStyleRange(new StyleRange(begin, end - begin, UiUtils.SLASH_COMMAND_FORGROUND_COLOR, UiUtils.SLASH_COMMAND_BACKGROUND_COLOR, SWT.BOLD)); return; @@ -417,4 +512,4 @@ private boolean isProposalPopupActive() { return false; // Default to false if reflection fails } } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatMarkupViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatMarkupViewer.java index ae8de949..2ad25c85 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatMarkupViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatMarkupViewer.java @@ -19,11 +19,13 @@ import org.eclipse.mylyn.wikitext.parser.css.CssParser; import org.eclipse.mylyn.wikitext.ui.viewer.MarkupViewer; import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.graphics.RGB; import org.eclipse.swt.widgets.Composite; import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.swt.CssConstants; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; class ChatMarkupViewer extends MarkupViewer { @@ -39,6 +41,8 @@ public ChatMarkupViewer(Composite parent, int styles) { MultipleHyperlinkPresenter hyperlinkPresenter = new MultipleHyperlinkPresenter((RGB) null); this.setHyperlinkPresenter(hyperlinkPresenter); + applyMessageBackground(); + // Register for chat font updates via centralized service var chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); if (chatServiceManager != null) { @@ -67,6 +71,7 @@ public void setMarkup(String source) { try { String htmlText = this.computeHtml(source); setHtml(htmlText); + applyMessageBackground(); // reset text presentation to update the style, otherwise the style won't be updated this.setTextPresentation(getTextPresentation()); } catch (Throwable t) { @@ -74,10 +79,27 @@ public void setMarkup(String source) { getTextPresentation().clear(); } setDocumentNoMarkup(new Document(source), new AnnotationModel()); + applyMessageBackground(); // TODO: Whether we should track the parse exception? } } + private void applyMessageBackground() { + StyledText textWidget = getTextWidget(); + if (textWidget == null || textWidget.isDisposed()) { + return; + } + Object currentClassNames = textWidget.getData(CssConstants.CSS_CLASS_NAME_KEY); + if (currentClassNames instanceof String classNames && !classNames.contains("chat-message-text")) { + textWidget.setData(CssConstants.CSS_CLASS_NAME_KEY, classNames + " chat-message-text"); + } else if (!(currentClassNames instanceof String)) { + textWidget.setData(CssConstants.CSS_CLASS_NAME_KEY, "chat-message-text"); + } + if (!UiUtils.isDarkTheme()) { + textWidget.setBackground(textWidget.getDisplay().getSystemColor(SWT.COLOR_WIDGET_BACKGROUND)); + } + } + // computeHtml(String) is a private method in MarkupViewer, so copy it here. private String computeHtml(String markupContent) { StringWriter out = new StringWriter(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 13e7fa09..0403665d 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -60,6 +60,8 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatStepStatus; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatStepTitles; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatTurnResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompressionCompletedParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompressionStartedParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ContextSizeInfo; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; @@ -86,6 +88,11 @@ import com.microsoft.copilot.eclipse.ui.chat.services.AgentToolService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatCompletionService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextPromptProcessor; +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextService; +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextPromptProcessor.ProcessedMessage; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextPromptProcessor; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService; import com.microsoft.copilot.eclipse.ui.chat.services.DebugEventAutoResponseHandler; import com.microsoft.copilot.eclipse.ui.chat.services.ReferencedFileService; import com.microsoft.copilot.eclipse.ui.chat.services.TodoListService; @@ -97,6 +104,7 @@ import com.microsoft.copilot.eclipse.ui.chat.viewers.NoSubscriptionViewer; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; import com.microsoft.copilot.eclipse.ui.utils.ResourceUtils; +import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; /** @@ -134,6 +142,8 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL // Auto breakpoint response handler private DebugEventAutoResponseHandler debugEventHandler; + private ConsoleContextService consoleContextService = new ConsoleContextService(); + private TransformEditorContextService transformEditorContextService = new TransformEditorContextService(); // Event handlers for cleanup private EventHandler chatOnSendHandler; @@ -148,6 +158,8 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL private EventHandler autoBreakpointToggleHandler; private EventHandler rateLimitWarningHandler; private EventHandler quotaWarningHandler; + private EventHandler compressionStartedHandler; + private EventHandler compressionCompletedHandler; // Context activation for chat view keyboard shortcuts private static final String CHAT_VIEW_CONTEXT = "com.microsoft.copilot.eclipse.chatViewContext"; @@ -398,6 +410,45 @@ public void done(IJobChangeEvent event) { }; this.eventBroker.subscribe(CopilotEventConstants.TOPIC_QUOTA_WARNING, this.quotaWarningHandler); + this.compressionStartedHandler = event -> { + Object data = event.getProperty(IEventBroker.DATA); + if (!(data instanceof CompressionStartedParams params)) { + return; + } + if (!isCompressionForActiveConversation(params.conversationId())) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) { + return; + } + this.chatContentViewer.showCompactingStatusOnLatestCopilotTurn(); + }, parent); + }; + this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_STARTED, + this.compressionStartedHandler); + + this.compressionCompletedHandler = event -> { + Object data = event.getProperty(IEventBroker.DATA); + if (!(data instanceof CompressionCompletedParams params)) { + return; + } + if (!isCompressionForActiveConversation(params.conversationId())) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) { + return; + } + this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn(); + if (params.contextInfo() != null) { + this.chatServiceManager.getContextWindowService().updateContextSize(params.contextInfo()); + } + }, parent); + }; + this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_COMPLETED, + this.compressionCompletedHandler); + // Register part listener to activate/deactivate chat view context for keyboard shortcuts registerPartListener(); } @@ -439,6 +490,7 @@ public void partActivated(IWorkbenchPartReference partRef) { if (partRef.getPart(false) == ChatView.this) { activateChatViewContext(); } + updateTransformPropertiesHint(partRef, true); } @Override @@ -446,6 +498,7 @@ public void partDeactivated(IWorkbenchPartReference partRef) { if (partRef.getPart(false) == ChatView.this) { deactivateChatViewContext(); } + updateTransformPropertiesHint(partRef, false); } @Override @@ -455,7 +508,7 @@ public void partBroughtToTop(IWorkbenchPartReference partRef) { @Override public void partClosed(IWorkbenchPartReference partRef) { - // No action needed + updateTransformPropertiesHint(partRef, false); } @Override @@ -509,6 +562,38 @@ private void deactivateChatViewContext() { } } + private void updateTransformPropertiesHint(IWorkbenchPartReference partRef, boolean activated) { + String partId = partRef.getId(); + if (!isTransformPropertiesView(partId)) { + return; + } + if (!activated) { + transformEditorContextService.clearActiveTransformHint(); + return; + } + String title = partRef.getPartName(); + String[] hint = parseTransformHintFromTitle(title); + transformEditorContextService.setActiveTransformHint(hint[0], hint[1]); + } + + private boolean isTransformPropertiesView(String partId) { + return partId != null && (partId.equals(TransformEditorContextService.MULE_TRANSFORM_VIEW_ID) + || partId.startsWith("org.mule.tooling.ui.views.transform") + || partId.startsWith("com.mulesoft.studio.properties")); + } + + private String[] parseTransformHintFromTitle(String title) { + if (title == null || title.isBlank()) { + return new String[] {"", ""}; + } + int colonIdx = title.indexOf(':'); + if (colonIdx >= 0 && colonIdx + 1 < title.length()) { + return new String[] {title.substring(colonIdx + 1).trim(), ""}; + } + String cleaned = title.replace("Transform Message", "").trim(); + return new String[] {cleaned, ""}; + } + /** * Build the view for the given status. * @@ -977,15 +1062,26 @@ public void setFocus() { private void onSendInternal(String workDoneToken, String message, String agentSlug, String agentJobWorkspaceFolder, boolean createNewTurn) { - String processedMessage = replaceWorkspaceCommand(message); - - // Persist the user input to history - chatServiceManager.getUserPreferenceService().addInputToHistory(processedMessage); - final ChatMode activeChatMode = chatServiceManager.getUserPreferenceService().getActiveChatMode(); // Get mode information String activeModeId = chatServiceManager.getUserPreferenceService().getActiveModeNameOrId(); + String consoleContextModeId = StringUtils.defaultIfBlank(activeModeId, + activeChatMode != null ? activeChatMode.toString() : null); + ProcessedMessage consoleProcessedMessage = processConsoleContextCommand(message, consoleContextModeId); + TransformEditorContextPromptProcessor.ProcessedMessage transformProcessedMessage = + processTransformContextCommand(consoleProcessedMessage.serverMessage(), consoleContextModeId); + if (!transformProcessedMessage.transformContextRequested()) { + transformProcessedMessage = autoInjectTransformContext( + transformProcessedMessage.serverMessage(), consoleContextModeId); + } + String processedMessage = replaceWorkspaceCommand(transformProcessedMessage.serverMessage()); + String userMessageToPersist = + (consoleProcessedMessage.consoleContextRequested() || transformProcessedMessage.transformContextRequested()) + ? message : processedMessage; + + // Persist the user input to history + chatServiceManager.getUserPreferenceService().addInputToHistory(userMessageToPersist); // Determine chat mode name and custom mode ID for LSP String chatModeName; @@ -1043,7 +1139,7 @@ private void onSendInternal(String workDoneToken, String message, String agentSl flushPendingAttachedFiles(this.conversationId); // Continue existing conversation - persist user message and send to existing conversation if (persistenceManager != null) { - this.persistUserTurnFuture = persistenceManager.persistUserTurnInfo(conversationId, null, processedMessage, + this.persistUserTurnFuture = persistenceManager.persistUserTurnInfo(conversationId, null, userMessageToPersist, activeModel, chatModeName, customChatModeId, currentFile, references); } @@ -1093,7 +1189,7 @@ private void onSendInternal(String workDoneToken, String message, String agentSl // Load turns from the history conversation and persist user turn with current conversation ID turns = persistenceManager.loadConversationTurns(this.conversationId); this.persistUserTurnFuture = persistenceManager.persistUserTurnInfo(this.conversationId, null, - processedMessage, activeModel, chatModeName, customChatModeId, currentFile, references); + userMessageToPersist, activeModel, chatModeName, customChatModeId, currentFile, references); // Set conversationId and last completed turnId for CLS server-side session restoration. restoredConversationId = this.conversationId; @@ -1116,7 +1212,7 @@ private void onSendInternal(String workDoneToken, String message, String agentSl // Generate a temporary ID for brand new conversation and persist user turn this.conversationId = UUID.randomUUID().toString(); this.persistUserTurnFuture = persistenceManager.persistUserTurnInfo(this.conversationId, null, - processedMessage, activeModel, chatModeName, customChatModeId, currentFile, references); + userMessageToPersist, activeModel, chatModeName, customChatModeId, currentFile, references); } List workspaceFolders = deriveWorkspaceFolders(currentFile, references); @@ -1209,6 +1305,15 @@ private boolean isProgressForCurrentConversation(ChatProgressValue value) { return StringUtils.equals(progressConversationId, this.conversationId); } + /** + * Checks whether a compression notification targets either the main conversation or the active subagent + * conversation, so the UI can reflect compaction happening at either level. + */ + private boolean isCompressionForActiveConversation(String compressionConversationId) { + return StringUtils.equals(compressionConversationId, this.conversationId) + || StringUtils.equals(compressionConversationId, this.subagentConversationId); + } + /** * Align with @Workspace of vscode, because we are actually indexing the whole workspace, not a single project. * (@Project is only for IntelliJ.) @@ -1227,6 +1332,26 @@ private String replaceWorkspaceCommand(String message) { return message; } + private ProcessedMessage processConsoleContextCommand(String message, String activeModeId) { + return ConsoleContextPromptProcessor.process(message, PreferencesUtils.isConsoleContextEnabled(), + ChatCompletionService.isConsoleContextSupportedMode(activeModeId), consoleContextService::captureActiveConsole); + } + + private TransformEditorContextPromptProcessor.ProcessedMessage processTransformContextCommand(String message, + String activeModeId) { + return TransformEditorContextPromptProcessor.process(message, PreferencesUtils.isTransformContextEnabled(), + ChatCompletionService.isTransformContextSupportedMode(activeModeId), + transformEditorContextService::captureActiveTransformContext); + } + + private TransformEditorContextPromptProcessor.ProcessedMessage autoInjectTransformContext(String message, + String activeModeId) { + return TransformEditorContextPromptProcessor.processAutoInject(message, + PreferencesUtils.isTransformContextEnabled(), + ChatCompletionService.isTransformContextSupportedMode(activeModeId), + transformEditorContextService::captureAutoTransformContext); + } + private void displayErrorAndResetSendButton(String workDoneToken, String message) { if (message == null) { message = Messages.chat_warnWidget_defaultErrorMsg; @@ -1377,6 +1502,9 @@ public void onCancel() { if (this.actionBar != null && !this.actionBar.isDisposed()) { this.actionBar.resetSendButton(); } + if (this.chatContentViewer != null && !this.chatContentViewer.isDisposed()) { + this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn(); + } } private void cancelCurrentTerminalCommand() { @@ -1565,6 +1693,14 @@ public void dispose() { this.eventBroker.unsubscribe(this.quotaWarningHandler); quotaWarningHandler = null; } + if (compressionStartedHandler != null) { + this.eventBroker.unsubscribe(this.compressionStartedHandler); + compressionStartedHandler = null; + } + if (compressionCompletedHandler != null) { + this.eventBroker.unsubscribe(this.compressionCompletedHandler); + compressionCompletedHandler = null; + } } if (this.chatServiceManager != null) { @@ -1821,17 +1957,17 @@ private void restoreTurn(AbstractTurnData turn) { if (userTurn.getMessage() == null || StringUtils.isNotBlank(userTurn.getMessage().getText())) { BaseTurnWidget userTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), false, true); userTurnWidget.appendMessage(userTurn.getMessage().getText()); - userTurnWidget.notifyTurnEnd(); + userTurnWidget.flushMessageBuffer(); return; } } else if (turn instanceof CopilotTurnData copilotTurn) { BaseTurnWidget copilotTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), true, true); restoreCopilotTurnContent(copilotTurn, copilotTurnWidget); - copilotTurnWidget.notifyTurnEnd(); + copilotTurnWidget.flushMessageBuffer(); // Restore model info footer if model name is present - // This must be done AFTER notifyTurnEnd() to ensure footer appears at the bottom + // This must be done AFTER flushMessageBuffer() to ensure footer appears at the bottom ReplyData replyData = copilotTurn.getReply(); if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) { // Reasoning effort was captured and persisted at send time so the footer reflects what was actually used diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java index e3efbdd7..f026aa0e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java @@ -17,6 +17,7 @@ import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.swt.SpinnerAnimator; import com.microsoft.copilot.eclipse.ui.utils.AccessibilityUtils; import com.microsoft.copilot.eclipse.ui.utils.ModelUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; @@ -25,6 +26,10 @@ * A custom widget that displays a turn for the copilot. */ public class CopilotTurnWidget extends ThinkingTurnWidget { + + private Composite compactingComposite; + private SpinnerAnimator compactingSpinner; + /** * Create the widget. */ @@ -106,6 +111,50 @@ public void renderModelInfo(String modelName, double billingMultiplier, String r } } + /** + * Shows a "Compacting conversation..." spinner below the last message in this turn. + * Must be called on the UI thread. + */ + public void showCompactingStatus() { + if (isDisposed() || compactingComposite != null) { + return; + } + compactingComposite = new Composite(this, SWT.NONE); + GridLayout layout = new GridLayout(2, false); + layout.marginWidth = 0; + layout.marginHeight = 4; + compactingComposite.setLayout(layout); + compactingComposite.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + + Label spinnerLabel = new Label(compactingComposite, SWT.NONE); + spinnerLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + compactingSpinner = new SpinnerAnimator(spinnerLabel); + compactingSpinner.start(); + + Label statusLabel = new Label(compactingComposite, SWT.NONE); + statusLabel.setText(Messages.chat_compacting_conversation); + statusLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, true, false)); + + ensureFooterAtBottom(); + requestLayout(); + } + + /** + * Hides the "Compacting conversation..." spinner. + * Must be called on the UI thread. + */ + public void hideCompactingStatus() { + if (compactingSpinner != null) { + compactingSpinner.stop(); + compactingSpinner = null; + } + if (compactingComposite != null && !compactingComposite.isDisposed()) { + compactingComposite.dispose(); + compactingComposite = null; + } + requestLayout(); + } + @Override protected void createFooter() { footer = new Composite(this, SWT.NONE); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CurrentReferencedFile.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CurrentReferencedFile.java index 46f22d4d..19a9dde2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CurrentReferencedFile.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CurrentReferencedFile.java @@ -92,6 +92,12 @@ public void setFile(IResource file) { super.setFile(file); } + @Override + protected @Nullable String getAccessibilityName() { + IResource file = getFile(); + return file == null ? null : Messages.chat_currentReferencedFile_description + " " + file.getName(); + } + /** * Set the current selection to display. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java index 8c9faf9d..db63a981 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java @@ -9,6 +9,8 @@ import org.eclipse.e4.ui.css.swt.CSSSWTConstants; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.swt.SWT; +import org.eclipse.swt.accessibility.AccessibleAdapter; +import org.eclipse.swt.accessibility.AccessibleEvent; import org.eclipse.swt.events.KeyAdapter; import org.eclipse.swt.events.KeyEvent; import org.eclipse.swt.events.MouseAdapter; @@ -19,6 +21,7 @@ import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.layout.RowData; import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Label; import org.eclipse.ui.ISharedImages; import org.eclipse.ui.PlatformUI; @@ -100,6 +103,7 @@ public void keyPressed(KeyEvent e) { }); AccessibilityUtils.addFocusBorderToComposite(this); + addAccessibilityName(this); } /** @@ -170,6 +174,22 @@ protected void setFile(@Nullable IResource file) { } } + /** + * Returns the accessible name for this referenced file widget. + */ + protected @Nullable String getAccessibilityName() { + return file == null ? null : file.getName(); + } + + private void addAccessibilityName(Control control) { + control.getAccessible().addAccessibleListener(new AccessibleAdapter() { + @Override + public void getName(AccessibleEvent event) { + event.result = getAccessibilityName(); + } + }); + } + /** * Setup display for unsupported files (e.g., images without vision support). */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentMessageBlock.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentMessageBlock.java index 45edd8cb..e7dcf908 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentMessageBlock.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentMessageBlock.java @@ -91,9 +91,9 @@ public void appendToolCallStatus(AgentToolCall toolCall) { /** * Notify the end of the subagent turn. */ - public void notifyTurnEnd() { + public void flushMessageBuffer() { if (currentSubagentTurnWidget != null) { - currentSubagentTurnWidget.notifyTurnEnd(); + currentSubagentTurnWidget.flushMessageBuffer(); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/UserTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/UserTurnWidget.java index bb5b4c99..437b0bec 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/UserTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/UserTurnWidget.java @@ -22,7 +22,9 @@ import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.i18n.Messages; +import com.microsoft.copilot.eclipse.ui.swt.CssConstants; import com.microsoft.copilot.eclipse.ui.utils.AccessibilityUtils; +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** * A custom widget that displays a turn for the user. @@ -110,6 +112,10 @@ protected void createTextBlock() { StyledText styledText = this.currentTextBlock.getTextWidget(); styledText.setLayoutData(new GridData(SWT.LEFT, SWT.FILL, true, false)); styledText.setEditable(false); + styledText.setData(CssConstants.CSS_CLASS_NAME_KEY, "chat-message-text"); + if (!UiUtils.isDarkTheme()) { + styledText.setBackground(styledText.getDisplay().getSystemColor(SWT.COLOR_WIDGET_BACKGROUND)); + } // Register for chat font updates via centralized service serviceManager.getChatFontService().registerControl(styledText); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java index 2582320c..b6029b3c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java @@ -4,17 +4,21 @@ package com.microsoft.copilot.eclipse.ui.chat; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.eclipse.swt.SWT; -import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.graphics.Color; import org.eclipse.swt.graphics.Image; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.layout.RowLayout; import org.eclipse.swt.widgets.Button; import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Link; import org.eclipse.ui.ISharedImages; import org.eclipse.ui.PlatformUI; @@ -30,7 +34,12 @@ * and whether to pass a plan. */ public class WarnWidget extends Composite { + private static final Pattern MARKDOWN_LINK_PATTERN = Pattern.compile("\\[([^\\]]+)]\\(([^)]+)\\)"); + private static final Pattern RAW_URL_PATTERN = Pattern.compile("https?://\\S+"); + private int buttonLeftMargin; + private Color darkBackground; + private Color darkForeground; /** * Create the composite. @@ -119,15 +128,71 @@ private void buildWarnLabelWithIcon(String message) { buttonLeftMargin = warnLayout.marginWidth + warnLayout.marginLeft + warnImage.getBounds().width + warnLayout.horizontalSpacing; - ChatMarkupViewer textLabel = new ChatMarkupViewer(composite, SWT.LEFT | SWT.WRAP); - StyledText styledText = textLabel.getTextWidget(); - styledText.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, true, true)); - styledText.setEditable(false); - textLabel.setMarkup(message); + Link messageLink = new Link(composite, SWT.LEFT | SWT.WRAP); + messageLink.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + messageLink.setText(toLinkMarkup(message)); + messageLink.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(org.eclipse.swt.events.SelectionEvent event) { + UiUtils.openLink(event.text); + } + }); requestLayout(); } + private static String toLinkMarkup(String message) { + String text = stripMarkdownEmphasis(message == null ? "" : message); + Matcher markdownLinkMatcher = MARKDOWN_LINK_PATTERN.matcher(text); + StringBuilder result = new StringBuilder(); + int offset = 0; + while (markdownLinkMatcher.find()) { + appendTextWithRawLinks(result, text.substring(offset, markdownLinkMatcher.start())); + appendLink(result, markdownLinkMatcher.group(1), markdownLinkMatcher.group(2)); + offset = markdownLinkMatcher.end(); + } + appendTextWithRawLinks(result, text.substring(offset)); + return result.toString(); + } + + private static void appendTextWithRawLinks(StringBuilder result, String text) { + Matcher rawUrlMatcher = RAW_URL_PATTERN.matcher(text); + int offset = 0; + while (rawUrlMatcher.find()) { + result.append(escapeLinkText(text.substring(offset, rawUrlMatcher.start()))); + String url = rawUrlMatcher.group(); + String trailingPunctuation = ""; + while (!url.isEmpty() && ".,;:".indexOf(url.charAt(url.length() - 1)) >= 0) { + trailingPunctuation = url.charAt(url.length() - 1) + trailingPunctuation; + url = url.substring(0, url.length() - 1); + } + appendLink(result, url, url); + result.append(escapeLinkText(trailingPunctuation)); + offset = rawUrlMatcher.end(); + } + result.append(escapeLinkText(text.substring(offset))); + } + + private static void appendLink(StringBuilder result, String label, String url) { + result.append("") + .append(escapeLinkText(stripMarkdownEmphasis(label == null ? "" : label))) + .append(""); + } + + private static String stripMarkdownEmphasis(String text) { + return text.replace("**", ""); + } + + private static String escapeLinkText(String text) { + return text.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + private static String escapeLinkAttribute(String text) { + return escapeLinkText(text).replace("\"", """).replace("'", "'"); + } + /** * Render plan-driven action buttons for a quota-exceeded warning, kept in sync with the quota {@link StaticBanner}. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java index cc6a8a5a..1c0780a6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Map; -import org.eclipse.core.resources.IFile; import org.eclipse.e4.ui.services.IStylingEngine; import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; @@ -31,6 +30,7 @@ import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ChatFontService; +import com.microsoft.copilot.eclipse.ui.chat.tools.ChangedFile; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService.FileChangeProperty; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; @@ -70,7 +70,7 @@ public WorkingSetBar(Composite parent, int style) { * * @param filesMap a map of files and their change status */ - public void buildSummaryBarFor(Map filesMap) { + public void buildSummaryBarFor(Map filesMap) { if (filesMap == null || isDisposed()) { return; } @@ -167,7 +167,7 @@ class WorkingSetTitleBar extends Composite { private Button undoButton; private String changeFilesTitle; - public WorkingSetTitleBar(Composite parent, int style, Map filesMap) { + public WorkingSetTitleBar(Composite parent, int style, Map filesMap) { super(parent, style); GridLayout gl = new GridLayout(3, false); gl.marginWidth = 0; @@ -302,9 +302,10 @@ class ChangedFiles extends Composite { private static final int MAX_VISIBLE_FILES = 5; private final Composite contentArea; private final ScrolledComposite scrolledComposite; + private final WorkbenchLabelProvider labelProvider = new WorkbenchLabelProvider(); private List fileRows; // List to keep track of file rows - public ChangedFiles(Composite parent, int style, Map filesMap) { + public ChangedFiles(Composite parent, int style, Map filesMap) { super(parent, style); // Main layout @@ -313,6 +314,7 @@ public ChangedFiles(Composite parent, int style, Map layout.marginHeight = 0; setLayout(layout); setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + addDisposeListener(e -> labelProvider.dispose()); // Count files long fileCount = filesMap.size(); @@ -345,15 +347,14 @@ public ChangedFiles(Composite parent, int style, Map contentArea.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); } - // TODO: Should share a same instance with ReferencedFile - WorkbenchLabelProvider labelProvider = new WorkbenchLabelProvider(); fileRows = new LinkedList<>(); - for (IFile file : filesMap.keySet()) { + for (ChangedFile file : filesMap.keySet()) { if (file == null) { continue; } - Image image = labelProvider.getImage(file); + Image image = file.isWorkspaceFile() ? labelProvider.getImage(file.getWorkspaceFile()) + : PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_OBJ_FILE); fileRows.add(new FileRow(contentArea, SWT.NONE, image, file)); } @@ -396,7 +397,7 @@ public class FileRow extends Composite { /** * Constructs a new FileRow. */ - public FileRow(Composite parent, int style, Image fileImage, IFile file) { + public FileRow(Composite parent, int style, Image fileImage, ChangedFile file) { super(parent, style); GridLayout layout = new GridLayout(2, false); @@ -434,7 +435,7 @@ public void mouseUp(MouseEvent e) { // File name (bold) Label nameLabel = new Label(fileInfo, SWT.NONE); nameLabel.setText(file.getName()); - nameLabel.setToolTipText(file.getFullPath().toString()); + nameLabel.setToolTipText(file.getDisplayPath()); nameLabel.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); nameLabel.addMouseListener(new MouseAdapter() { @Override @@ -466,7 +467,7 @@ public void mouseUp(MouseEvent e) { // File path CLabel pathLabel = new CLabel(fileInfo, SWT.NONE); - pathLabel.setText(file.getFullPath().toString()); + pathLabel.setText(file.getDisplayPath()); pathLabel.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, true, false)); pathLabel.addMouseListener(new MouseAdapter() { @Override diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java index be236d9c..0daab267 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java @@ -42,13 +42,28 @@ import com.microsoft.copilot.eclipse.ui.chat.InvokeToolConfirmationDialog; import com.microsoft.copilot.eclipse.ui.chat.confirmation.AttachedFileRegistry; import com.microsoft.copilot.eclipse.ui.chat.confirmation.ConfirmationService; +import com.microsoft.copilot.eclipse.ui.chat.tools.ApiSchemaAnalyzeTool; import com.microsoft.copilot.eclipse.ui.chat.tools.BaseTool; import com.microsoft.copilot.eclipse.ui.chat.tools.CreateFileTool; import com.microsoft.copilot.eclipse.ui.chat.tools.EditFileTool; import com.microsoft.copilot.eclipse.ui.chat.tools.GetErrorsTool; import com.microsoft.copilot.eclipse.ui.chat.tools.JavaDebuggerToolAdapter; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleCodeReviewTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleDwlOptimizeTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleDwlReadTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleDwlWriteTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleProjectErrorsTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleProjectScanTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleProjectSummaryTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleSecurityReviewTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleTransformReadTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleTransformWriteTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MunitFullReviewTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MunitImprovementSuggestionsTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.MunitValidateFlowTestsTool; import com.microsoft.copilot.eclipse.ui.chat.tools.RunInTerminalToolAdapter; import com.microsoft.copilot.eclipse.ui.chat.tools.RunInTerminalToolAdapter.GetTerminalOutputTool; +import com.microsoft.copilot.eclipse.ui.chat.tools.RunMuleMavenTestsTool; import com.microsoft.copilot.eclipse.ui.dialogs.MissingTerminalDependenciesDialog; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; @@ -110,6 +125,21 @@ private void registerDefaultTools() { // Diagnostic tools registerTool(new GetErrorsTool()); + registerTool(new MuleProjectSummaryTool()); + registerTool(new MuleProjectScanTool()); + registerTool(new ApiSchemaAnalyzeTool()); + registerTool(new MuleCodeReviewTool()); + registerTool(new MuleSecurityReviewTool()); + registerTool(new MuleTransformReadTool()); + registerTool(new MuleTransformWriteTool()); + registerTool(new MuleDwlReadTool()); + registerTool(new MuleDwlWriteTool()); + registerTool(new MuleDwlOptimizeTool()); + registerTool(new MunitValidateFlowTestsTool()); + registerTool(new MunitFullReviewTool()); + registerTool(new MunitImprovementSuggestionsTool()); + registerTool(new MuleProjectErrorsTool()); + registerTool(new RunMuleMavenTestsTool()); // Debug tools - only register if JDT bundles are available and in nightly build if (JdtUtils.isJdtDebugAvailable() && PlatformUtils.isNightly()) { @@ -363,4 +393,4 @@ public void dispose() { this.tools.clear(); unbindChatView(); } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java index cff99834..daaa6137 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java @@ -30,6 +30,7 @@ import com.microsoft.copilot.eclipse.core.CopilotAuthStatusListener; import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.FeatureFlags; +import com.microsoft.copilot.eclipse.core.chat.BuiltInChatMode; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; @@ -46,6 +47,10 @@ public class ChatCompletionService implements CopilotAuthStatusListener { public static final String AGENT_MARK = "@"; public static final String TEMPLATE_MARK = "/"; + public static final String CONSOLE_CONTEXT_COMMAND = "console"; + public static final String CONSOLE_CONTEXT_DESCRIPTION = "Attach active console output"; + public static final String TRANSFORM_CONTEXT_COMMAND = "transform"; + public static final String TRANSFORM_CONTEXT_DESCRIPTION = "Attach active Transform Message editor context"; private volatile List templates = List.of(); private volatile List agents = List.of(); @@ -188,12 +193,25 @@ public ConversationTemplate[] getFilteredTemplates(ChatMode chatMode) { * @return the start and end index of the broken slash command */ public boolean isBrokenCommand(String text, int cursorPosition) { - if (allCommands == null) { + return isBrokenCommand(text, cursorPosition, null); + } + + /** + * Find a broken slash or agent command in the given text for the active mode. + * + * @param text the text + * @param cursorPosition the cursor position inside the first word + * @param activeModeNameOrId the active mode name or custom mode ID + * @return true when the command can be repaired by deleting one character + */ + public boolean isBrokenCommand(String text, int cursorPosition, String activeModeNameOrId) { + Set commands = getAllCommandsSnapshot(activeModeNameOrId); + if (commands == null) { return false; } // Try to recover the text by adding a dot at the cursor position String recoveredText = text.substring(0, cursorPosition) + "." + text.substring(cursorPosition); - for (String command : allCommands) { + for (String command : commands) { if (matchesRecoveredCommand(recoveredText, command)) { return true; } @@ -224,10 +242,22 @@ private boolean matchesRecoveredCommand(String recovered, String command) { * @return the start and end index of the slash command */ public boolean isCommand(String text) { - if (allCommands == null) { + return isCommand(text, null); + } + + /** + * Find a command in the given text for the active mode. + * + * @param text the text + * @param activeModeNameOrId the active mode name or custom mode ID + * @return true if the text is a known command + */ + public boolean isCommand(String text, String activeModeNameOrId) { + Set commands = getAllCommandsSnapshot(activeModeNameOrId); + if (commands == null) { return false; } - return allCommands.contains(text); + return commands.contains(text); } public boolean isTempaltesReady() { @@ -242,6 +272,63 @@ public ConversationAgent[] getAgents() { return agents.toArray(new ConversationAgent[0]); } + /** + * Returns whether @console should be available for the given active mode. + * + * @param activeModeNameOrId the selected built-in display name or custom mode ID + * @return true if @console is enabled and supported + */ + public boolean isConsoleContextCommandAvailable(String activeModeNameOrId) { + return PreferencesUtils.isConsoleContextEnabled() && isConsoleContextSupportedMode(activeModeNameOrId); + } + + /** + * Console context is intentionally limited to built-in Ask, Agent, and Plan modes. + * + * @param activeModeNameOrId the selected built-in display name or custom mode ID + * @return true if the mode supports console context + */ + public static boolean isConsoleContextSupportedMode(String activeModeNameOrId) { + return BuiltInChatMode.ASK_MODE_NAME.equalsIgnoreCase(activeModeNameOrId) + || BuiltInChatMode.AGENT_MODE_NAME.equalsIgnoreCase(activeModeNameOrId) + || BuiltInChatMode.PLAN_MODE_NAME.equalsIgnoreCase(activeModeNameOrId); + } + + /** + * Returns true when the @transform command is both enabled in preferences and supported by the active mode. + * + * @param activeModeNameOrId the selected built-in display name or custom mode ID + * @return true if @transform is enabled and supported + */ + public boolean isTransformContextCommandAvailable(String activeModeNameOrId) { + return PreferencesUtils.isTransformContextEnabled() && isTransformContextSupportedMode(activeModeNameOrId); + } + + /** + * Transform context is limited to built-in Ask, Agent, and Plan modes. + * + * @param activeModeNameOrId the selected built-in display name or custom mode ID + * @return true if the mode supports transform context + */ + public static boolean isTransformContextSupportedMode(String activeModeNameOrId) { + return BuiltInChatMode.ASK_MODE_NAME.equalsIgnoreCase(activeModeNameOrId) + || BuiltInChatMode.AGENT_MODE_NAME.equalsIgnoreCase(activeModeNameOrId) + || BuiltInChatMode.PLAN_MODE_NAME.equalsIgnoreCase(activeModeNameOrId); + } + + private Set getAllCommandsSnapshot(String activeModeNameOrId) { + Set commands = new HashSet<>(allCommands); + // Only add @console/@transform if enabled AND the current mode (if known) supports it. + // When activeModeNameOrId is null, we conservatively skip since we can't verify mode support. + if (activeModeNameOrId != null && isConsoleContextCommandAvailable(activeModeNameOrId)) { + commands.add(AGENT_MARK + CONSOLE_CONTEXT_COMMAND); + } + if (activeModeNameOrId != null && isTransformContextCommandAvailable(activeModeNameOrId)) { + commands.add(AGENT_MARK + TRANSFORM_CONTEXT_COMMAND); + } + return commands; + } + @Override public void onDidCopilotStatusChange(CopilotStatusResult copilotStatusResult) { String status = copilotStatusResult.getStatus(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextPromptProcessor.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextPromptProcessor.java new file mode 100644 index 00000000..50e6d5fc --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextPromptProcessor.java @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import java.util.function.Supplier; + +import org.apache.commons.lang3.StringUtils; + +import com.microsoft.copilot.eclipse.ui.chat.services.ConsoleContextService.ConsoleSnapshot; + +/** + * Applies explicit @console chat context to the message sent to the language server. + */ +public final class ConsoleContextPromptProcessor { + private static final String CONSOLE_CONTEXT_BLOCK_TITLE = "[Console Context]"; + + private ConsoleContextPromptProcessor() { + // Utility class. + } + + /** + * Adds console context to the server payload when the prompt starts with @console and the feature is available. + * + * @param message original user message + * @param enabled whether console context is enabled + * @param supportedMode whether the active chat mode supports console context + * @param snapshotSupplier supplier for the current console snapshot + * @return processed message details + */ + public static ProcessedMessage process(String message, boolean enabled, boolean supportedMode, + Supplier snapshotSupplier) { + if (!enabled || !supportedMode || !startsWithConsoleCommand(message)) { + return new ProcessedMessage(message, false); + } + + String promptWithoutCommand = stripConsoleCommand(message); + ConsoleSnapshot snapshot = snapshotSupplier.get(); + String serverMessage = appendConsoleContext(promptWithoutCommand, snapshot); + return new ProcessedMessage(serverMessage, true); + } + + /** + * Checks whether a message starts with @console as a full first token. + * + * @param message user message + * @return true when @console is the leading command + */ + public static boolean startsWithConsoleCommand(String message) { + String trimmed = StringUtils.trimToEmpty(message); + if (!trimmed.startsWith(ChatCompletionService.AGENT_MARK + ChatCompletionService.CONSOLE_CONTEXT_COMMAND)) { + return false; + } + + int commandLength = (ChatCompletionService.AGENT_MARK + ChatCompletionService.CONSOLE_CONTEXT_COMMAND).length(); + return trimmed.length() == commandLength || Character.isWhitespace(trimmed.charAt(commandLength)); + } + + private static String stripConsoleCommand(String message) { + String trimmed = StringUtils.trimToEmpty(message); + int commandLength = (ChatCompletionService.AGENT_MARK + ChatCompletionService.CONSOLE_CONTEXT_COMMAND).length(); + return StringUtils.stripStart(trimmed.substring(commandLength), null); + } + + private static String appendConsoleContext(String prompt, ConsoleSnapshot snapshot) { + StringBuilder builder = new StringBuilder(StringUtils.defaultString(prompt).stripTrailing()); + if (!builder.isEmpty()) { + builder.append("\n\n"); + } + builder.append(CONSOLE_CONTEXT_BLOCK_TITLE).append('\n'); + + if (snapshot == null || !snapshot.isAvailable()) { + String reason = snapshot != null ? snapshot.unavailableReason() : "Console context is unavailable."; + builder.append("Console context unavailable: ").append(reason); + return builder.toString(); + } + + builder.append("Console: ").append(snapshot.consoleName()).append('\n'); + builder.append("Truncated: ").append(snapshot.truncated() ? "yes" : "no").append('\n'); + + if (snapshot.isEmpty()) { + builder.append("Output: Console output is empty."); + return builder.toString(); + } + + builder.append("Output:\n\n"); + builder.append(enrichConsoleOutput(snapshot.consoleName(), snapshot.output()).stripTrailing()); + builder.append("\n"); + return builder.toString(); + } + + private static String enrichConsoleOutput(String consoleName, String rawOutput) { + if (consoleName != null) { + String lowerName = consoleName.toLowerCase(); + if (lowerName.contains("maven") || lowerName.contains("mvn")) { + return MavenConsoleParser.enrich(rawOutput); + } + } + return MuleConsoleParser.enrich(rawOutput); + } + + /** + * Result of processing a chat message for console context. + */ + public record ProcessedMessage(String serverMessage, boolean consoleContextRequested) { + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextService.java new file mode 100644 index 00000000..4d1062ae --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ConsoleContextService.java @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.text.IDocument; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.IWorkbenchWindow; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.console.ConsolePlugin; +import org.eclipse.ui.console.IConsole; +import org.eclipse.ui.console.IConsoleConstants; +import org.eclipse.ui.console.IConsoleManager; +import org.eclipse.ui.console.IConsoleView; +import org.eclipse.ui.console.TextConsole; + +import com.microsoft.copilot.eclipse.core.CopilotCore; + +/** + * Captures bounded output from the active Eclipse console for chat context. + */ +public class ConsoleContextService { + public static final int DEFAULT_MAX_CHARS = 12_000; + + /** + * Captures output from the console currently selected in the Eclipse Console view. + * + * @return a console snapshot, or an unavailable snapshot when no active text console can be read + */ + public ConsoleSnapshot captureActiveConsole() { + IConsole activeConsole = getActiveConsole(); + return captureConsole(activeConsole, DEFAULT_MAX_CHARS); + } + + /** + * Captures a bounded tail from the given console. + * + * @param console the console to read + * @param maxChars maximum number of characters to include + * @return a console snapshot + */ + public ConsoleSnapshot captureConsole(IConsole console, int maxChars) { + if (console == null) { + return ConsoleSnapshot.unavailable("No active console is selected."); + } + if (!(console instanceof TextConsole textConsole)) { + return ConsoleSnapshot.unavailable("The active console is not text-backed."); + } + + IDocument document = textConsole.getDocument(); + if (document == null) { + return ConsoleSnapshot.unavailable("The active console has no readable document."); + } + + String output = document.get(); + if (output == null) { + output = StringUtils.EMPTY; + } + + return ConsoleSnapshot.available(console.getName(), tailAtLineBoundary(output, maxChars), + output.length() > maxChars); + } + + private IConsole getActiveConsole() { + try { + // Note: PlatformUI.getWorkbench() and workbench page methods must be called on the SWT UI thread. + // This method is safe when called from onSendInternal() in ChatView, which runs on the UI dispatch thread. + // If called from a background thread (e.g., job), it will throw SWTException: invalid thread access. + IWorkbenchWindow window = PlatformUI.getWorkbench().getActiveWorkbenchWindow(); + if (window == null) { + return null; + } + IWorkbenchPage page = window.getActivePage(); + if (page == null) { + return null; + } + if (page.findView(IConsoleConstants.ID_CONSOLE_VIEW) instanceof IConsoleView consoleView) { + IConsole active = consoleView.getConsole(); + if (active != null) { + return active; + } + } + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to capture active console context", e); + } + return findPreferredMuleConsole(); + } + + /** + * Searches all registered Eclipse consoles and returns the best match for Mule-related output. + * Priority order: Mule runtime console > MUnit console > Maven/mvn console. + * + * @return the preferred console, or null if none found + */ + IConsole findPreferredMuleConsole() { + try { + IConsoleManager manager = ConsolePlugin.getDefault().getConsoleManager(); + if (manager == null) { + return null; + } + IConsole muleConsole = null; + IConsole munitConsole = null; + IConsole mavenConsole = null; + for (IConsole console : manager.getConsoles()) { + String name = console.getName().toLowerCase(); + if (name.contains("munit")) { + if (munitConsole == null) { + munitConsole = console; + } + } else if (name.contains("mule")) { + if (muleConsole == null) { + muleConsole = console; + } + } else if (name.contains("maven") || name.contains("mvn")) { + if (mavenConsole == null) { + mavenConsole = console; + } + } + } + if (muleConsole != null) { + return muleConsole; + } + if (munitConsole != null) { + return munitConsole; + } + return mavenConsole; + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to find preferred Mule console", e); + return null; + } + } + + private String tailAtLineBoundary(String output, int maxChars) { + // When maxChars <= 0, return full output (guards against nonsensical limits gracefully). + // Otherwise, trim from the end to stay within maxChars and align to line boundaries. + if (maxChars <= 0 || output.length() <= maxChars) { + return output; + } + + int start = output.length() - maxChars; + int nextLineBreak = output.indexOf('\n', start); + if (nextLineBreak >= 0 && nextLineBreak + 1 < output.length()) { + return output.substring(nextLineBreak + 1); + } + + return output.substring(start); + } + + /** + * Snapshot of console context for a chat turn. + */ + public record ConsoleSnapshot(String consoleName, String output, boolean truncated, String unavailableReason) { + public static ConsoleSnapshot available(String consoleName, String output, boolean truncated) { + return new ConsoleSnapshot(StringUtils.defaultIfBlank(consoleName, "Console"), + StringUtils.defaultString(output), truncated, null); + } + + public static ConsoleSnapshot unavailable(String reason) { + return new ConsoleSnapshot(null, StringUtils.EMPTY, false, reason); + } + + public boolean isAvailable() { + return unavailableReason == null; + } + + public boolean isEmpty() { + return StringUtils.isBlank(output); + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/MavenConsoleParser.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/MavenConsoleParser.java new file mode 100644 index 00000000..551262f7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/MavenConsoleParser.java @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses Maven build output from the Eclipse console and prepends a structured summary block. + * This surfaces the build result, error count, and warning count before the full log dump, + * reducing noise in the context window. + */ +final class MavenConsoleParser { + + private static final Pattern MAVEN_MARKER = + Pattern.compile("\\[(INFO|WARNING|ERROR|WARN)\\]|BUILD (SUCCESS|FAILURE)", Pattern.CASE_INSENSITIVE); + private static final Pattern BUILD_RESULT_PATTERN = + Pattern.compile("BUILD (SUCCESS|FAILURE)", Pattern.CASE_INSENSITIVE); + private static final Pattern ERROR_LINE_PATTERN = + Pattern.compile("^\\[ERROR\\]", Pattern.MULTILINE | Pattern.CASE_INSENSITIVE); + private static final Pattern WARNING_LINE_PATTERN = + Pattern.compile("^\\[WARNING\\]", Pattern.MULTILINE | Pattern.CASE_INSENSITIVE); + + private MavenConsoleParser() { + } + + /** + * Inspects raw console output. If Maven build output is detected, prepends a structured + * [Maven Build Summary] block so the model can orient to the build result before reading the + * full log. + * + * @param rawOutput the raw console snapshot text + * @return enriched text with summary prepended, or the original text if no Maven output found + */ + static String enrich(String rawOutput) { + if (rawOutput == null || rawOutput.isBlank() || !isMavenOutput(rawOutput)) { + return rawOutput; + } + + StringBuilder summary = new StringBuilder("[Maven Build Summary]\n"); + + Matcher resultMatcher = BUILD_RESULT_PATTERN.matcher(rawOutput); + if (resultMatcher.find()) { + summary.append("Result: ").append(resultMatcher.group(0)).append('\n'); + } + + summary.append("Errors: ").append(countMatches(ERROR_LINE_PATTERN, rawOutput)).append('\n'); + summary.append("Warnings: ").append(countMatches(WARNING_LINE_PATTERN, rawOutput)).append('\n'); + summary.append('\n'); + + return summary + rawOutput; + } + + private static boolean isMavenOutput(String text) { + return MAVEN_MARKER.matcher(text).find(); + } + + private static int countMatches(Pattern pattern, String text) { + int count = 0; + Matcher matcher = pattern.matcher(text); + while (matcher.find()) { + count++; + } + return count; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java index 759af1a0..1f5dc6c5 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java @@ -51,12 +51,14 @@ public class McpExtensionPointManager { private static final String EXTENSION_POINT_ID = "com.microsoft.copilot.eclipse.ui.mcpRegistration"; private static final String ELEMENT_PROVIDER = "provider"; private static final String ATTRIBUTE_CLASS = "class"; + private static final String REDACTED_VALUE = ""; - private String approvedExtMcpServers; + private volatile String approvedExtMcpServers; private Map extMcpInfoMap = new HashMap<>(); // Key: Plugin-Id(Bundle) private McpConfigService mcpConfigService; private Gson gson; + private IEventBroker eventBroker; /** * Constructor for McpExtensionPointManager. @@ -64,28 +66,36 @@ public class McpExtensionPointManager { public McpExtensionPointManager(McpConfigService mcpConfigService) { gson = new GsonBuilder().disableHtmlEscaping().create(); this.mcpConfigService = mcpConfigService; + this.eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); if (CopilotCore.getPlugin().getFeatureFlags().isMcpContributionPointEnabled()) { initializeExtMcpRegistration(); } - IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); eventBroker.subscribe(CopilotEventConstants.TOPIC_DID_CHANGE_MCP_CONTRIBUTION_POINT_POLICY, event -> { Boolean enabled = (Boolean) event.getProperty(IEventBroker.DATA); if (enabled.booleanValue()) { initializeExtMcpRegistration(); } else { - extMcpInfoMap.clear(); - approvedExtMcpServers = null; - persistExtMcpInfo(extMcpInfoMap); - mcpConfigService.setNewExtMcpRegFound(false); + // Disabling the contribution point clears the in-memory state shared with doRegistration(). + // Hold the manager monitor so the (non-thread-safe) HashMap clear and the approved-servers + // reset are not observed mid-update by a concurrent doRegistration() running on the async + // worker. + synchronized (this) { + extMcpInfoMap.clear(); + approvedExtMcpServers = null; + persistExtMcpInfo(extMcpInfoMap); + mcpConfigService.setNewExtMcpRegFound(false); + } } }); } private synchronized void initializeExtMcpRegistration() { - // Previously approved servers will be started during Plugin startup. + // Avoid pre-populating the in-memory approved servers from the persisted cache. If we did so, + // the initial syncMcpRegistrationConfiguration() at startup would push potentially stale data + // (server removed by the contributing plugin, port changed, plugin uninstalled) to the language + // server, which would surface a connection failure before doRegistration() can refresh the state. Map persistedMcpContribs = loadPersistedMcpContribs(); - updateApprovedMcpServerString(persistedMcpContribs); // Run the heavy initialization work asynchronously, which has weak relation with Plugin startup. CompletableFuture.runAsync(() -> { @@ -93,16 +103,42 @@ private synchronized void initializeExtMcpRegistration() { }); } - private synchronized void doRegistration(Map persistedMcpContribs) { + private void doRegistration(Map persistedMcpContribs) { + String approvedServersToPublish = null; + boolean shouldPublish = false; try { FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); if (flags != null && !flags.isMcpEnabled()) { return; } - loadMcpRegistrationExtensionPoint(); - detectChangesInMcpContribs(persistedMcpContribs); + // Perform the slow extension-point scan and diff work outside the lock so that + // UI-thread callers (e.g. approveExtMcpRegistration) are not blocked. + Map scannedMap = loadMcpRegistrationExtensionPoint(); + detectChangesInMcpContribs(scannedMap, persistedMcpContribs); + synchronized (this) { + extMcpInfoMap = scannedMap; + updateApprovedMcpServerString(extMcpInfoMap); + persistExtMcpInfo(extMcpInfoMap); + approvedServersToPublish = approvedExtMcpServers; + shouldPublish = true; + } } catch (Exception e) { CopilotCore.LOGGER.error("Error during EXT MCP registration initialization", e); + return; + } + // Publish the verified state outside the synchronized section so the manager monitor is not + // held while subscribers (notably LanguageServerSettingManager) push to the language server. + // The event fires unconditionally on success - including when the persisted JSON is unchanged + // and IPreferenceStore.setValue() therefore short-circuits its property-change notification - + // so the LSP always receives the verified extension-contributed servers. + if (shouldPublish) { + publishRegistrationCompleted(approvedServersToPublish); + } + } + + private void publishRegistrationCompleted(String approvedServersJson) { + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED, approvedServersJson); } } @@ -136,8 +172,7 @@ private void updateApprovedMcpServerString(Map extM if (servers != null && !servers.isEmpty()) { // Merge all servers into the result map servers.forEach((serverName, serverValue) -> { - String displayServerName = regInfo.getPluginDisplayName() + ": " + serverName; - allServers.merge(displayServerName, serverValue, + allServers.merge(serverName, serverValue, (existingValue, newValue) -> newValue != null ? newValue : existingValue); }); } @@ -151,11 +186,12 @@ private void updateApprovedMcpServerString(Map extM /** * Load MCP registration from extension point. */ - private void loadMcpRegistrationExtensionPoint() { + private Map loadMcpRegistrationExtensionPoint() { + Map result = new HashMap<>(); IExtensionRegistry registry = Platform.getExtensionRegistry(); IExtensionPoint extensionPoint = registry.getExtensionPoint(EXTENSION_POINT_ID); if (extensionPoint == null) { - return; + return result; } // Traverse all extensions/bundles. @@ -167,7 +203,7 @@ private void loadMcpRegistrationExtensionPoint() { CopilotCore.LOGGER.error("Cannot find bundle: " + bundleName, null); continue; // Skip inactive plug-ins } - if (bundle.getState() != Bundle.ACTIVE || bundle.getState() != Bundle.STARTING) { + if (bundle.getState() != Bundle.ACTIVE && bundle.getState() != Bundle.STARTING) { try { bundle.start(Bundle.START_ACTIVATION_POLICY); } catch (BundleException e) { @@ -221,9 +257,10 @@ private void loadMcpRegistrationExtensionPoint() { // Update registration info if (!mergedServers.isEmpty()) { - extMcpInfoMap.put(bundleName, new McpRegistrationInfo(isTrusted, isApproved, pluginDisplayName, mergedServers)); + result.put(bundleName, new McpRegistrationInfo(isTrusted, isApproved, pluginDisplayName, mergedServers)); } } + return result; } /** @@ -268,20 +305,22 @@ private boolean isMcpFromSignedBundle(Bundle bundle) { /** * Detect changes in MCP registration from extension point compared to the existing record. */ - private void detectChangesInMcpContribs(Map existingExtMcpInfoMap) { + private void detectChangesInMcpContribs( + Map scannedMap, + Map existingExtMcpInfoMap) { boolean newExtMcpRegFound = false; if (existingExtMcpInfoMap == null) { existingExtMcpInfoMap = Collections.emptyMap(); } // Compare each plugin's current MCP servers with the stored record - for (Map.Entry entry : extMcpInfoMap.entrySet()) { + for (Map.Entry entry : scannedMap.entrySet()) { String contributorName = entry.getKey(); McpRegistrationInfo mcpRegistrationInfo = entry.getValue(); McpRegistrationInfo storedInfo = existingExtMcpInfoMap.get(contributorName); if (storedInfo != null) { - String storedMcpServersJson = storedInfo.getMcpServersAsJson(); - String currentMcpServersJson = mcpRegistrationInfo.getMcpServersAsJson(); + String storedMcpServersJson = storedInfo.getComparableMcpServersAsJson(); + String currentMcpServersJson = mcpRegistrationInfo.getComparableMcpServersAsJson(); if (currentMcpServersJson.equals(storedMcpServersJson)) { mcpRegistrationInfo.setApproved(storedInfo.isApproved()); } else { @@ -296,13 +335,6 @@ private void detectChangesInMcpContribs(Map existin if (newExtMcpRegFound) { mcpConfigService.setNewExtMcpRegFound(true); } - - // Always persist the latest MCP registration info, in case some plug-ins are un-installed, or unregister MCP - // servers. - if (!extMcpInfoMap.equals(existingExtMcpInfoMap)) { - updateApprovedMcpServerString(extMcpInfoMap); - persistExtMcpInfo(extMcpInfoMap); - } } /** @@ -310,23 +342,40 @@ private void detectChangesInMcpContribs(Map existin */ public String approveExtMcpRegistration() { Shell shell = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getShell(); - if (extMcpInfoMap.isEmpty()) { - MessageDialog.openInformation(shell, "", "No MCP server registration found"); - return null; + Map dialogInput; + synchronized (this) { + if (extMcpInfoMap.isEmpty()) { + MessageDialog.openInformation(shell, "", "No MCP server registration found"); + return null; + } + // Take a shallow snapshot so the dialog can iterate the map safely even if doRegistration() + // mutates extMcpInfoMap on the async worker. Approval flips happen on the McpRegistrationInfo + // value objects, which are shared between the snapshot and the live map by reference, so the + // user's choices are reflected in extMcpInfoMap once the dialog returns. + dialogInput = new HashMap<>(extMcpInfoMap); } - McpApprovalDialog dialog = new McpApprovalDialog(shell, extMcpInfoMap); + // Open the modal dialog outside the synchronized block so a concurrent doRegistration() worker + // is not stalled on this manager's monitor while the user interacts with the dialog. + McpApprovalDialog dialog = new McpApprovalDialog(shell, dialogInput); dialog.open(); - mcpConfigService.setNewExtMcpRegFound(false); // Reset the flag after user approval - updateApprovedMcpServerString(extMcpInfoMap); - persistExtMcpInfo(extMcpInfoMap); - return approvedExtMcpServers; + String approvedJson; + synchronized (this) { + mcpConfigService.setNewExtMcpRegFound(false); // Reset the flag after user approval + updateApprovedMcpServerString(extMcpInfoMap); + persistExtMcpInfo(extMcpInfoMap); + approvedJson = approvedExtMcpServers; + } + // Publish outside the lock so subscribers (notably LanguageServerSettingManager) are not + // running their LSP push while the manager monitor is held. + publishRegistrationCompleted(approvedJson); + return approvedJson; } private void persistExtMcpInfo(Map extMcpInfoMap) { IPreferenceStore preferenceStore = CopilotUi.getPlugin().getPreferenceStore(); - String extMcpInfo = gson.toJson(extMcpInfoMap); + String extMcpInfo = gson.toJson(sanitizeExtMcpInfo(extMcpInfoMap)); preferenceStore.setValue(Constants.MCP_EXTENSION_POINT_CONTRIB, extMcpInfo); // Necessary for persistence try { @@ -336,6 +385,39 @@ private void persistExtMcpInfo(Map extMcpInfoMap) { } } + private Map sanitizeExtMcpInfo(Map extMcpInfoMap) { + Map sanitized = new HashMap<>(); + extMcpInfoMap.forEach((key, value) -> sanitized.put(key, new McpRegistrationInfo(value.isTrusted, + value.isApproved, value.pluginDisplayName, sanitizeMap(value.mcpServers)))); + return sanitized; + } + + private static Map sanitizeMap(Map value) { + if (value == null) { + return Collections.emptyMap(); + } + Map sanitized = new HashMap<>(); + value.forEach((key, childValue) -> sanitized.put(key, sanitizeValue(key, childValue))); + return sanitized; + } + + @SuppressWarnings("unchecked") + private static Object sanitizeValue(String key, Object value) { + if (isSensitiveKey(key)) { + return REDACTED_VALUE; + } + if (value instanceof Map map) { + return sanitizeMap((Map) map); + } + return value; + } + + private static boolean isSensitiveKey(String key) { + String upperKey = key == null ? "" : key.toUpperCase(); + return upperKey.contains("SECRET") || upperKey.contains("TOKEN") || upperKey.contains("PASSWORD") + || upperKey.equals("ANYPOINT_CLIENT_ID") || upperKey.endsWith("_CLIENT_ID"); + } + public String getApprovedExtMcpServers() { return approvedExtMcpServers; } @@ -391,12 +473,27 @@ public String getMcpServersAsJson() { wrapper.put("servers", mcpServers); return gson.toJson(wrapper); } + + /** + * Get MCP servers as JSON with sensitive values redacted for persistence comparison. + * + * @return JSON string representation of the redacted MCP servers + */ + public String getComparableMcpServersAsJson() { + if (mcpServers == null || mcpServers.isEmpty()) { + return null; + } + Gson gson = new Gson(); + Map wrapper = new HashMap<>(); + wrapper.put("servers", sanitizeMap(mcpServers)); + return gson.toJson(wrapper); + } } /** * Check if there is any MCP registration from extension point. */ - public boolean hasExtMcpRegistration() { + public synchronized boolean hasExtMcpRegistration() { return !extMcpInfoMap.isEmpty(); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/MuleConsoleParser.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/MuleConsoleParser.java new file mode 100644 index 00000000..df6130f8 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/MuleConsoleParser.java @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses Mule 4 runtime exception output from the Anypoint Studio console and prepends a structured + * summary block. This reduces noise in the context window by surfacing the most actionable fields β€” + * error type, flow name, root cause, and component location β€” before the raw console dump. + */ +final class MuleConsoleParser { + + private static final Pattern ERROR_TYPE_PATTERN = + Pattern.compile("error type:\\s*([A-Z][A-Z0-9_]*:[A-Z][A-Z0-9_]*)", Pattern.CASE_INSENSITIVE); + private static final Pattern ROOT_CAUSE_PATTERN = + Pattern.compile("(?:Caused by|root cause):\\s*(.{1,200})", Pattern.CASE_INSENSITIVE); + private static final Pattern COMPONENT_PATTERN = + Pattern.compile("at ([\\w\\-]+)\\s+@\\s+([\\w\\-./]+/processors/\\d+(?:/\\d+)?)", + Pattern.CASE_INSENSITIVE); + private static final Pattern FLOW_PATTERN = + Pattern.compile("(?:Flow name:\\s*|at flow:\\s*|\\bflow=)([\\w\\-.:]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern MULE_EXCEPTION_MARKER = + Pattern.compile("org\\.mule\\.runtime|MuleRuntimeException|MuleException"); + + private MuleConsoleParser() { + } + + /** + * Inspects raw console output. If a Mule runtime exception is detected, prepends a structured + * [Mule Error Summary] block so the model can orient to the error before reading the full dump. + * + * @param rawOutput the raw console snapshot text + * @return enriched text with summary prepended, or the original text if no Mule exception found + */ + static String enrich(String rawOutput) { + if (rawOutput == null || rawOutput.isBlank() || !isMuleException(rawOutput)) { + return rawOutput; + } + + StringBuilder summary = new StringBuilder("[Mule Error Summary]\n"); + + String errorType = extractFirst(ERROR_TYPE_PATTERN, rawOutput, 1); + if (!errorType.isBlank()) { + summary.append("Error type: ").append(errorType).append('\n'); + } + + String flow = extractFirst(FLOW_PATTERN, rawOutput, 1); + if (!flow.isBlank()) { + summary.append("Flow: ").append(flow).append('\n'); + } + + String rootCause = extractFirst(ROOT_CAUSE_PATTERN, rawOutput, 1); + if (!rootCause.isBlank()) { + summary.append("Root cause: ").append(rootCause.trim()).append('\n'); + } + + String component = extractComponent(rawOutput); + if (!component.isBlank()) { + summary.append("Component: ").append(component).append('\n'); + } + + summary.append('\n'); + return summary + rawOutput; + } + + private static boolean isMuleException(String text) { + return MULE_EXCEPTION_MARKER.matcher(text).find(); + } + + private static String extractFirst(Pattern pattern, String text, int group) { + Matcher matcher = pattern.matcher(text); + return matcher.find() ? matcher.group(group).trim() : ""; + } + + private static String extractComponent(String text) { + Matcher matcher = COMPONENT_PATTERN.matcher(text); + return matcher.find() ? matcher.group(1).trim() + " @ " + matcher.group(2).trim() : ""; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextPromptProcessor.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextPromptProcessor.java new file mode 100644 index 00000000..2789c8e9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextPromptProcessor.java @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import java.util.function.Supplier; + +import org.apache.commons.lang3.StringUtils; + +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService.TransformEditorSnapshot; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService.TransformEditorSnapshot.ScriptEntry; +import com.microsoft.copilot.eclipse.ui.chat.services.TransformEditorContextService.TransformEditorSnapshot.TransformEntry; + +/** + * Applies explicit @transform chat context to the message sent to the language server. + */ +public final class TransformEditorContextPromptProcessor { + private static final String TRANSFORM_CONTEXT_BLOCK_TITLE = "[Transform Context]"; + + private TransformEditorContextPromptProcessor() { + // Utility class. + } + + /** + * Adds transform context to the server payload when the prompt starts with @transform and the feature is available. + * + * @param message original user message + * @param enabled whether transform context is enabled + * @param supportedMode whether the active chat mode supports transform context + * @param snapshotSupplier supplier for the current transform editor snapshot + * @return processed message details + */ + public static ProcessedMessage process(String message, boolean enabled, boolean supportedMode, + Supplier snapshotSupplier) { + if (!enabled || !supportedMode || !startsWithTransformCommand(message)) { + return new ProcessedMessage(message, false); + } + + String promptWithoutCommand = stripTransformCommand(message); + TransformEditorSnapshot snapshot = snapshotSupplier.get(); + String serverMessage = appendTransformContext(promptWithoutCommand, snapshot); + return new ProcessedMessage(serverMessage, true); + } + + /** + * Checks whether a message starts with @transform as a full first token. + * + * @param message user message + * @return true when @transform is the leading command + */ + public static boolean startsWithTransformCommand(String message) { + String trimmed = StringUtils.trimToEmpty(message); + String command = ChatCompletionService.AGENT_MARK + ChatCompletionService.TRANSFORM_CONTEXT_COMMAND; + if (!trimmed.startsWith(command)) { + return false; + } + return trimmed.length() == command.length() || Character.isWhitespace(trimmed.charAt(command.length())); + } + + private static String stripTransformCommand(String message) { + String trimmed = StringUtils.trimToEmpty(message); + int commandLength = (ChatCompletionService.AGENT_MARK + ChatCompletionService.TRANSFORM_CONTEXT_COMMAND).length(); + return StringUtils.stripStart(trimmed.substring(commandLength), null); + } + + private static String appendTransformContext(String prompt, TransformEditorSnapshot snapshot) { + StringBuilder builder = new StringBuilder(StringUtils.defaultString(prompt).stripTrailing()); + if (!builder.isEmpty()) { + builder.append("\n\n"); + } + builder.append(TRANSFORM_CONTEXT_BLOCK_TITLE).append('\n'); + + if (snapshot == null || !snapshot.isAvailable()) { + String reason = snapshot != null ? snapshot.unavailableReason() : "Transform context is unavailable."; + builder.append("Transform context unavailable: ").append(reason); + return builder.toString(); + } + + if (snapshot.isFromPropertiesView()) { + builder.append("source: properties-view\n"); + } + builder.append("file: ").append(snapshot.xmlFilePath()).append('\n'); + builder.append("transforms: ").append(snapshot.transformCount()).append('\n'); + + if (snapshot.isEmpty()) { + builder.append("Transform context: No ee:transform elements found in this file."); + return builder.toString(); + } + + for (TransformEntry entry : snapshot.transforms()) { + builder.append('\n'); + builder.append("--- Transform: ").append(entry.label()).append(" ---\n"); + for (ScriptEntry script : entry.scripts()) { + builder.append("target: ").append(script.target()).append('\n'); + if (!script.outputType().isBlank()) { + builder.append("outputType: ").append(script.outputType()).append('\n'); + } + builder.append("script:\n"); + builder.append(script.script().stripTrailing()).append('\n'); + } + } + + if (snapshot.truncated()) { + builder.append("\n(Note: one or more DataWeave scripts were truncated due to length.)"); + } + + return builder.toString(); + } + + /** + * Silently appends transform context to the message without requiring an explicit {@code @transform} + * command. Returns the original message unchanged when context is unavailable or empty β€” no error + * block is emitted, since the user did not ask for it. + * + * @param message original user message (no @transform prefix expected) + * @param enabled whether transform context is enabled + * @param supportedMode whether the active chat mode supports transform context + * @param snapshotSupplier supplier for the current transform editor snapshot + * @return processed message details + */ + public static ProcessedMessage processAutoInject(String message, boolean enabled, boolean supportedMode, + Supplier snapshotSupplier) { + if (!enabled || !supportedMode) { + return new ProcessedMessage(message, false); + } + TransformEditorSnapshot snapshot = snapshotSupplier.get(); + if (snapshot == null || !snapshot.isAvailable() || snapshot.isEmpty()) { + return new ProcessedMessage(message, false); + } + String serverMessage = appendTransformContext(message, snapshot); + return new ProcessedMessage(serverMessage, true); + } + + /** + * Result of processing a chat message for transform context. + */ + public record ProcessedMessage(String serverMessage, boolean transformContextRequested) { + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextService.java new file mode 100644 index 00000000..b7cd2592 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TransformEditorContextService.java @@ -0,0 +1,286 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.services; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.core.resources.IFile; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleTransformSupport; +import com.microsoft.copilot.eclipse.ui.chat.tools.MuleTransformSupport.ScriptContent; +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; + +/** + * Captures the active Mule XML editor's Transform Message elements for chat context. + */ +public class TransformEditorContextService { + public static final int DEFAULT_MAX_CHARS_PER_SCRIPT = 8_000; + + /** View ID for the Anypoint Studio Transform Message properties panel. */ + public static final String MULE_TRANSFORM_VIEW_ID = "org.mule.tooling.ui.views.transformView"; + + /** Source tag emitted when context comes from the properties-view hint. */ + public static final String SOURCE_PROPERTIES_VIEW = "properties-view"; + + private volatile String activeTransformName = null; + private volatile String activeTransformId = null; + + /** + * Sets a hint identifying the specific Transform Message component currently open in the + * Anypoint Studio properties panel. Called from the workbench part listener when the + * Transform properties view is activated. + * + * @param transformName the {@code doc:name} of the focused transform (may be blank) + * @param transformId the {@code doc:id} of the focused transform (may be blank) + */ + public void setActiveTransformHint(String transformName, String transformId) { + this.activeTransformName = transformName; + this.activeTransformId = transformId; + } + + /** + * Clears the properties-view hint so context falls back to the full file's transforms. + * Called when the Transform properties view is deactivated or closed. + */ + public void clearActiveTransformHint() { + this.activeTransformName = null; + this.activeTransformId = null; + } + + /** + * Captures all Transform Message elements from the currently active Mule XML editor. + * + *

When the Anypoint Studio Transform Message properties view is active, narrows the result + * to the specific transform identified by the properties-view hint. Falls back to all transforms + * in the file when no hint is set, and to searching open editors when no main editor is active. + * Must be called on the SWT UI thread. + * + * @return a transform editor snapshot, or an unavailable snapshot when no Mule XML editor is active + */ + public TransformEditorSnapshot captureActiveTransformContext() { + IFile xmlFile = UiUtils.getCurrentFile(); + if (xmlFile == null) { + xmlFile = findFirstMuleXmlFile(UiUtils.getOpenedFiles()); + } + String nameHint = this.activeTransformName; + String idHint = this.activeTransformId; + return captureTransformContext(xmlFile, nameHint, idHint, DEFAULT_MAX_CHARS_PER_SCRIPT); + } + + /** + * Captures all Transform Message elements by scanning all currently open editors for a Mule XML + * file. Used for silent auto-inject when the user has not typed an explicit {@code @transform} + * command. + * + *

Must be called on the SWT UI thread. + * + * @return a transform editor snapshot, or an unavailable snapshot when no open Mule XML file is found + */ + public TransformEditorSnapshot captureAutoTransformContext() { + IFile xmlFile = findFirstMuleXmlFile(UiUtils.getOpenedFiles()); + return captureTransformContext(xmlFile, null, null, DEFAULT_MAX_CHARS_PER_SCRIPT); + } + + /** + * Captures Transform Message elements from the given Mule XML file, returning all transforms. + * + * @param file the IFile to inspect (may be null) + * @param maxCharsPerScript maximum characters per individual DataWeave script + * @return a transform editor snapshot + */ + public TransformEditorSnapshot captureTransformContext(IFile file, int maxCharsPerScript) { + return captureTransformContext(file, null, null, maxCharsPerScript); + } + + /** + * Captures Transform Message elements from the given Mule XML file, optionally filtered to a + * specific transform by name or ID. + * + * @param file the IFile to inspect (may be null) + * @param transformName optional {@code doc:name} filter (null or blank = match all) + * @param transformId optional {@code doc:id} filter (null or blank = match all) + * @param maxCharsPerScript maximum characters per individual DataWeave script + * @return a transform editor snapshot + */ + public TransformEditorSnapshot captureTransformContext(IFile file, String transformName, String transformId, + int maxCharsPerScript) { + if (file == null) { + return TransformEditorSnapshot.unavailable("No active editor is open."); + } + if (!isMuleXmlFile(file)) { + return TransformEditorSnapshot.unavailable("The active file is not a Mule XML flow file."); + } + + String name = transformName != null ? transformName : ""; + String id = transformId != null ? transformId : ""; + boolean hasHint = !name.isBlank() || !id.isBlank(); + + Path xmlPath = file.getLocation().toFile().toPath(); + try { + Document document = MuleTransformSupport.parseXml(xmlPath); + List transforms = MuleTransformSupport.findTransforms(document, name, id); + + String source = hasHint ? SOURCE_PROPERTIES_VIEW : null; + + if (transforms.isEmpty()) { + return TransformEditorSnapshot.available(xmlPath.toString(), 0, List.of(), false, source); + } + + List entries = new ArrayList<>(); + boolean anyTruncated = false; + + for (Element transform : transforms) { + String label = MuleTransformSupport.transformLabel(transform); + String docName = getDocAttribute(transform, "name"); + String docId = getDocAttribute(transform, "id"); + + List scripts = new ArrayList<>(); + + // payload and attributes (inside ee:message) + for (Element messageEl : MuleTransformSupport.directChildren(transform, "message")) { + for (Element setPayload : MuleTransformSupport.directChildren(messageEl, "set-payload")) { + ReadResult entry = readScript(setPayload, xmlPath, MuleTransformSupport.TARGET_PAYLOAD, maxCharsPerScript); + scripts.add(entry.scriptEntry()); + if (entry.truncated()) { + anyTruncated = true; + } + } + for (Element setAttribs : MuleTransformSupport.directChildren(messageEl, "set-attributes")) { + ReadResult entry = readScript(setAttribs, xmlPath, MuleTransformSupport.TARGET_ATTRIBUTES, + maxCharsPerScript); + scripts.add(entry.scriptEntry()); + if (entry.truncated()) { + anyTruncated = true; + } + } + } + + // variables (inside ee:variables) + for (Element setVar : MuleTransformSupport.directChildren(transform, "variables")) { + for (Element variable : MuleTransformSupport.directChildren(setVar, "set-variable")) { + String varName = variable.getAttribute("variableName"); + String target = varName.isBlank() ? MuleTransformSupport.TARGET_VARIABLE_PREFIX + "unknown" + : MuleTransformSupport.TARGET_VARIABLE_PREFIX + varName; + ReadResult entry = readScript(variable, xmlPath, target, maxCharsPerScript); + scripts.add(entry.scriptEntry()); + if (entry.truncated()) { + anyTruncated = true; + } + } + } + + entries.add(new TransformEditorSnapshot.TransformEntry(label, docName, docId, scripts)); + } + + return TransformEditorSnapshot.available(xmlPath.toString(), transforms.size(), entries, anyTruncated, source); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to capture transform editor context", e); + return TransformEditorSnapshot.unavailable("Failed to read Mule XML: " + e.getMessage()); + } + } + + private IFile findFirstMuleXmlFile(java.util.List files) { + for (IFile file : files) { + if (isMuleXmlFile(file)) { + return file; + } + } + return null; + } + + private boolean isMuleXmlFile(IFile file) { + if (!"xml".equalsIgnoreCase(file.getFileExtension())) { + return false; + } + // Accept files under src/main/mule or any XML in a project that has pom.xml + String fullPath = file.getFullPath().toString(); + return fullPath.contains("/src/main/mule/") || fullPath.contains("\\src\\main\\mule\\"); + } + + private String getDocAttribute(Element element, String attrLocalName) { + String value = element.getAttributeNS(MuleTransformSupport.DOC_NS, attrLocalName); + if (value.isBlank()) { + value = element.getAttribute("doc:" + attrLocalName); + } + return value; + } + + private ReadResult readScript(Element element, Path xmlPath, String target, int maxCharsPerScript) { + ScriptContent content = MuleTransformSupport.readScriptContent(element, xmlPath); + String script = content.script(); + boolean truncated = maxCharsPerScript > 0 && script.length() > maxCharsPerScript; + if (truncated) { + script = script.substring(0, maxCharsPerScript); + } + String outputType = extractOutputType(content.script()); + return new ReadResult( + new TransformEditorSnapshot.ScriptEntry(target, outputType, script), + truncated); + } + + private String extractOutputType(String script) { + if (script == null || script.isBlank()) { + return ""; + } + for (String line : script.split("\n", -1)) { + String trimmed = line.trim(); + if (trimmed.startsWith("output ")) { + return trimmed.substring("output ".length()).trim(); + } + } + return ""; + } + + private record ReadResult(TransformEditorSnapshot.ScriptEntry scriptEntry, boolean truncated) { + } + + /** + * Snapshot of the active Mule XML editor's Transform Message elements for a chat turn. + */ + public record TransformEditorSnapshot( + String xmlFilePath, + int transformCount, + List transforms, + boolean truncated, + String unavailableReason, + String source) { + + public record TransformEntry(String label, String docName, String docId, List scripts) { + } + + public record ScriptEntry(String target, String outputType, String script) { + } + + public static TransformEditorSnapshot available(String xmlFilePath, int transformCount, + List transforms, boolean truncated, String source) { + return new TransformEditorSnapshot(xmlFilePath, transformCount, transforms, truncated, null, source); + } + + public static TransformEditorSnapshot available(String xmlFilePath, int transformCount, + List transforms, boolean truncated) { + return available(xmlFilePath, transformCount, transforms, truncated, null); + } + + public static TransformEditorSnapshot unavailable(String reason) { + return new TransformEditorSnapshot(null, 0, List.of(), false, reason, null); + } + + public boolean isAvailable() { + return unavailableReason == null; + } + + public boolean isEmpty() { + return transforms.isEmpty(); + } + + public boolean isFromPropertiesView() { + return SOURCE_PROPERTIES_VIEW.equals(source); + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java index 0ca4b969..277cb437 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java @@ -206,6 +206,16 @@ public void onDidCopilotStatusChange(CopilotStatusResult copilotStatusResult) { String status = copilotStatusResult.getStatus(); switch (status) { case CopilotStatusResult.OK, CopilotStatusResult.NOT_AUTHORIZED: + // Asynchronously reload built-in modes in case the LSP was not ready at enum-init time + // (startup race condition). Update the observable on completion so the mode picker refreshes. + BuiltInChatModeManager.INSTANCE.reloadModesAsync().thenRun(() -> ensureRealm(() -> { + if (!Arrays.deepEquals(getAvailableChatModes(), chatModeObservable.getValue())) { + chatModeObservable.setValue(getAvailableChatModes()); + } + })).exceptionally(ex -> { + CopilotCore.LOGGER.error("Failed to reload built-in modes on status change", ex); + return null; + }); init(); break; default: diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ApiSchemaAnalyzeTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ApiSchemaAnalyzeTool.java new file mode 100644 index 00000000..139126ff --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ApiSchemaAnalyzeTool.java @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Lightweight API schema analyzer for MuleSoft projects. + */ +public class ApiSchemaAnalyzeTool extends BaseTool { + static final String TOOL_NAME = "api_schema_analyze"; + + /** + * Creates an API schema analyzer tool. + */ + public ApiSchemaAnalyzeTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Analyze Mule API schema files"); + toolInfo.setDescription(""" + Analyze RAML, OpenAPI, OData, AsyncAPI, GraphQL, WSDL, XSD, JSON Schema, Avro, CSV, or flat-file metadata. + Reports syntax errors and governance diagnostics. Governance issues include: missing required metadata + (title, version, baseUri/servers), missing examples on request or response bodies, missing error response + definitions (400, 401, 404, 500), no security scheme defined, inline anonymous schemas that should be + named reusable types, inconsistent naming conventions across endpoints, and APIkit compatibility issues + (RAML baseUri and version must match the APIkit router config, all resources must have at least one method). + Common findings: RAML with no securitySchemes, OpenAPI with 200-only responses on POST endpoints, + RAML types section missing (all schemas inline), examples that do not validate against their schema. + This tool is read-only. + """); + toolInfo.setInputSchema(MuleToolInputs.schemaAnalyzeSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path schemaPath = MuleToolInputs.existingFile(input.get(MuleToolInputs.SCHEMA_PATH)); + if (schemaPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("schemaPath must be an absolute path to an existing schema file."); + } else { + result.setStatus(ToolInvocationStatus.success); + result.addContent(MuleProjectAnalyzer.schemaAnalyzeResponse(schemaPath, + MuleToolInputs.optionalString(input.get(MuleToolInputs.SCHEMA_TYPE)), + MuleToolInputs.optionalPath(input.get(MuleToolInputs.RULESET_PATH))).toJson()); + } + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to analyze API schema: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java new file mode 100644 index 00000000..aed4594c --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.eclipse.core.resources.IFile; + +/** + * Represents a file tracked in the file change summary bar. + */ +public final class ChangedFile { + private final IFile workspaceFile; + private final Path localPath; + + private ChangedFile(IFile workspaceFile, Path localPath) { + this.workspaceFile = workspaceFile; + this.localPath = localPath; + } + + /** + * Creates a changed file entry for a workspace file. + * + * @param file the workspace file + * @return the changed file entry + */ + public static ChangedFile workspace(IFile file) { + return new ChangedFile(Objects.requireNonNull(file), null); + } + + /** + * Creates a changed file entry for a local file. + * + * @param path the local file path + * @return the changed file entry + */ + public static ChangedFile local(Path path) { + return new ChangedFile(null, normalize(path)); + } + + /** + * Returns true if this entry represents a workspace file. + * + * @return true for workspace files, false for local files + */ + public boolean isWorkspaceFile() { + return workspaceFile != null; + } + + /** + * Gets the workspace file for this entry. + * + * @return the workspace file, or null for local files + */ + public IFile getWorkspaceFile() { + return workspaceFile; + } + + /** + * Gets the local path for this entry. + * + * @return the local path, or null for workspace files + */ + public Path getLocalPath() { + return localPath; + } + + /** + * Gets the display name for this file. + * + * @return the file name + */ + public String getName() { + if (workspaceFile != null) { + return workspaceFile.getName(); + } + Path fileName = localPath.getFileName(); + return fileName == null ? localPath.toString() : fileName.toString(); + } + + /** + * Gets the display path for this file. + * + * @return the workspace path or local filesystem path + */ + public String getDisplayPath() { + if (workspaceFile != null) { + return workspaceFile.getFullPath().toString(); + } + return localPath.toString(); + } + + private static Path normalize(Path path) { + return Objects.requireNonNull(path).toAbsolutePath().normalize(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof ChangedFile other)) { + return false; + } + return Objects.equals(workspaceFile, other.workspaceFile) && Objects.equals(localPath, other.localPath); + } + + @Override + public int hashCode() { + return Objects.hash(workspaceFile, localPath); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("workspaceFile", workspaceFile); + builder.append("localPath", localPath); + return builder.toString(); + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java index 29a807ce..c7795aeb 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java @@ -5,9 +5,13 @@ import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.Arrays; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -19,6 +23,7 @@ import org.eclipse.core.runtime.NullProgressMonitor; import org.eclipse.lsp4j.FileChangeType; +import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchema; import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchemaPropertyValue; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; @@ -57,7 +62,7 @@ public LanguageModelToolInformation getToolInformation() { // Set the name and description of the tool toolInfo.setName(TOOL_NAME); toolInfo.setDescription(""" - This is a tool for creating a new file in the workspace. + This is a tool for creating a new workspace file or a new file at an absolute local filesystem path. The file will be created with the specified content. """); @@ -90,34 +95,49 @@ public CompletableFuture invoke(Map i return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); } - try { - // Resolve file in workspace - IFile file = FileUtils.getFileFromPath(filePath, false); + String content = StringUtils.isBlank((String) input.get("content")) ? "" : (String) input.get("content"); + result = createFile(filePath, content); - if (file == null) { - result.setStatus(ToolInvocationStatus.error); - result.addContent("Invalid file path: " + filePath + " does not exist in the workspace."); - return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); - } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + private LanguageModelToolResult createFile(String filePath, String content) { + IFile file = FileUtils.getFileFromPath(filePath, false); + + if (file != null && file.getProject().exists()) { + return createWorkspaceFile(file, filePath, content); + } + + Path localPath = getLocalFilePath(filePath); + if (localPath != null) { + return createLocalFile(localPath, content); + } - // Check if file already exists + LanguageModelToolResult result = new LanguageModelToolResult(); + result.setStatus(ToolInvocationStatus.error); + result.addContent("Invalid file path: " + filePath + " does not exist in the workspace."); + return result; + } + + private LanguageModelToolResult createWorkspaceFile(IFile file, String filePath, String content) { + LanguageModelToolResult result = new LanguageModelToolResult(); + + try { if (file.exists()) { result.setStatus(ToolInvocationStatus.error); result.addContent("Failed: file already exists: " + filePath + ". Please use edit file tool to update."); - return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + return result; } - // Create parent folders if needed createParentFolders(file.getParent()); - // Create file with content - String content = StringUtils.isBlank((String) input.get("content")) ? "" : (String) input.get("content"); try (ByteArrayInputStream contentStream = new ByteArrayInputStream( content.getBytes(PlatformUtils.getFileCharset(file)))) { file.create(contentStream, IResource.FORCE, new NullProgressMonitor()); - cacheTheOriginalFileContent(file); + cacheTheOriginalFileContent(ChangedFile.workspace(file), StringUtils.EMPTY); } - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(file, FileChangeType.Created); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(ChangedFile.workspace(file), + FileChangeType.Created); file.refreshLocal(IResource.DEPTH_ZERO, new NullProgressMonitor()); result.addContent("File created at: " + file.getFullPath().toOSString()); @@ -130,7 +150,36 @@ public CompletableFuture invoke(Map i result.addContent("Error handling file stream: " + e.getMessage()); } - return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + return result; + } + + private LanguageModelToolResult createLocalFile(Path filePath, String content) { + LanguageModelToolResult result = new LanguageModelToolResult(); + Path normalizedPath = normalizeLocalPath(filePath); + if (Files.exists(normalizedPath, LinkOption.NOFOLLOW_LINKS)) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed: file already exists: " + normalizedPath + ". Please use edit file tool to update."); + return result; + } + + try { + Path parent = normalizedPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.writeString(normalizedPath, content, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + cacheTheOriginalFileContent(ChangedFile.local(normalizedPath), StringUtils.EMPTY); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile( + ChangedFile.local(normalizedPath), FileChangeType.Created); + result.addContent("File created at: " + normalizedPath); + result.setStatus(ToolInvocationStatus.success); + } catch (IOException e) { + CopilotCore.LOGGER.error("Error creating local file", e); + result.setStatus(ToolInvocationStatus.error); + result.addContent("Error creating file: " + e.getMessage()); + } + + return result; } /** @@ -152,33 +201,36 @@ private void createParentFolders(IResource parent) throws CoreException { } @Override - public void onKeepAllChanges(List files) { - files.forEach(this::onKeepChange); + public void onKeepChange(ChangedFile file) { + removeCachedFileContent(file); + closeCompareEditor(file); } @Override - public void onKeepChange(IFile file) { + public void onUndoChange(ChangedFile file) throws CoreException, IOException { + deleteCreatedFile(file); + removeCachedFileContent(file); closeCompareEditor(file); } - @Override - public void onUndoAllChanges(List files) throws CoreException { - for (IFile file : files) { - onUndoChange(file); + private void deleteCreatedFile(ChangedFile file) throws CoreException, IOException { + if (file.isWorkspaceFile()) { + IFile workspaceFile = file.getWorkspaceFile(); + if (workspaceFile != null && workspaceFile.exists()) { + workspaceFile.delete(true, new NullProgressMonitor()); + } + return; } + Files.deleteIfExists(file.getLocalPath()); } @Override - public void onUndoChange(IFile file) throws CoreException { - if (file != null && file.exists()) { - file.delete(true, new NullProgressMonitor()); + public void onViewDiff(ChangedFile file) { + if (file.isWorkspaceFile()) { + SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openInEditor(file.getWorkspaceFile())); + return; } - closeCompareEditor(file); - } - - @Override - public void onViewDiff(IFile file) { - SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openInEditor(file)); + SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openLocalFileInEditor(file.getLocalPath())); } @Override diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/DwlAnalyzer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/DwlAnalyzer.java new file mode 100644 index 00000000..3dee42da --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/DwlAnalyzer.java @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Static analysis helper for DataWeave 2.0 scripts. Detects common performance anti-patterns, + * null-safety gaps, and documentation gaps. + */ +final class DwlAnalyzer { + + private static final Pattern NESTED_MAP_FILTER = + Pattern.compile("map\\s*\\([^)]*->\\s*[^)]*filter\\b|filter\\s*\\([^)]*->\\s*[^)]*map\\b", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private static final Pattern INLINE_REGEX_IN_MAP = + Pattern.compile("(?:map|filter)\\s*[({][^)}]*\\/[^/\\n]+\\/[^)}]*[)}]", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private static final Pattern ROUND_TRIP_WRITE_READ = + Pattern.compile("write\\s*\\([^)]*read\\s*\\(|read\\s*\\([^)]*write\\s*\\(", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private static final Pattern FIELD_ACCESS_WITHOUT_DEFAULT = + Pattern.compile("payload\\.\\w+(?!\\s+default\\b)", Pattern.CASE_INSENSITIVE); + private static final Pattern FUN_DECL = + Pattern.compile("^(\\s*)fun\\s+(\\w+)\\s*\\(", Pattern.MULTILINE); + private static final Pattern COMMENT_BEFORE_LINE = + Pattern.compile("//[^\\n]*\\n\\s*fun\\s+|/\\*\\*[\\s\\S]*?\\*/\\s*fun\\s+", + Pattern.CASE_INSENSITIVE); + + record Issue(String type, int line, String description, String suggestion) { + } + + private DwlAnalyzer() { + } + + /** + * Analyzes a DataWeave script for common issues. + * + * @param script the full script text + * @return list of issues found (may be empty) + */ + static List analyze(String script) { + List issues = new ArrayList<>(); + if (script == null || script.isBlank()) { + return issues; + } + + String[] lines = script.split("\\r?\\n", -1); + + // Check 1: Missing %dw 2.0 header + if (!script.stripLeading().startsWith("%dw 2.0")) { + issues.add(new Issue("missing-dw-header", 1, + "Script is missing the '%dw 2.0' header directive.", + "%dw 2.0\noutput application/json\n---\n" + script.stripLeading())); + } + + // Check 2: Missing output directive + boolean hasOutput = false; + for (String line : lines) { + if (line.trim().startsWith("output ")) { + hasOutput = true; + break; + } + } + if (!hasOutput) { + issues.add(new Issue("missing-output-directive", 1, + "No 'output' directive found. Add 'output application/json' (or the appropriate type) after the %dw 2.0 header.", + "output application/json")); + } + + // Check 3: Nested map+filter (O(nΓ—m) pattern) + if (NESTED_MAP_FILTER.matcher(script).find()) { + int lineNum = findPatternLine(NESTED_MAP_FILTER, script); + issues.add(new Issue("nested-map-filter", lineNum, + "Nested map+filter detected β€” this is O(nΓ—m). Pre-index the inner array with 'groupBy' and look up in O(1).", + "var indexedB = arrayB groupBy $.id\narrayA map (a -> indexedB[a.id][0] default {})")); + } + + // Check 4: Inline regex inside map/filter + if (INLINE_REGEX_IN_MAP.matcher(script).find()) { + int lineNum = findPatternLine(INLINE_REGEX_IN_MAP, script); + issues.add(new Issue("inline-regex-in-map", lineNum, + "Regex literal inside map/filter is compiled on every iteration. Extract to a 'var' before the map.", + "var namePattern = /^[A-Z].*/\npayload map (item -> item.name matches namePattern)")); + } + + // Check 5: Round-trip serialization (write then read, or read then write) + if (ROUND_TRIP_WRITE_READ.matcher(script).find()) { + int lineNum = findPatternLine(ROUND_TRIP_WRITE_READ, script); + issues.add(new Issue("round-trip-serialization", lineNum, + "write() immediately followed by read() (or vice versa) is a no-op round-trip. Remove both calls.", + "// Remove the write(...) and read(...) pair β€” pass the value directly")); + } + + // Check 6: Field access without null guard (heuristic β€” flags bare payload.field patterns) + Matcher nullMatcher = FIELD_ACCESS_WITHOUT_DEFAULT.matcher(script); + int nullIssues = 0; + while (nullMatcher.find() && nullIssues < 3) { + int lineNum = lineNumberAt(script, nullMatcher.start()); + String access = nullMatcher.group().trim(); + issues.add(new Issue("missing-null-guard", lineNum, + "'" + access + "' accessed without a 'default' guard. If this field is optional, add 'default'.", + access + " default \"\"")); + nullIssues++; + } + + // Check 7: Undocumented fun declarations + Matcher funMatcher = FUN_DECL.matcher(script); + while (funMatcher.find()) { + int lineNum = lineNumberAt(script, funMatcher.start()); + String funName = funMatcher.group(2); + boolean hasComment = hasCommentBefore(script, funMatcher.start()); + if (!hasComment) { + issues.add(new Issue("undocumented-function", lineNum, + "Function '" + funName + "' has no documentation comment.", + "// " + funName + ": describe what this function does, its parameters and return type")); + } + } + + return issues; + } + + /** + * Adds documentation comments before undocumented {@code fun} declarations in the script. + * + * @param script the original DataWeave script + * @return the script with comment stubs inserted + */ + static String addComments(String script) { + if (script == null || script.isBlank()) { + return script; + } + StringBuilder result = new StringBuilder(); + String[] lines = script.split("\\r?\\n", -1); + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + String trimmed = line.trim(); + if (trimmed.startsWith("fun ")) { + boolean alreadyCommented = (i > 0 && lines[i - 1].trim().startsWith("//")) + || (i > 0 && lines[i - 1].trim().startsWith("*")) + || (i > 0 && lines[i - 1].trim().startsWith("/**")); + if (!alreadyCommented) { + String indent = line.substring(0, line.length() - line.stripLeading().length()); + String funName = trimmed.replaceFirst("fun\\s+(\\w+).*", "$1"); + result.append(indent).append("// ").append(funName) + .append(": describe what this function does, its parameters and return type") + .append(System.lineSeparator()); + } + } + result.append(line); + if (i < lines.length - 1) { + result.append(System.lineSeparator()); + } + } + return result.toString(); + } + + private static int findPatternLine(Pattern pattern, String script) { + Matcher m = pattern.matcher(script); + if (m.find()) { + return lineNumberAt(script, m.start()); + } + return 0; + } + + private static int lineNumberAt(String script, int charIndex) { + int line = 1; + for (int i = 0; i < charIndex && i < script.length(); i++) { + if (script.charAt(i) == '\n') { + line++; + } + } + return line; + } + + private static boolean hasCommentBefore(String script, int funStart) { + int lineStart = script.lastIndexOf('\n', funStart - 1); + if (lineStart < 0) { + return false; + } + String prevLine = script.substring(script.lastIndexOf('\n', lineStart - 1) + 1, lineStart).trim(); + return prevLine.startsWith("//") || prevLine.startsWith("*") || prevLine.startsWith("/**"); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java index 9a6c532e..6a20fcd6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java @@ -7,13 +7,14 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; import java.util.Arrays; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; -import org.eclipse.compare.CompareEditorInput; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; import org.eclipse.core.runtime.CoreException; @@ -52,7 +53,7 @@ public LanguageModelToolInformation getToolInformation() { // Set the name and description of the tool toolInfo.setName(TOOL_NAME); toolInfo.setDescription(""" - Insert new code into an existing file in the workspace. + Insert new code into an existing workspace file or local filesystem file. Use this tool once per file that needs to be modified, even if there are multiple changes for a file. Generate the "explanation" property first. The system is very smart and can understand how to apply your edits to the files, @@ -122,30 +123,8 @@ class Person { public CompletableFuture invoke(Map input, ChatView chatView) { CompletableFuture resultFuture = new CompletableFuture<>(); if (input.get("filePath") instanceof String filePath) { - IFile file = FileUtils.getFileFromPath(filePath, true); - - if (file == null || !file.exists()) { - resultFuture.complete(new LanguageModelToolResult[] { - new LanguageModelToolResult("The file path provided does not exist. Please check the path and try again.", - ToolInvocationStatus.error) }); - return resultFuture; - } - if (input.get("code") instanceof String code) { - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(file, FileChangeType.Changed); - cacheTheOriginalFileContent(file); - try { - applyChangesToFile(code, file); - } catch (CoreException | IOException e) { - CopilotCore.LOGGER.error("Error replacing file content", e); - resultFuture.complete(new LanguageModelToolResult[] { new LanguageModelToolResult( - "Failed to apply changes to the file: " + e.getMessage(), ToolInvocationStatus.error) }); - return resultFuture; - } - refreshCompareEditorIfOpen(fileContentCache.get(file), file); - // Must return the updated content as a result to the CLS. - resultFuture.complete( - new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }); + resultFuture.complete(editFile(filePath, code)); } else { resultFuture.complete(new LanguageModelToolResult[] { new LanguageModelToolResult("The code provided is not a valid string. Please check the code and try again.", @@ -160,6 +139,60 @@ public CompletableFuture invoke(Map i return resultFuture; } + private LanguageModelToolResult[] editFile(String filePath, String code) { + IFile file = FileUtils.getFileFromPath(filePath, true); + + if (file != null && file.exists()) { + return editWorkspaceFile(file, code); + } + + Path localPath = getLocalFilePath(filePath); + if (localPath != null && Files.isRegularFile(localPath, LinkOption.NOFOLLOW_LINKS)) { + return editLocalFile(localPath, code); + } + + return new LanguageModelToolResult[] { + new LanguageModelToolResult("The file path provided does not exist. Please check the path and try again.", + ToolInvocationStatus.error) }; + } + + private LanguageModelToolResult[] editWorkspaceFile(IFile file, String code) { + ChangedFile changedFile = ChangedFile.workspace(file); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(changedFile, + FileChangeType.Changed); + cacheTheOriginalFileContent(changedFile); + try { + applyChangesToFile(code, file); + } catch (CoreException | IOException e) { + CopilotCore.LOGGER.error("Error replacing file content", e); + return new LanguageModelToolResult[] { new LanguageModelToolResult( + "Failed to apply changes to the file: " + e.getMessage(), ToolInvocationStatus.error) }; + } + refreshCompareEditorIfOpen(getCachedFileContent(changedFile), changedFile); + return new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }; + } + + private LanguageModelToolResult[] editLocalFile(Path filePath, String code) { + Path normalizedPath = normalizeLocalPath(filePath); + ChangedFile changedFile = ChangedFile.local(normalizedPath); + try { + String originalContent = getCachedFileContent(changedFile); + if (originalContent == null) { + originalContent = Files.readString(normalizedPath, StandardCharsets.UTF_8); + } + Files.writeString(normalizedPath, code, StandardCharsets.UTF_8); + cacheTheOriginalFileContent(changedFile, originalContent); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(changedFile, + FileChangeType.Changed); + refreshCompareEditorIfOpen(getCachedFileContent(changedFile), changedFile); + return new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }; + } catch (IOException e) { + CopilotCore.LOGGER.error("Error replacing local file content", e); + return new LanguageModelToolResult[] { new LanguageModelToolResult( + "Failed to apply changes to the file: " + e.getMessage(), ToolInvocationStatus.error) }; + } + } + private void applyChangesToFile(String changedContent, IFile file) throws CoreException, IOException { if (!validateEdit(file)) { throw new IllegalStateException("File validation failed for " + file.getFullPath()); @@ -189,55 +222,40 @@ private ByteArrayInputStream getInputStream(String changedContent, IFile file) { } @Override - public void onKeepChange(IFile file) { - fileContentCache.remove(file); + public void onKeepChange(ChangedFile file) { + removeCachedFileContent(file); closeCompareEditor(file); } @Override - public void onKeepAllChanges(List files) { - for (IFile file : files) { - onKeepChange(file); - } - } - - @Override - public void onUndoChange(IFile file) throws CoreException, IOException { + public void onUndoChange(ChangedFile file) throws CoreException, IOException { undoChangesToFile(file); closeCompareEditor(file); } @Override - public void onUndoAllChanges(List files) throws CoreException, IOException { - for (IFile file : files) { - onUndoChange(file); + public void onViewDiff(ChangedFile file) { + if (bringCompareEditorToTopIfOpen(file)) { + return; } + compareStringWithFile(getCachedFileContent(file), file); } - @Override - public void onViewDiff(IFile file) { - CompareEditorInput input = compareEditorInputMap.get(file); - if (input != null) { - if (isCompareEditorOpen(input)) { - bringCompareEditorToTop(input); - return; - } - // Compare editor was closed by the user, remove stale entry and recreate - compareEditorInputMap.remove(file); + private void undoChangesToFile(ChangedFile file) throws CoreException, IOException { + String fileCache = getCachedFileContent(file); + if (fileCache == null) { + return; + } + if (file.isWorkspaceFile()) { + applyChangesToFile(fileCache, file.getWorkspaceFile()); + } else { + Files.writeString(file.getLocalPath(), fileCache, StandardCharsets.UTF_8); } - compareStringWithFile(fileContentCache.get(file), file); + removeCachedFileContent(file); } @Override public void onResolveAllChanges() { cleanupChangedFiles(); } - - private void undoChangesToFile(IFile file) throws CoreException, IOException { - String fileCache = fileContentCache.get(file); - if (fileCache != null) { - applyChangesToFile(fileCache, file); - } - fileContentCache.remove(file); - } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java index 1bf6fc6e..3ad526bf 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java @@ -8,11 +8,17 @@ import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.lang.reflect.InvocationTargetException; +import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import org.eclipse.compare.CompareConfiguration; import org.eclipse.compare.CompareEditorInput; @@ -30,6 +36,7 @@ import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.Status; import org.eclipse.swt.graphics.Image; import org.eclipse.ui.IEditorPart; import org.eclipse.ui.IEditorReference; @@ -49,8 +56,8 @@ * Abstract class for handling file change tool related actions. */ public abstract class FileToolBase extends BaseTool { - protected static Map compareEditorInputMap = new ConcurrentHashMap<>(); - protected static Map fileContentCache = new ConcurrentHashMap<>(); + protected static Map compareEditorInputMap = new ConcurrentHashMap<>(); + protected static Map fileContentCache = new ConcurrentHashMap<>(); @Override public abstract CompletableFuture invoke(Map input, ChatView chatView); @@ -59,7 +66,7 @@ public abstract class FileToolBase extends BaseTool { * Common method to handle cleanup of file changes. */ protected void cleanupChangedFiles() { - for (IFile file : compareEditorInputMap.keySet()) { + for (ChangedFile file : compareEditorInputMap.keySet()) { closeCompareEditor(file); } compareEditorInputMap.clear(); @@ -67,24 +74,62 @@ protected void cleanupChangedFiles() { } /** - * Caches the original content of the file to be compared with the proposed changes. + * Caches the original content of the changed file to be compared with the proposed changes. * - * @param file The file whose original content is to be cached. + * @param file The changed file whose original content is to be cached. */ - protected void cacheTheOriginalFileContent(IFile file) { + protected void cacheTheOriginalFileContent(ChangedFile file) { if (fileContentCache.containsKey(file)) { // We only need to cache the original file content once to keep the initial file content so that we can undo the // entire file edit even the file has been modified for multiple rounds. return; } - try (InputStream inputStream = file.getContents()) { - String content = new String(inputStream.readAllBytes(), PlatformUtils.getFileCharset(file)); - fileContentCache.put(file, content); + try { + fileContentCache.put(file, readCurrentFileContent(file)); } catch (IOException | CoreException e) { CopilotCore.LOGGER.error("Error caching original file content", e); } } + /** + * Caches the original content for a changed file if no baseline exists yet. + * + * @param file The file whose original content is to be cached. + * @param content The content to use as the original baseline. + */ + protected void cacheTheOriginalFileContent(ChangedFile file, String content) { + fileContentCache.putIfAbsent(file, content); + } + + private String readCurrentFileContent(ChangedFile file) throws IOException, CoreException { + if (file.isWorkspaceFile()) { + IFile workspaceFile = file.getWorkspaceFile(); + try (InputStream inputStream = workspaceFile.getContents()) { + return new String(inputStream.readAllBytes(), PlatformUtils.getFileCharset(workspaceFile)); + } + } + return Files.readString(file.getLocalPath(), StandardCharsets.UTF_8); + } + + /** + * Gets the cached original content for a changed file. + * + * @param file The changed file whose cached content should be returned. + * @return the cached content, or null if no content is cached. + */ + protected String getCachedFileContent(ChangedFile file) { + return fileContentCache.get(file); + } + + /** + * Removes the cached original content for a changed file. + * + * @param file The changed file whose cached content should be removed. + */ + protected void removeCachedFileContent(ChangedFile file) { + fileContentCache.remove(file); + } + /** * Validate the edit to ensure the files are writable. * @@ -105,14 +150,12 @@ public void run(IProgressMonitor monitor) throws CoreException { } /** - * Compares the given string with the content of the given file in a compare editor. + * Compares the given string with the content of a changed file in a compare editor. * * @param originalFileContent The original string content of the file to compare with. - * @param file The user's file with the proposed changes has been applied. - * @throws InvocationTargetException If the operation is canceled. - * @throws InterruptedException If the operation is canceled. + * @param file The changed file with the proposed changes applied. */ - protected void compareStringWithFile(String originalFileContent, IFile file) { + protected void compareStringWithFile(String originalFileContent, ChangedFile file) { try { CompareEditorInput input = createCompareEditorInput(originalFileContent, file); input.run(new NullProgressMonitor()); @@ -130,47 +173,12 @@ protected void compareStringWithFile(String originalFileContent, IFile file) { } /** - * Updates the current or creates a new compare editor with the given file content and file. - * - * @param originalFileContent The original string content of the file to compare with. - * @param file The user's file with the proposed changes has been applied. - */ - protected void updateOrCreateCompareStringWithFile(String fileContent, IFile file) { - if (fileContent == null) { - return; - } - - CompareEditorInput input = compareEditorInputMap.get(file); - if (input != null) { - if (fileContent.equals(fileContentCache.get(file))) { - SwtUtils.invokeOnDisplayThreadAsync(() -> { - CompareUI.reuseCompareEditor(input, (IReusableEditor) getCompareEditor(input)); - }); - } else { - CompareEditorInput newInput = createCompareEditorInput(fileContent, file); - compareEditorInputMap.put(file, newInput); - SwtUtils.invokeOnDisplayThreadAsync(() -> { - CompareEditorInput compareEditorInput = compareEditorInputMap.get(file); - if (compareEditorInput != null) { - CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) getCompareEditor(compareEditorInput)); - } - }); - } - bringCompareEditorToTop(input); - } else { - // If not, create a new compare editor - compareStringWithFile(fileContent, file); - } - } - - /** - * Refreshes the compare editor for the given file only if it is already open. Does not open a new editor or steal - * focus. + * Refreshes the compare editor for the given changed file only if it is already open. * * @param fileContent The original file content to compare against. - * @param file The file whose compare editor should be refreshed. + * @param file The changed file whose compare editor should be refreshed. */ - protected void refreshCompareEditorIfOpen(String fileContent, IFile file) { + protected void refreshCompareEditorIfOpen(String fileContent, ChangedFile file) { if (fileContent == null) { return; } @@ -184,11 +192,10 @@ protected void refreshCompareEditorIfOpen(String fileContent, IFile file) { // If the compare editor is closed, remove the input from the map and skip refreshing. compareEditorInputMap.remove(file); return; - } else { - CompareEditorInput compareEditorInput = compareEditorInputMap.get(file); - if (compareEditorInput != null) { - CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) editor); - } + } + CompareEditorInput compareEditorInput = compareEditorInputMap.get(file); + if (compareEditorInput != null) { + CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) editor); } }); } @@ -236,12 +243,11 @@ private IEditorPart getCompareEditor(CompareEditorInput input) { } /** - * Close the compare editor for the given file if it is open. + * Closes the compare editor for the given changed file if it is open. * - * @param file The file to check. - * @return true if the compare editor is open, false otherwise. + * @param file The changed file to check. */ - protected void closeCompareEditor(IFile file) { + protected void closeCompareEditor(ChangedFile file) { CompareEditorInput input = compareEditorInputMap.get(file); if (input != null) { SwtUtils.invokeOnDisplayThread(() -> { @@ -262,70 +268,154 @@ protected void closeCompareEditor(IFile file) { compareEditorInputMap.remove(file); } - private CompareEditorInput createCompareEditorInput(String comparedContent, IFile file) { - // Create a new CompareConfiguration - CompareConfiguration config = new CompareConfiguration(); - config.setLeftLabel(Messages.agent_tool_compareEditor_proposedChangesTitle.replaceAll("\"", "")); - config.setRightLabel(file.getName()); + /** + * Brings the compare editor for a changed file to the top if it is open. + * + * @param file The changed file whose compare editor should be shown. + * @return true if an open compare editor was found, false otherwise. + */ + protected boolean bringCompareEditorToTopIfOpen(ChangedFile file) { + CompareEditorInput input = compareEditorInputMap.get(file); + if (input == null) { + return false; + } + if (isCompareEditorOpen(input)) { + bringCompareEditorToTop(input); + return true; + } + compareEditorInputMap.remove(file); + return false; + } - // Enable editing on the proposed changes side and disable it on the original file side. Eclipse's original side - // and - // changes side are swapped, so we need to set the left side as editable to edit the proposed changes. - config.setLeftEditable(true); - config.setRightEditable(false); + /** + * Normalizes a local path for cache and map lookups. + * + * @param file the local file path + * @return the normalized absolute path + */ + protected Path normalizeLocalPath(Path file) { + return file.toAbsolutePath().normalize(); + } - // Set up the configuration to properly show differences - config.setProperty(CompareConfiguration.USE_OUTLINE_VIEW, Boolean.TRUE); - config.setProperty(CompareConfiguration.SHOW_PSEUDO_CONFLICTS, Boolean.TRUE); - config.setProperty(CompareConfiguration.IGNORE_WHITESPACE, Boolean.FALSE); + /** + * Resolves an absolute local filesystem path from a path or file URI. + * + * @param filePath the path or URI to resolve + * @return the local filesystem path, or null if the input is not an absolute local path + */ + protected Path getLocalFilePath(String filePath) { + try { + if (filePath.startsWith("file:")) { + return Paths.get(new URI(filePath)); + } + Path path = Paths.get(filePath); + return path.isAbsolute() ? path : null; + } catch (IllegalArgumentException | URISyntaxException e) { + CopilotCore.LOGGER.error("Invalid local file path: " + filePath, e); + return null; + } + } + + private CompareEditorInput createWorkspaceCompareEditorInput(String comparedContent, IFile file) { + ChangedFile changedFile = ChangedFile.workspace(file); + EditableFileCompareInput originalFile = new EditableFileCompareInput(file); + return createCompareEditorInputForTarget(comparedContent, originalFile.getName(), originalFile.getType(), + PlatformUtils.getFileCharset(file), () -> originalFile, (diffNode, monitor) -> { + EditableFileCompareInput inputToBeApplied = (EditableFileCompareInput) diffNode.getLeft(); + try (InputStream inputStream = inputToBeApplied.getContents()) { + file.setContents(inputStream, true, true, monitor); + } catch (IOException e) { + CopilotCore.LOGGER.error("Error saving compare editor changes to file", e); + } + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().completeFile(changedFile); + removeCachedFileContent(changedFile); + }); + } + + private CompareEditorInput createLocalCompareEditorInput(String comparedContent, Path file) { + Path normalizedPath = normalizeLocalPath(file); + ChangedFile changedFile = ChangedFile.local(normalizedPath); + EditableFileCompareInput originalFile = new EditableFileCompareInput(normalizedPath); + return createCompareEditorInputForTarget(comparedContent, originalFile.getName(), originalFile.getType(), + StandardCharsets.UTF_8.name(), () -> originalFile, + (diffNode, monitor) -> { + EditableFileCompareInput inputToBeApplied = (EditableFileCompareInput) diffNode.getLeft(); + try (InputStream inputStream = inputToBeApplied.getContents()) { + Files.write(normalizedPath, inputStream.readAllBytes()); + } catch (IOException e) { + CopilotCore.LOGGER.error("Error saving compare editor changes to local file", e); + } + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().completeFile(changedFile); + removeCachedFileContent(changedFile); + }); + } + + private CompareEditorInput createCompareEditorInput(String comparedContent, ChangedFile file) { + if (file.isWorkspaceFile()) { + return createWorkspaceCompareEditorInput(comparedContent, file.getWorkspaceFile()); + } + return createLocalCompareEditorInput(comparedContent, file.getLocalPath()); + } + + private CompareEditorInput createCompareEditorInputForTarget(String comparedContent, String fileName, + String fileExtension, String charset, Supplier originalFileSupplier, + CompareContentSaver contentSaver) { + CompareConfiguration config = createCompareConfiguration(fileName); return new CompareEditorInput(config) { @Override protected Object prepareInput(IProgressMonitor monitor) throws InvocationTargetException, InterruptedException { monitor.beginTask("Calculating differences", 10); - setTitle(Messages.agent_tool_compareEditor_titlePrefix + file.getName()); - // Keep proposedChanges virtual file's name and type same as the originalFile original file's name and type - EditableStringCompareInput proposedChanges = new EditableStringCompareInput(comparedContent, file.getName(), - file.getFileExtension(), PlatformUtils.getFileCharset(file)); - EditableFileCompareInput originalFile = new EditableFileCompareInput(file); - - // Create a diff node with proper configuration for text comparison - DiffNode diffNode = new DiffNode(null, Differencer.CHANGE, null, originalFile, proposedChanges); - + setTitle(Messages.agent_tool_compareEditor_titlePrefix + fileName); + EditableStringCompareInput proposedChanges = new EditableStringCompareInput(comparedContent, fileName, + fileExtension, charset); + DiffNode diffNode = new DiffNode(null, Differencer.CHANGE, null, originalFileSupplier.get(), proposedChanges); monitor.done(); return diffNode; } @Override public void saveChanges(IProgressMonitor monitor) throws CoreException { - // We need to set the right side as editable to save the changes made to the proposed changes. Otherwise, the - // changes won't be saved. if (isDirty()) { config.setRightEditable(true); super.saveChanges(monitor); - // Get the diff node which contains the comparison inputs DiffNode diffNode = (DiffNode) getCompareResult(); if (diffNode != null) { - // Get the right side input (the original file with any edits made) - EditableFileCompareInput inputToBeApplied = (EditableFileCompareInput) diffNode.getLeft(); - - // Save the modified content back to the file - try (InputStream inputStream = inputToBeApplied.getContents()) { - file.setContents(inputStream, true, true, monitor); - } catch (IOException e) { - CopilotCore.LOGGER.error("Error saving compare editor changes to file", e); - } + contentSaver.save(diffNode, monitor); } - - // If user keeps the changes with keyboard shortcut, we also need to complete the file. - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().completeFile(file); - fileContentCache.remove(file); } } }; } + private CompareConfiguration createCompareConfiguration(String rightLabel) { + CompareConfiguration config = new CompareConfiguration(); + config.setLeftLabel(Messages.agent_tool_compareEditor_proposedChangesTitle.replaceAll("\"", "")); + config.setRightLabel(rightLabel); + config.setLeftEditable(true); + config.setRightEditable(false); + config.setProperty(CompareConfiguration.USE_OUTLINE_VIEW, Boolean.TRUE); + config.setProperty(CompareConfiguration.SHOW_PSEUDO_CONFLICTS, Boolean.TRUE); + config.setProperty(CompareConfiguration.IGNORE_WHITESPACE, Boolean.FALSE); + return config; + } + + /** + * Saves the editable compare content back to the target file type. + */ + @FunctionalInterface + private interface CompareContentSaver { + /** + * Saves the edited content represented by a compare diff node. + * + * @param diffNode The diff node containing the editable compare inputs. + * @param monitor The progress monitor for the save operation. + * @throws CoreException if saving through Eclipse APIs fails. + */ + void save(DiffNode diffNode, IProgressMonitor monitor) throws CoreException; + } + /** * Dispose the file change summary bar and related resources. */ @@ -342,8 +432,10 @@ protected void dispose() { /** * Editable file compare input class to handle file content editing on the compare editor. */ - public class EditableFileCompareInput implements ITypedElement, IEncodedStreamContentAccessor, IEditableContent { - private IFile file; + public static final class EditableFileCompareInput implements ITypedElement, IEncodedStreamContentAccessor, + IEditableContent { + private final IFile workspaceFile; + private final Path localFile; private byte[] modifiedContent = null; /** @@ -352,12 +444,27 @@ public class EditableFileCompareInput implements ITypedElement, IEncodedStreamCo * @param file The file to be edited. */ public EditableFileCompareInput(IFile file) { - this.file = file; + this.workspaceFile = file; + this.localFile = null; + } + + /** + * Constructor for EditableFileCompareInput. + * + * @param file The local file to be edited. + */ + EditableFileCompareInput(Path file) { + this.workspaceFile = null; + this.localFile = file.toAbsolutePath().normalize(); } @Override public String getName() { - return file.getName(); + if (workspaceFile != null) { + return workspaceFile.getName(); + } + Path fileName = localFile.getFileName(); + return fileName == null ? localFile.toString() : fileName.toString(); } @Override @@ -367,11 +474,19 @@ public Image getImage() { @Override public String getType() { - return file.getFileExtension(); + if (workspaceFile != null) { + return workspaceFile.getFileExtension(); + } + return getLocalFileExtension(localFile); } + /** + * Gets the workspace file represented by this compare input. + * + * @return the workspace file + */ public IFile getFile() { - return file; + return workspaceFile; } @Override @@ -379,12 +494,19 @@ public InputStream getContents() throws CoreException { if (modifiedContent != null) { return new ByteArrayInputStream(modifiedContent); } - return file.getContents(); + if (workspaceFile != null) { + return workspaceFile.getContents(); + } + try { + return Files.newInputStream(localFile); + } catch (IOException e) { + throw new CoreException(Status.error("Error reading local file", e)); + } } @Override public String getCharset() throws CoreException { - return file.getCharset(); + return workspaceFile == null ? StandardCharsets.UTF_8.name() : workspaceFile.getCharset(); } @Override @@ -401,7 +523,6 @@ public void setContent(byte[] newContent) { public ITypedElement replace(ITypedElement dest, ITypedElement src) { if (src instanceof IStreamContentAccessor sca) { try (InputStream is = sca.getContents()) { - // Just store changes in memory modifiedContent = is.readAllBytes(); } catch (IOException | CoreException e) { CopilotCore.LOGGER.error("Error occurred while replacing file content", e); @@ -409,6 +530,15 @@ public ITypedElement replace(ITypedElement dest, ITypedElement src) { } return this; } + + private static String getLocalFileExtension(Path file) { + String name = file.getFileName() == null ? file.toString() : file.getFileName().toString(); + int index = name.lastIndexOf('.'); + if (index < 0 || index == name.length() - 1) { + return ""; + } + return name.substring(index + 1); + } } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java index 1e57daa9..151cf791 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java @@ -6,13 +6,11 @@ import java.io.IOException; import java.util.ArrayList; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import org.eclipse.core.databinding.observable.sideeffect.ISideEffect; import org.eclipse.core.databinding.observable.value.IObservableValue; import org.eclipse.core.databinding.observable.value.WritableValue; -import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.CoreException; import org.eclipse.e4.core.services.events.IEventBroker; import org.eclipse.lsp4j.FileChangeType; @@ -35,7 +33,7 @@ * the files to be created or edited and the enable state of the button. */ public class FileToolService extends ChatBaseService { - private IObservableValue> filesObservable; + private IObservableValue> filesObservable; private IObservableValue buttonEnableObservable; private WorkingSetBar workingSetBar; @@ -78,7 +76,7 @@ public void bindWorkingSetBar(ChatView chatView) { ensureRealm(() -> { unbindWorkingSetBar(); filesSideEffect = ISideEffect.create(() -> filesObservable.getValue(), - (Map filesMap) -> { + (Map filesMap) -> { if (filesMap.isEmpty()) { disposeWorkingSetBar(); } else { @@ -154,7 +152,7 @@ public void setWorkingSetBarButtonStatus(boolean status) { /** * Set the changed files for the working set bar. */ - public void setChangedFiles(Map files) { + public void setChangedFiles(Map files) { ensureRealm(() -> { filesObservable.setValue(files); }); @@ -163,7 +161,7 @@ public void setChangedFiles(Map files) { /** * Get the changed files for the working set bar. */ - public Map getChangedFiles() { + public Map getChangedFiles() { return filesObservable.getValue(); } @@ -175,11 +173,11 @@ public WorkingSetBar getWorkingSetBar() { } /** - * Add a newly created file to the working set bar. + * Add a changed file to the working set bar. */ - public void addChangedFile(IFile file, FileChangeType fileChangeType) { + public void addChangedFile(ChangedFile file, FileChangeType fileChangeType) { ensureRealm(() -> { - Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); + Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); if (filesMap.containsKey(file)) { return; } @@ -194,9 +192,9 @@ public void addChangedFile(IFile file, FileChangeType fileChangeType) { * * @param file the file to complete */ - public void completeFile(IFile file) { + public void completeFile(ChangedFile file) { ensureRealm(() -> { - Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); + Map filesMap = new LinkedHashMap<>(filesObservable.getValue()); filesMap.remove(file); filesObservable.setValue(filesMap); @@ -212,7 +210,7 @@ public void completeFile(IFile file) { * @param file the file to get the change type for * @return the file change type, or null if the file is not in the list */ - public FileChangeType getFileChangeTypeOf(IFile file) { + private FileChangeType getFileChangeTypeInternal(ChangedFile file) { FileChangeProperty property = filesObservable.getValue().get(file); if (property != null) { return property.getChangeType(); @@ -226,10 +224,10 @@ public FileChangeType getFileChangeTypeOf(IFile file) { * * @param file the file to keep changes for */ - public void onKeepChange(IFile file) { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { + public void onKeepChange(ChangedFile file) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { this.createFileTool.onKeepChange(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { this.editFileTool.onKeepChange(file); } this.completeFile(file); @@ -239,8 +237,13 @@ public void onKeepChange(IFile file) { * Handles the action of keeping all changes to files. */ public void onKeepAllChanges() { - this.createFileTool.onKeepAllChanges(getCreatedFiles()); - this.editFileTool.onKeepAllChanges(getEditedFiles()); + for (ChangedFile file : new ArrayList<>(filesObservable.getValue().keySet())) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { + this.createFileTool.onKeepChange(file); + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { + this.editFileTool.onKeepChange(file); + } + } onResolveAllChanges(); } @@ -249,11 +252,11 @@ public void onKeepAllChanges() { * * @param file the file to undo changes for */ - public void onUndoChange(IFile file) { + public void onUndoChange(ChangedFile file) { try { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { this.createFileTool.onUndoChange(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { this.editFileTool.onUndoChange(file); } } catch (CoreException | IOException e) { @@ -267,8 +270,13 @@ public void onUndoChange(IFile file) { */ public void onUndoAllChanges() { try { - this.createFileTool.onUndoAllChanges(getCreatedFiles()); - this.editFileTool.onUndoAllChanges(getEditedFiles()); + for (ChangedFile file : new ArrayList<>(filesObservable.getValue().keySet())) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { + this.createFileTool.onUndoChange(file); + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { + this.editFileTool.onUndoChange(file); + } + } } catch (CoreException | IOException e) { CopilotCore.LOGGER.error("Error undoing all changes for the files", e); } @@ -280,10 +288,14 @@ public void onUndoAllChanges() { * * @param file the file to view the diff for */ - public void onViewDiff(IFile file) { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { + public void onViewDiff(ChangedFile file) { + FileChangeProperty property = filesObservable.getValue().get(file); + if (property == null) { + return; + } + if (property.getChangeType() == FileChangeType.Created) { this.createFileTool.onViewDiff(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { + } else if (property.getChangeType() == FileChangeType.Changed) { this.editFileTool.onViewDiff(file); } } @@ -313,29 +325,8 @@ public void disposeWorkingSetBar() { } } - private List getCreatedFiles() { - List createdFiles = new ArrayList<>(); - for (Map.Entry entry : this.filesObservable.getValue().entrySet()) { - if (entry.getValue().getChangeType() == FileChangeType.Created) { - createdFiles.add(entry.getKey()); - } - } - return createdFiles; - } - - private List getEditedFiles() { - List editedFiles = new ArrayList<>(); - for (Map.Entry entry : this.filesObservable.getValue().entrySet()) { - if (entry.getValue().getChangeType() == FileChangeType.Changed) { - editedFiles.add(entry.getKey()); - } - } - return editedFiles; - } - /** - * Class for file change properties. changeType - The type of file change (new or edited). isCompleted - Whether the - * file change is completed or not. + * Class for file change properties. */ public static class FileChangeProperty { private FileChangeType changeType; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleCodeReviewTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleCodeReviewTool.java new file mode 100644 index 00000000..e160f4d2 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleCodeReviewTool.java @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Senior-level MuleSoft code review tool. + */ +public class MuleCodeReviewTool extends BaseTool { + static final String TOOL_NAME = "mule_code_review"; + + /** + * Creates a Mule code review tool. + */ + public MuleCodeReviewTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Review Mule XML, DataWeave, specs, and tests"); + toolInfo.setDescription(""" + Perform a MuleSoft code review across Mule XML, DataWeave, properties, API specs, MUnit, and POM metadata. + Checks: flow naming conventions (camelCase verb-noun), duplicate or unused global configs, missing + On Error Propagate on HTTP-facing flows, correlation ID propagation in error handlers, property placeholder + externalization (secure:: for secrets, plain placeholder for env values), APIkit route coverage vs API spec, + DataWeave output type declarations and null-safety, and MUnit test coverage gaps. + Common findings: flows with no error handler, hardcoded URLs in global configs, on-error-continue misused + as a catch-all, flows with zero MUnit coverage, DataWeave scripts missing output directive. + This tool is read-only and returns findings by severity (critical, high, medium, low) with remediation guidance. + """); + toolInfo.setInputSchema(MuleToolInputs.codeReviewSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path projectPath = MuleToolInputs.existingDirectory(input.get(MuleToolInputs.PROJECT_PATH)); + if (projectPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("projectPath must be an absolute path to an existing Mule project folder."); + } else { + result.setStatus(ToolInvocationStatus.success); + result.addContent(MuleProjectAnalyzer.codeReviewResponse(projectPath, + MuleToolInputs.optionalStringList(input.get(MuleToolInputs.FILES)), + MuleToolInputs.optionalString(input.get(MuleToolInputs.REVIEW_TYPE))).toJson()); + } + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to review Mule project: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDiagnostic.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDiagnostic.java new file mode 100644 index 00000000..4a0479e7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDiagnostic.java @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +/** + * Finding emitted by MuleSoft analysis tools. + */ +final class MuleDiagnostic { + private final String severity; + private final String file; + private final int line; + private final String message; + private final String recommendation; + + MuleDiagnostic(String severity, String file, int line, String message, String recommendation) { + this.severity = severity; + this.file = file; + this.line = line; + this.message = message; + this.recommendation = recommendation; + } + + static MuleDiagnostic info(String file, String message, String recommendation) { + return new MuleDiagnostic("info", file, 0, message, recommendation); + } + + static MuleDiagnostic low(String file, int line, String message, String recommendation) { + return new MuleDiagnostic("low", file, line, message, recommendation); + } + + static MuleDiagnostic medium(String file, int line, String message, String recommendation) { + return new MuleDiagnostic("medium", file, line, message, recommendation); + } + + static MuleDiagnostic high(String file, int line, String message, String recommendation) { + return new MuleDiagnostic("high", file, line, message, recommendation); + } + + static MuleDiagnostic critical(String file, int line, String message, String recommendation) { + return new MuleDiagnostic("critical", file, line, message, recommendation); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlOptimizeTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlOptimizeTool.java new file mode 100644 index 00000000..68ee59a8 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlOptimizeTool.java @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConfirmationMessages; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Analyzes a standalone DataWeave (.dwl) file for performance anti-patterns, null-safety gaps, + * and documentation issues. By default operates as read-only (preview). When applyFixes=true, + * writes the optimized script back to the file (requires user confirmation). + */ +public class MuleDwlOptimizeTool extends BaseTool { + static final String TOOL_NAME = "mule_optimize_dwl"; + + /** + * Creates a DataWeave optimize tool. + */ + public MuleDwlOptimizeTool() { + this.name = TOOL_NAME; + } + + @Override + public boolean needConfirmation() { + return true; + } + + @Override + public ConfirmationMessages getConfirmationMessages() { + ConfirmationMessages messages = new ConfirmationMessages(); + messages.setTitle("Apply DataWeave Optimizations"); + messages.setMessage( + "This will analyze and optionally rewrite the DataWeave .dwl file with performance " + + "improvements and documentation comments. Continue?"); + return messages; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Analyze and optimize a DataWeave module file"); + toolInfo.setDescription(""" + Analyze a standalone DataWeave 2.0 module (.dwl) file for common issues: + - Missing %dw 2.0 header or output directive + - Nested map+filter patterns (O(nΓ—m)) β€” suggest groupBy pre-indexing + - Inline regex literals inside map/filter β€” suggest extracting to var + - Round-trip write()/read() serialization β€” flag as no-op + - Field accesses without null guards (missing 'default' operator) + - Undocumented function declarations β€” suggest comment stubs + Returns a structured findings report and the suggested optimized script. + When applyFixes=true, writes the improved script back to the file (requires confirmation). + When includeComments=true (default), adds documentation comments to undocumented functions. + Always run mulesoft/dataweave_run_script_tool after applying to validate the result. + """); + toolInfo.setInputSchema(MuleToolInputs.dwlOptimizeSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path dwlPath = MuleToolInputs.existingFile(input.get(MuleToolInputs.DWL_FILE_PATH)); + if (dwlPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("dwlFilePath must be an absolute path to an existing .dwl file."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + if (!dwlPath.getFileName().toString().endsWith(".dwl")) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("The file must be a DataWeave module (.dwl). Got: " + dwlPath.getFileName()); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + boolean includeComments = !Boolean.FALSE.equals(input.get(MuleToolInputs.INCLUDE_COMMENTS)); + boolean applyFixes = Boolean.TRUE.equals(input.get(MuleToolInputs.APPLY_FIXES)); + + String script = Files.readString(dwlPath, StandardCharsets.UTF_8); + List issues = DwlAnalyzer.analyze(script); + String optimizedScript = includeComments ? DwlAnalyzer.addComments(script) : script; + + if (applyFixes) { + Files.writeString(dwlPath, optimizedScript, StandardCharsets.UTF_8); + refreshWorkspaceFile(dwlPath); + } + + result.setStatus(ToolInvocationStatus.success); + result.addContent(formatReport(dwlPath, issues, optimizedScript, applyFixes)); + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to optimize DataWeave file: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + private static String formatReport(Path dwlPath, List issues, + String optimizedScript, boolean applied) { + StringBuilder sb = new StringBuilder(); + sb.append("file=").append(dwlPath.toAbsolutePath()).append(System.lineSeparator()); + sb.append("issues=").append(issues.size()).append(System.lineSeparator()); + sb.append("optimized=").append(applied ? "yes (written to file)" : "no (preview only)") + .append(System.lineSeparator()); + + if (issues.isEmpty()) { + sb.append(System.lineSeparator()).append("No issues found. Script looks good."); + } else { + int num = 1; + for (DwlAnalyzer.Issue issue : issues) { + sb.append(System.lineSeparator()); + sb.append("[Issue ").append(num++).append("]").append(System.lineSeparator()); + sb.append("type: ").append(issue.type()).append(System.lineSeparator()); + sb.append("line: ").append(issue.line()).append(System.lineSeparator()); + sb.append("description: ").append(issue.description()).append(System.lineSeparator()); + if (!issue.suggestion().isBlank()) { + sb.append("suggestion:").append(System.lineSeparator()); + for (String line : issue.suggestion().split("\\r?\\n", -1)) { + sb.append(" ").append(line).append(System.lineSeparator()); + } + } + } + } + + sb.append(System.lineSeparator()); + sb.append("[Suggested script").append(applied ? " (applied)" : " (preview)").append(":]") + .append(System.lineSeparator()); + sb.append(optimizedScript); + return sb.toString(); + } + + private static void refreshWorkspaceFile(Path dwlPath) { + try { + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + IFile file = root.getFileForLocation( + new org.eclipse.core.runtime.Path(dwlPath.toAbsolutePath().toString())); + if (file != null && file.exists()) { + file.refreshLocal(IFile.DEPTH_ZERO, null); + } + } catch (Exception e) { + // Non-fatal: the file was written successfully; workspace refresh will happen on next build + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlReadTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlReadTool.java new file mode 100644 index 00000000..a3e8abfa --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlReadTool.java @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Reads the content of a standalone DataWeave module (.dwl) file. Use this before editing or + * reviewing a DataWeave module to understand the current script and its output type declaration. + * This tool is read-only. + */ +public class MuleDwlReadTool extends BaseTool { + static final String TOOL_NAME = "mule_read_dwl_file"; + + /** + * Creates a DataWeave file read tool. + */ + public MuleDwlReadTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Read a standalone DataWeave module file"); + toolInfo.setDescription(""" + Read the content of a standalone DataWeave 2.0 module (.dwl) file. + Returns the file path, line count, and full script content. + Use this before editing, reviewing, or optimizing a DataWeave module to understand + the current script, its output type declaration, function definitions, and imports. + This tool is read-only. + """); + toolInfo.setInputSchema(MuleToolInputs.dwlReadSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path dwlPath = MuleToolInputs.existingFile(input.get(MuleToolInputs.DWL_FILE_PATH)); + if (dwlPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("dwlFilePath must be an absolute path to an existing .dwl file."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + if (!dwlPath.getFileName().toString().endsWith(".dwl")) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("The file must be a DataWeave module (.dwl). Got: " + dwlPath.getFileName()); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + String content = Files.readString(dwlPath, StandardCharsets.UTF_8); + long lineCount = content.lines().count(); + + StringBuilder sb = new StringBuilder(); + sb.append("file=").append(dwlPath.toAbsolutePath()).append(System.lineSeparator()); + sb.append("lines=").append(lineCount).append(System.lineSeparator()); + sb.append("script:").append(System.lineSeparator()); + sb.append(content); + + result.setStatus(ToolInvocationStatus.success); + result.addContent(sb.toString()); + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to read DataWeave file: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlWriteTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlWriteTool.java new file mode 100644 index 00000000..4293d64f --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleDwlWriteTool.java @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConfirmationMessages; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Writes a complete DataWeave 2.0 script to a standalone .dwl module file. Replaces the entire + * file content. Requires user confirmation before writing. + */ +public class MuleDwlWriteTool extends BaseTool { + static final String TOOL_NAME = "mule_write_dwl_file"; + + /** + * Creates a DataWeave file write tool. + */ + public MuleDwlWriteTool() { + this.name = TOOL_NAME; + } + + @Override + public boolean needConfirmation() { + return true; + } + + @Override + public ConfirmationMessages getConfirmationMessages() { + ConfirmationMessages messages = new ConfirmationMessages(); + messages.setTitle("Update DataWeave Module File"); + messages.setMessage( + "This will replace the entire content of the DataWeave .dwl file. Continue?"); + return messages; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Write a DataWeave script to a standalone .dwl module file"); + toolInfo.setDescription(""" + Replace the content of a standalone DataWeave 2.0 module (.dwl) file. + The dwlScript must be a complete script (not a fragment) and should start with '%dw 2.0' + followed by an output directive. + Always run mule_read_dwl_file first to confirm the current state before writing. + Always run mulesoft/dataweave_run_script_tool after writing to validate the updated script. + Requires user confirmation before modifying the file. + """); + toolInfo.setInputSchema(MuleToolInputs.dwlWriteSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path dwlPath = MuleToolInputs.existingFile(input.get(MuleToolInputs.DWL_FILE_PATH)); + if (dwlPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("dwlFilePath must be an absolute path to an existing .dwl file."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + if (!dwlPath.getFileName().toString().endsWith(".dwl")) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("The file must be a DataWeave module (.dwl). Got: " + dwlPath.getFileName()); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + String dwlScript = MuleToolInputs.optionalString(input.get(MuleToolInputs.DWL_SCRIPT)); + if (dwlScript.isBlank()) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("dwlScript is required and must not be blank."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + Files.writeString(dwlPath, dwlScript, StandardCharsets.UTF_8); + refreshWorkspaceFile(dwlPath); + + result.setStatus(ToolInvocationStatus.success); + result.addContent("Updated DataWeave module: " + dwlPath.toAbsolutePath()); + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to write DataWeave file: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + private static void refreshWorkspaceFile(Path dwlPath) { + try { + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + IFile file = root.getFileForLocation( + new org.eclipse.core.runtime.Path(dwlPath.toAbsolutePath().toString())); + if (file != null && file.exists()) { + file.refreshLocal(IFile.DEPTH_ZERO, null); + } + } catch (Exception e) { + // Non-fatal: the file was written successfully; workspace refresh will happen on next build + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalysis.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalysis.java new file mode 100644 index 00000000..6037b47c --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalysis.java @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * In-memory summary of a Mule project. + */ +final class MuleProjectAnalysis { + final Path projectPath; + final List muleXmlFiles = new ArrayList<>(); + final List resourceFiles = new ArrayList<>(); + final List apiSpecFiles = new ArrayList<>(); + final List munitFiles = new ArrayList<>(); + final Set flows = new LinkedHashSet<>(); + final Set subFlows = new LinkedHashSet<>(); + final Set globalConfigs = new LinkedHashSet<>(); + final Set namespaces = new LinkedHashSet<>(); + final Set placeholders = new LinkedHashSet<>(); + final Set connectorDependencies = new LinkedHashSet<>(); + final Set deploymentPlugins = new LinkedHashSet<>(); + final Map processorCounts = new LinkedHashMap<>(); + final List diagnostics = new ArrayList<>(); + final Map flowErrorHandlerTypes = new LinkedHashMap<>(); + final Set flowsWithCorrelationId = new LinkedHashSet<>(); + final Set schedulerFlows = new LinkedHashSet<>(); + final List untilSuccessfulWithoutMaxRetries = new ArrayList<>(); + String muleRuntimeVersion = ""; + String log4j2RootLevel = ""; + boolean hasPom; + boolean hasMuleArtifact; + boolean hasApikit; + boolean hasSecureProperties; + boolean hasDbPoolConfig; + boolean hasHttpRequestTimeout; + boolean hasReconnectForever; + boolean hasBatchJob; + + MuleProjectAnalysis(Path projectPath) { + this.projectPath = projectPath; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalyzer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalyzer.java new file mode 100644 index 00000000..ca5ea164 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectAnalyzer.java @@ -0,0 +1,1412 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +/** + * Parser-backed Mule project analyzer used by MuleSoft Copilot chat tools. + */ +final class MuleProjectAnalyzer { + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{([^}]+)\\}"); + private static final Pattern SECRET_LINE_PATTERN = + Pattern.compile("(?i)^\\s*[^#\\s][^=]*(password|secret|token|apikey|api-key|client_secret|clientSecret)" + + "[^=]*=(.*)$"); + private static final Pattern RAML_RESPONSE_PATTERN = Pattern.compile("^\\s{2,}[1-5][0-9][0-9]:\\s*$"); + private static final int MAX_FILES = 200; + private static final Set API_SPEC_EXTENSIONS = Set.of(".raml", ".yaml", ".yml", ".json", ".wsdl", ".xsd", + ".graphql", ".avsc", ".csv"); + + private MuleProjectAnalyzer() { + } + + static MuleProjectAnalysis scan(Path projectPath) throws Exception { + MuleProjectAnalysis analysis = new MuleProjectAnalysis(projectPath); + analysis.hasPom = Files.isRegularFile(projectPath.resolve("pom.xml")); + analysis.hasMuleArtifact = Files.isRegularFile(projectPath.resolve("mule-artifact.json")); + + if (analysis.hasPom) { + parsePom(projectPath.resolve("pom.xml"), analysis); + } else { + analysis.diagnostics.add(MuleDiagnostic.medium("pom.xml", 0, "Mule project is missing pom.xml.", + "Add the Mule Maven project descriptor or select the Mule project root.")); + } + + if (analysis.hasMuleArtifact) { + parseMuleArtifact(projectPath.resolve("mule-artifact.json"), analysis); + } else { + analysis.diagnostics.add(MuleDiagnostic.medium("mule-artifact.json", 0, + "Mule project is missing mule-artifact.json.", "Add deployment metadata with the target Mule runtime.")); + } + + listFiles(projectPath.resolve("src/main/mule"), ".xml") + .forEach(file -> parseMuleXml(projectPath, file, analysis)); + Path log4j2Path = projectPath.resolve("src/main/resources/log4j2.xml"); + if (Files.isRegularFile(log4j2Path)) { + parseLog4j2(log4j2Path, analysis); + } + listFiles(projectPath.resolve("src/main/resources"), null).forEach(file -> { + String relative = relativize(projectPath, file); + analysis.resourceFiles.add(relative); + if (isApiSpec(file)) { + analysis.apiSpecFiles.add(relative); + } + }); + listFiles(projectPath.resolve("src/test/munit"), ".xml") + .forEach(file -> analysis.munitFiles.add(relativize(projectPath, file))); + + addProjectLevelDiagnostics(analysis); + return analysis; + } + + static MuleToolResponse projectScanResponse(Path projectPath) throws Exception { + MuleProjectAnalysis analysis = scan(projectPath); + MuleToolResponse response = new MuleToolResponse("success", "Scanned Mule project " + projectPath); + response.addArtifact("runtimeVersion=" + blankToUnknown(analysis.muleRuntimeVersion)); + response.addArtifact("muleXmlFiles=" + analysis.muleXmlFiles.size()); + response.addArtifact("apiSpecFiles=" + analysis.apiSpecFiles.size()); + response.addArtifact("munitFiles=" + analysis.munitFiles.size()); + response.addArtifact("connectors=" + String.join(", ", analysis.connectorDependencies)); + response.addArtifact("deploymentPlugins=" + String.join(", ", analysis.deploymentPlugins)); + response.addArtifact("flows=" + String.join(", ", analysis.flows)); + response.addArtifact("subFlows=" + String.join(", ", analysis.subFlows)); + response.addArtifact("globalConfigs=" + String.join(", ", analysis.globalConfigs)); + response.addArtifact("propertyPlaceholders=" + String.join(", ", analysis.placeholders)); + response.addArtifact("hasApikit=" + analysis.hasApikit); + response.addArtifact("hasSecureProperties=" + analysis.hasSecureProperties); + response.addArtifact("hasBatchJob=" + analysis.hasBatchJob); + response.addArtifact("schedulerFlows=" + String.join(", ", analysis.schedulerFlows)); + response.addArtifact("hasReconnectForever=" + analysis.hasReconnectForever); + response.addArtifact("log4j2RootLevel=" + blankToUnknown(analysis.log4j2RootLevel)); + response.addArtifact("hasDbPoolConfig=" + analysis.hasDbPoolConfig); + response.addArtifact("hasHttpRequestTimeout=" + analysis.hasHttpRequestTimeout); + response.addArtifact("flowsWithCorrelationId=" + String.join(", ", analysis.flowsWithCorrelationId)); + String errorHandlerSummary = analysis.flowErrorHandlerTypes.entrySet().stream() + .map(e -> e.getKey() + ":" + e.getValue()).collect(Collectors.joining(", ")); + response.addArtifact("flowErrorHandlerTypes=" + (errorHandlerSummary.isBlank() ? "none" : errorHandlerSummary)); + if (!analysis.untilSuccessfulWithoutMaxRetries.isEmpty()) { + response.addArtifact("untilSuccessfulWithoutMaxRetries=" + String.join(", ", + analysis.untilSuccessfulWithoutMaxRetries)); + } + response.addDiagnostics(analysis.diagnostics); + response.addNextAction("Run mule_code_review for maintainability findings."); + response.addNextAction("Run mule_security_review before committing Mule configuration or property changes."); + return response; + } + + static MuleToolResponse codeReviewResponse(Path projectPath, List files, String reviewType) throws Exception { + MuleProjectAnalysis analysis = scan(projectPath); + List diagnostics = new ArrayList<>(analysis.diagnostics); + if (analysis.munitFiles.isEmpty()) { + diagnostics.add(MuleDiagnostic.medium("src/test/munit", 0, "No MUnit suites were found.", + "Add positive, negative, and edge-case MUnit coverage for changed flows.")); + } + if (analysis.apiSpecFiles.isEmpty() && analysis.hasApikit) { + diagnostics.add(MuleDiagnostic.high("src/main/resources", 0, + "APIkit usage was detected but no API specification file was found in resources.", + "Add or reference the RAML/OpenAPI contract used by APIkit routing.")); + } + addFlowStructureFindings(projectPath, analysis, diagnostics); + addDataWeaveFindings(projectPath, files, diagnostics); + + MuleToolResponse response = new MuleToolResponse(diagnostics.isEmpty() ? "success" : "partial", + "Completed Mule " + emptyToDefault(reviewType, "code") + " review for " + projectPath); + response.addArtifact("reviewedFiles=" + (files == null || files.isEmpty() ? "project" : String.join(", ", files))); + response.addArtifact("flows=" + analysis.flows.size()); + response.addArtifact("munitSuites=" + analysis.munitFiles.size()); + response.addDiagnostics(diagnostics); + response.addNextAction("Apply minimal fixes for high and critical findings first."); + response.addNextAction("Run run_mule_maven_tests with goals [\"test\"] after changes."); + return response; + } + + static MuleToolResponse securityReviewResponse(Path projectPath, String scope, String apiExposure) throws Exception { + MuleProjectAnalysis analysis = scan(projectPath); + List diagnostics = new ArrayList<>(analysis.diagnostics); + addSecretFindings(projectPath, diagnostics); + addXmlSecurityFindings(projectPath, analysis, diagnostics); + if ("public".equalsIgnoreCase(apiExposure) && analysis.apiSpecFiles.isEmpty()) { + diagnostics.add(MuleDiagnostic.high("src/main/resources", 0, + "Public API exposure was selected but no API contract was found.", + "Require a RAML/OpenAPI contract with auth, examples, validation, and error responses.")); + } + if (!analysis.hasSecureProperties) { + diagnostics.add(MuleDiagnostic.medium("src/main/mule", 0, + "No secure-properties configuration was detected.", + "Use Mule secure properties or external secret references for sensitive configuration.")); + } + + MuleToolResponse response = new MuleToolResponse(diagnostics.isEmpty() ? "success" : "partial", + "Completed Mule security review for " + projectPath); + response.addArtifact("scope=" + emptyToDefault(scope, "full")); + response.addArtifact("apiExposure=" + emptyToDefault(apiExposure, "internal")); + response.addDiagnostics(diagnostics); + response.addNextAction("Move hardcoded secrets into secure properties before merge."); + response.addNextAction("Confirm API Manager or implementation-level authentication and authorization policies."); + return response; + } + + static MuleToolResponse schemaAnalyzeResponse(Path schemaPath, String schemaType, Path rulesetPath) throws Exception { + String content = Files.readString(schemaPath, StandardCharsets.UTF_8); + String inferredType = inferSchemaType(schemaPath, schemaType, content); + List diagnostics = new ArrayList<>(); + List artifacts = new ArrayList<>(); + artifacts.add("schemaType=" + inferredType); + artifacts.add("schemaPath=" + schemaPath); + + switch (inferredType) { + case "raml" -> analyzeRaml(schemaPath, content, diagnostics, artifacts); + case "openapi" -> analyzeOpenApi(schemaPath, content, diagnostics, artifacts); + case "wsdl", "xsd" -> analyzeXmlContract(schemaPath, content, diagnostics, artifacts); + case "jsonschema", "avro" -> analyzeJsonContract(schemaPath, content, diagnostics, artifacts); + default -> diagnostics.add(MuleDiagnostic.info(schemaPath.toString(), + "Schema type was inferred as " + inferredType + " with lightweight validation only.", + "Provide a schemaType value or ruleset for deeper validation.")); + } + if (rulesetPath != null && !Files.isRegularFile(rulesetPath)) { + diagnostics.add(MuleDiagnostic.medium(rulesetPath.toString(), 0, "Ruleset path does not exist.", + "Provide an existing governance ruleset file or omit rulesetPath.")); + } + + MuleToolResponse response = new MuleToolResponse(diagnostics.isEmpty() ? "success" : "partial", + "Analyzed API schema " + schemaPath.getFileName()); + artifacts.forEach(response::addArtifact); + response.addDiagnostics(diagnostics); + response.addNextAction("Address missing examples, error responses, and security definitions before " + + "implementation."); + return response; + } + + static MuleToolResponse munitValidationResponse(Path projectPath, String flowName, Path munitPath) throws Exception { + MuleProjectAnalysis analysis = scan(projectPath); + List diagnostics = new ArrayList<>(analysis.diagnostics); + List munitFiles = resolveMunitFiles(projectPath, analysis, munitPath, diagnostics); + Map> flowComponents = readFlowComponents(projectPath, diagnostics); + List suites = new ArrayList<>(); + + for (Path file : munitFiles) { + suites.add(analyzeMunitSuite(projectPath, file, diagnostics)); + } + + validateMunitPurposeAndCoverage(projectPath, emptyToDefault(flowName, ""), flowComponents, suites, diagnostics); + + MuleToolResponse response = new MuleToolResponse(diagnostics.isEmpty() ? "success" : "partial", + "Validated MUnit structure and flow coverage for " + projectPath); + response.addArtifact("requiredNamespaces=munit, munit-tools"); + response.addArtifact("requiredSchemaLocations=mule-munit.xsd, mule-munit-tools.xsd"); + response.addArtifact("requiredSuiteElement=munit:config"); + response.addArtifact("requiredTestStructure=munit:test with execution and validation"); + response.addArtifact("recommendedValidation=munit-tools:assert-that, verify-call, spy, and mock-when"); + response.addArtifact("targetFlow=" + emptyToDefault(flowName, "all flows")); + response.addArtifact("munitSuites=" + suites.stream().map(suite -> suite.relativePath).toList()); + response.addArtifact("flows=" + flowComponents.keySet()); + response.addDiagnostics(diagnostics); + response.addNextAction("Add MUnit assertions for payload, variables, attributes, status, and error outcomes."); + response.addNextAction("Mock external connectors and verify critical connector or flow-ref calls."); + response.addNextAction("Run run_mule_maven_tests with goals [\"test\"] after updating MUnit suites."); + return response; + } + + static MuleToolResponse munitFullReviewResponse(Path projectPath, String flowName, Path munitPath) throws Exception { + MuleProjectAnalysis analysis = scan(projectPath); + List diagnostics = new ArrayList<>(analysis.diagnostics); + List munitFiles = resolveMunitFiles(projectPath, analysis, munitPath, diagnostics); + Map> flowComponents = readFlowComponents(projectPath, diagnostics); + List suites = new ArrayList<>(); + + for (Path file : munitFiles) { + suites.add(analyzeMunitSuite(projectPath, file, diagnostics)); + } + + String targetFlow = emptyToDefault(flowName, ""); + validateMunitPurposeAndCoverage(projectPath, targetFlow, flowComponents, suites, diagnostics); + addMunitReviewDiagnostics(projectPath, targetFlow, flowComponents, suites, diagnostics); + + MuleToolResponse response = new MuleToolResponse(diagnostics.isEmpty() ? "success" : "partial", + "Completed full MUnit review for " + projectPath); + addMunitReviewArtifacts(response, flowComponents, suites, targetFlow); + response.addDiagnostics(diagnostics); + response.addNextAction("Prioritize high findings that make tests non-executable or logically meaningless."); + response.addNextAction("Add scenario-level assertions before expanding low-value processor-only checks."); + response.addNextAction("Run run_mule_maven_tests with goals [\"test\"] and review failing test intent."); + return response; + } + + static MuleToolResponse munitImprovementSuggestionsResponse(Path projectPath, String flowName, Path munitPath) + throws Exception { + MuleProjectAnalysis analysis = scan(projectPath); + List diagnostics = new ArrayList<>(analysis.diagnostics); + List munitFiles = resolveMunitFiles(projectPath, analysis, munitPath, diagnostics); + Map> flowComponents = readFlowComponents(projectPath, diagnostics); + List suites = new ArrayList<>(); + + for (Path file : munitFiles) { + suites.add(analyzeMunitSuite(projectPath, file, diagnostics)); + } + + String targetFlow = emptyToDefault(flowName, ""); + addMunitReviewDiagnostics(projectPath, targetFlow, flowComponents, suites, diagnostics); + addMunitCadenceDiagnostics(targetFlow, flowComponents, suites, diagnostics); + + MuleToolResponse response = new MuleToolResponse(diagnostics.isEmpty() ? "success" : "partial", + "Suggested MUnit improvements for " + projectPath); + addMunitReviewArtifacts(response, flowComponents, suites, targetFlow); + response.addArtifact("recommendedCadence=positive, negative, edge, connector-failure, and error-contract tests"); + response.addArtifact("recommendedAssertions=payload, attributes, variables, status, outbound calls, and errors"); + response.addDiagnostics(diagnostics); + response.addNextAction("Create one happy-path test for every public flow before adding edge cases."); + response.addNextAction("For each branch, add one test per route and assert the business outcome."); + response.addNextAction("For each external connector, mock success and failure and verify the call shape."); + response.addNextAction("Keep generated tests small: arrange mocks, execute one flow, then assert outcomes."); + return response; + } + + static String renderSummary(MuleProjectAnalysis analysis) { + StringBuilder builder = new StringBuilder(); + builder.append("Mule project: ").append(analysis.projectPath).append(System.lineSeparator()); + builder.append("Runtime version: ").append(blankToUnknown(analysis.muleRuntimeVersion)) + .append(System.lineSeparator()); + appendList(builder, "Mule XML files", analysis.muleXmlFiles); + appendList(builder, "API specs", analysis.apiSpecFiles); + appendList(builder, "MUnit suites", analysis.munitFiles); + appendList(builder, "Flows", analysis.flows); + appendList(builder, "Sub-flows", analysis.subFlows); + appendList(builder, "Global configs", analysis.globalConfigs); + appendList(builder, "Connector dependencies", analysis.connectorDependencies); + appendList(builder, "Deployment plugins", analysis.deploymentPlugins); + appendList(builder, "Property placeholders", analysis.placeholders); + builder.append("Top processors/connectors:").append(System.lineSeparator()); + analysis.processorCounts.entrySet().stream().sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())).limit(25) + .forEach(entry -> builder.append("- ").append(entry.getKey()).append(": ").append(entry.getValue()) + .append(System.lineSeparator())); + builder.append("hasApikit: ").append(analysis.hasApikit).append(System.lineSeparator()); + builder.append("hasSecureProperties: ").append(analysis.hasSecureProperties).append(System.lineSeparator()); + builder.append("hasBatchJob: ").append(analysis.hasBatchJob).append(System.lineSeparator()); + builder.append("hasReconnectForever: ").append(analysis.hasReconnectForever).append(System.lineSeparator()); + builder.append("log4j2RootLevel: ").append(blankToUnknown(analysis.log4j2RootLevel)).append(System.lineSeparator()); + builder.append("hasDbPoolConfig: ").append(analysis.hasDbPoolConfig).append(System.lineSeparator()); + builder.append("hasHttpRequestTimeout: ").append(analysis.hasHttpRequestTimeout).append(System.lineSeparator()); + if (!analysis.schedulerFlows.isEmpty()) { + appendList(builder, "Scheduler-triggered flows", analysis.schedulerFlows); + } + if (!analysis.flowsWithCorrelationId.isEmpty()) { + appendList(builder, "Flows with correlationId set", analysis.flowsWithCorrelationId); + } + builder.append("Diagnostics: ").append(analysis.diagnostics.size()) + .append(" finding(s) β€” run mule_project_scan for full details.").append(System.lineSeparator()); + return builder.toString(); + } + + private static void parsePom(Path pomPath, MuleProjectAnalysis analysis) throws Exception { + Document document = parseXml(pomPath); + NodeList artifactIds = document.getElementsByTagNameNS("*", "artifactId"); + for (int i = 0; i < artifactIds.getLength(); i++) { + String artifactId = artifactIds.item(i).getTextContent().trim(); + if (artifactId.contains("mule") || artifactId.contains("connector")) { + analysis.connectorDependencies.add(artifactId); + } + } + NodeList plugins = document.getElementsByTagNameNS("*", "plugin"); + for (int i = 0; i < plugins.getLength(); i++) { + Element plugin = (Element) plugins.item(i); + String artifactId = firstChildText(plugin, "artifactId"); + if (artifactId.contains("mule") || artifactId.contains("cloudhub") || artifactId.contains("rtf")) { + analysis.deploymentPlugins.add(artifactId); + } + } + String text = Files.readString(pomPath, StandardCharsets.UTF_8); + Matcher runtimeMatcher = + Pattern.compile("([^<]+)").matcher(text); + if (runtimeMatcher.find()) { + analysis.muleRuntimeVersion = runtimeMatcher.group(1).trim(); + } + } + + private static void parseMuleArtifact(Path artifactPath, MuleProjectAnalysis analysis) throws Exception { + String text = Files.readString(artifactPath, StandardCharsets.UTF_8); + Matcher matcher = Pattern.compile("\"minMuleVersion\"\\s*:\\s*\"([^\"]+)\"").matcher(text); + if (matcher.find()) { + analysis.muleRuntimeVersion = matcher.group(1).trim(); + } + } + + private static void parseMuleXml(Path projectPath, Path xmlFile, MuleProjectAnalysis analysis) { + String relative = relativize(projectPath, xmlFile); + analysis.muleXmlFiles.add(relative); + try { + Document document = parseXml(xmlFile); + Element root = document.getDocumentElement(); + collectNamespaces(root, analysis); + collectPlaceholders(root.getTextContent(), analysis.placeholders); + collectElements(root, analysis); + analyzeFlowDetails(document, analysis); + } catch (Exception e) { + analysis.diagnostics.add(MuleDiagnostic.high(relative, 0, "Failed to parse Mule XML: " + e.getMessage(), + "Fix XML syntax before asking Copilot to edit or review this file.")); + } + } + + private static void collectNamespaces(Element root, MuleProjectAnalysis analysis) { + for (int i = 0; i < root.getAttributes().getLength(); i++) { + Node attribute = root.getAttributes().item(i); + String name = attribute.getNodeName(); + String value = attribute.getNodeValue(); + if ("xmlns".equals(name) || name.startsWith("xmlns:")) { + analysis.namespaces.add(name + "=" + value); + if (value.contains("/mule/apikit")) { + analysis.hasApikit = true; + } + if (value.contains("/mule/secure-properties")) { + analysis.hasSecureProperties = true; + } + } else { + collectPlaceholders(value, analysis.placeholders); + } + } + } + + private static void collectElements(Element root, MuleProjectAnalysis analysis) { + NodeList nodes = root.getElementsByTagName("*"); + for (int i = 0; i < nodes.getLength(); i++) { + if (!(nodes.item(i) instanceof Element element)) { + continue; + } + String localName = localName(element); + String qualifiedName = qualifiedName(element); + analysis.processorCounts.merge(qualifiedName, 1, Integer::sum); + if ("flow".equals(localName)) { + addNamedElement(element, analysis.flows); + } else if ("sub-flow".equals(localName)) { + addNamedElement(element, analysis.subFlows); + } else if (element.getParentNode() == root) { + analysis.globalConfigs.add(qualifiedName + optionalName(element)); + } + if ("reconnect-forever".equals(localName)) { + analysis.hasReconnectForever = true; + } + if ("job".equals(localName) && qualifiedName.startsWith("batch:")) { + analysis.hasBatchJob = true; + } + if ("until-successful".equals(localName) && !element.hasAttribute("maxRetries")) { + String docName = element.getAttribute("doc:name"); + analysis.untilSuccessfulWithoutMaxRetries.add(docName.isBlank() ? "unnamed" : docName); + } + if (element.hasAttribute("minPoolSize")) { + analysis.hasDbPoolConfig = true; + } + if (element.hasAttribute("responseTimeout")) { + analysis.hasHttpRequestTimeout = true; + } + collectPlaceholders(element.getTextContent(), analysis.placeholders); + collectAttributePlaceholders(element, analysis.placeholders); + } + } + + private static void collectAttributePlaceholders(Element element, Set placeholders) { + for (int i = 0; i < element.getAttributes().getLength(); i++) { + collectPlaceholders(element.getAttributes().item(i).getNodeValue(), placeholders); + } + } + + private static void addProjectLevelDiagnostics(MuleProjectAnalysis analysis) { + if (analysis.muleXmlFiles.isEmpty()) { + analysis.diagnostics.add(MuleDiagnostic.high("src/main/mule", 0, "No Mule XML files were found.", + "Select a Mule application root with src/main/mule/*.xml files.")); + } + if (analysis.apiSpecFiles.isEmpty()) { + analysis.diagnostics.add(MuleDiagnostic.low("src/main/resources", 0, "No API specification files were found.", + "Add RAML/OpenAPI/AsyncAPI/WSDL/XSD contracts when this Mule app exposes or consumes APIs.")); + } + if (analysis.hasReconnectForever) { + analysis.diagnostics.add(MuleDiagnostic.medium("src/main/mule", 0, + "reconnect-forever detected in connector configuration.", + "Replace reconnect-forever with reconnect (finite retries and frequency) to prevent indefinite thread blocking in production.")); + } + if (!analysis.untilSuccessfulWithoutMaxRetries.isEmpty()) { + analysis.diagnostics.add(MuleDiagnostic.medium("src/main/mule", 0, + "until-successful usage without maxRetries: " + String.join(", ", analysis.untilSuccessfulWithoutMaxRetries), + "Set maxRetries and millisBetweenRetries on all until-successful scopes to prevent runaway retry loops.")); + } + if (!analysis.log4j2RootLevel.isBlank() + && (analysis.log4j2RootLevel.equalsIgnoreCase("debug") + || analysis.log4j2RootLevel.equalsIgnoreCase("trace"))) { + analysis.diagnostics.add(MuleDiagnostic.medium("src/main/resources/log4j2.xml", 0, + "Root log level is " + analysis.log4j2RootLevel.toUpperCase() + " which is not suitable for production.", + "Set the root logger level to INFO or WARN before deploying to production environments.")); + } + boolean hasDbConnector = analysis.connectorDependencies.stream() + .anyMatch(d -> d.toLowerCase(Locale.ROOT).contains("db") || d.toLowerCase(Locale.ROOT).contains("database")); + if (hasDbConnector && !analysis.hasDbPoolConfig) { + analysis.diagnostics.add(MuleDiagnostic.medium("src/main/mule", 0, + "Database connector dependency found but no connection pool configuration (minPoolSize/maxPoolSize) was detected.", + "Add connection pool settings to db:config to prevent connection exhaustion under load.")); + } + boolean hasHttpConnector = analysis.connectorDependencies.stream() + .anyMatch(d -> d.toLowerCase(Locale.ROOT).contains("http")); + if (hasHttpConnector && !analysis.hasHttpRequestTimeout) { + analysis.diagnostics.add(MuleDiagnostic.medium("src/main/mule", 0, + "HTTP connector found but no responseTimeout was detected in HTTP Request configurations.", + "Set responseTimeout on http:request-config to prevent threads blocking indefinitely on slow upstreams.")); + } + detectDuplicateNames("flow", analysis.flows, analysis.diagnostics); + detectDuplicateNames("sub-flow", analysis.subFlows, analysis.diagnostics); + } + + private static void addFlowStructureFindings(Path projectPath, MuleProjectAnalysis analysis, + List diagnostics) throws Exception { + for (String relative : analysis.muleXmlFiles) { + Path file = projectPath.resolve(relative); + String text = Files.readString(file, StandardCharsets.UTF_8); + if (!text.contains(" files, List diagnostics) + throws Exception { + List dwFiles = listFiles(projectPath.resolve("src/main/resources"), ".dwl"); + if (files != null && !files.isEmpty()) { + Set selected = files.stream().map(projectPath::resolve).map(Path::normalize).collect(Collectors.toSet()); + dwFiles = dwFiles.stream().filter(file -> selected.contains(file.normalize())).toList(); + } + for (Path file : dwFiles) { + String text = Files.readString(file, StandardCharsets.UTF_8); + String relative = relativize(projectPath, file); + if (!text.contains("%dw 2.0")) { + diagnostics.add(MuleDiagnostic.high(relative, 1, "DataWeave script does not declare %dw 2.0.", + "Add the DataWeave version header expected by Mule 4.")); + } + if (!text.contains("output ")) { + diagnostics.add(MuleDiagnostic.medium(relative, 0, "DataWeave script does not declare an output MIME type.", + "Declare the output format explicitly, for example output application/json.")); + } + } + } + + private static void addSecretFindings(Path projectPath, List diagnostics) throws Exception { + List files = new ArrayList<>(); + files.addAll(listFiles(projectPath.resolve("src/main/resources"), null)); + files.addAll(listFiles(projectPath.resolve("src/main/mule"), ".xml")); + for (Path file : files) { + String relative = relativize(projectPath, file); + List lines = Files.readAllLines(file, StandardCharsets.UTF_8); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + Matcher matcher = SECRET_LINE_PATTERN.matcher(line); + if (matcher.find() && isLiteralSecret(matcher.group(2))) { + diagnostics.add(MuleDiagnostic.critical(relative, i + 1, "Possible hardcoded secret in configuration.", + "Move this value to secure properties or an external secret manager.")); + } + String lower = line.toLowerCase(Locale.ROOT); + if (lower.contains("password=\"") || lower.contains("clientsecret=\"") || lower.contains("client-secret=\"") + || lower.contains("secret=\"") || lower.contains("token=\"")) { + if (!line.contains("${") && !line.contains("$[")) { + diagnostics.add(MuleDiagnostic.critical(relative, i + 1, "Possible hardcoded secret in Mule XML attribute.", + "Use property placeholders backed by secure properties.")); + } + } + } + } + } + + private static void addXmlSecurityFindings(Path projectPath, MuleProjectAnalysis analysis, + List diagnostics) throws Exception { + for (String relative : analysis.muleXmlFiles) { + Path file = projectPath.resolve(relative); + List lines = Files.readAllLines(file, StandardCharsets.UTF_8); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i).toLowerCase(Locale.ROOT); + if ((line.contains(" diagnostics, + List artifacts) { + if (!content.startsWith("#%RAML 1.0")) { + diagnostics.add(MuleDiagnostic.high(schemaPath.toString(), 1, "RAML file does not start with #%RAML 1.0.", + "Use RAML 1.0 for APIkit-compatible Mule contracts.")); + } + addPresenceDiagnostic(schemaPath, content, "title:", "RAML contract is missing title.", diagnostics, + "Add a human-readable API title."); + addPresenceDiagnostic(schemaPath, content, "version:", "RAML contract is missing version.", diagnostics, + "Add a version so generated Mule artifacts and Exchange assets are traceable."); + if (!content.contains("securitySchemes:") && !content.contains("securedBy:")) { + diagnostics.add(MuleDiagnostic.medium(schemaPath.toString(), 0, "RAML contract does not declare security.", + "Add securitySchemes and securedBy for the expected auth profile.")); + } + if (!RAML_RESPONSE_PATTERN.matcher(content).find()) { + diagnostics.add(MuleDiagnostic.high(schemaPath.toString(), 0, "RAML contract does not define HTTP responses.", + "Define success and error responses for every method.")); + } + if (!content.contains("example:") && !content.contains("examples:")) { + diagnostics.add(MuleDiagnostic.medium(schemaPath.toString(), 0, "RAML contract has no examples.", + "Add request and response examples for Copilot, APIkit, and review workflows.")); + } + artifacts.add("resources=" + countMatches(content, Pattern.compile("^\\s*/[^:]+:", Pattern.MULTILINE))); + } + + private static void analyzeOpenApi(Path schemaPath, String content, List diagnostics, + List artifacts) { + if (!content.contains("openapi: 3.") && !content.contains("\"openapi\"")) { + diagnostics.add(MuleDiagnostic.high(schemaPath.toString(), 0, "OpenAPI 3.x marker was not found.", + "Use OpenAPI 3.0.x or 3.1.x for Mule API contracts.")); + } + if (!content.contains("operationId")) { + diagnostics.add(MuleDiagnostic.medium(schemaPath.toString(), 0, "OpenAPI operations are missing operationId.", + "Add stable operationId values for routing, generated code, and review traceability.")); + } + if (!content.contains("components:") && !content.contains("\"components\"")) { + diagnostics.add(MuleDiagnostic.medium(schemaPath.toString(), 0, "OpenAPI contract does not use components.", + "Move schemas, responses, parameters, and security schemes into reusable components.")); + } + if (!content.contains("security") && !content.contains("securitySchemes")) { + diagnostics.add(MuleDiagnostic.medium(schemaPath.toString(), 0, "OpenAPI contract does not declare security.", + "Add OAuth2, JWT, API key, mTLS, or client credential definitions as appropriate.")); + } + artifacts.add("paths=" + countMatches(content, Pattern.compile("^\\s*/[^:]+:", Pattern.MULTILINE))); + } + + private static void analyzeXmlContract(Path schemaPath, String content, List diagnostics, + List artifacts) { + if (!content.trim().startsWith("<")) { + diagnostics.add(MuleDiagnostic.high(schemaPath.toString(), 1, "XML contract does not start with an XML element.", + "Fix WSDL/XSD syntax before Mule import or validation.")); + } + artifacts.add("xmlElements=" + countMatches(content, Pattern.compile("<[A-Za-z_:][A-Za-z0-9_.:-]*"))); + } + + private static void analyzeJsonContract(Path schemaPath, String content, List diagnostics, + List artifacts) { + String trimmed = content.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { + diagnostics.add(MuleDiagnostic.high(schemaPath.toString(), 1, "JSON-like contract is not valid JSON-shaped text.", + "Fix syntax or choose the correct schemaType.")); + } + artifacts.add("bytes=" + content.getBytes(StandardCharsets.UTF_8).length); + } + + private static List resolveMunitFiles(Path projectPath, MuleProjectAnalysis analysis, Path munitPath, + List diagnostics) { + if (munitPath != null) { + if (Files.isRegularFile(munitPath)) { + return List.of(munitPath); + } + diagnostics.add(MuleDiagnostic.high(munitPath.toString(), 0, "MUnit suite path does not exist.", + "Provide an existing MUnit XML suite file or omit munitPath to validate all suites.")); + return List.of(); + } + if (analysis.munitFiles.isEmpty()) { + diagnostics.add(MuleDiagnostic.high("src/test/munit", 0, "No MUnit suites were found.", + "Create MUnit suites under src/test/munit for the Mule flows.")); + return List.of(); + } + return analysis.munitFiles.stream().map(projectPath::resolve).toList(); + } + + private static Map> readFlowComponents(Path projectPath, + List diagnostics) { + Map> componentsByFlow = new LinkedHashMap<>(); + for (Path file : listFiles(projectPath.resolve("src/main/mule"), ".xml")) { + String relative = relativize(projectPath, file); + try { + Document document = parseXml(file); + NodeList flows = document.getElementsByTagNameNS("*", "flow"); + for (int i = 0; i < flows.getLength(); i++) { + Element flow = (Element) flows.item(i); + String flowName = flow.getAttribute("name"); + if (!flowName.isBlank()) { + componentsByFlow.put(flowName, collectFlowComponents(relative, flow)); + } + } + } catch (Exception e) { + diagnostics.add(MuleDiagnostic.high(relative, 0, "Failed to parse Mule XML for MUnit validation.", + "Fix Mule XML syntax before validating MUnit coverage.")); + } + } + return componentsByFlow; + } + + private static List collectFlowComponents(String relativeFile, Element flow) { + List components = new ArrayList<>(); + NodeList nodes = flow.getElementsByTagName("*"); + for (int i = 0; i < nodes.getLength(); i++) { + if (!(nodes.item(i) instanceof Element element) || isStructuralFlowElement(element)) { + continue; + } + String qualifiedName = qualifiedName(element); + components.add(new MuleFlowComponent(relativeFile, flow.getAttribute("name"), qualifiedName, + componentDisplayName(element), isExternalProcessor(qualifiedName), isBranchProcessor(element))); + } + return components; + } + + private static MunitSuiteSummary analyzeMunitSuite(Path projectPath, Path suitePath, + List diagnostics) { + String relative = relativize(projectPath, suitePath); + MunitSuiteSummary suite = new MunitSuiteSummary(relative); + try { + Document document = parseXml(suitePath); + Element root = document.getDocumentElement(); + suite.hasMunitNamespace = hasNamespace(root, "munit", "http://www.mulesoft.org/schema/mule/munit"); + suite.hasMunitToolsNamespace = + hasNamespace(root, "munit-tools", "http://www.mulesoft.org/schema/mule/munit-tools"); + suite.hasMunitSchema = root.getAttribute("xsi:schemaLocation").contains("mule-munit.xsd"); + suite.hasMunitToolsSchema = root.getAttribute("xsi:schemaLocation").contains("mule-munit-tools.xsd"); + suite.hasConfig = document.getElementsByTagNameNS("*", "config").getLength() > 0; + suite.tests.addAll(collectMunitTests(document)); + addMunitStructureDiagnostics(suite, diagnostics); + } catch (Exception e) { + diagnostics.add(MuleDiagnostic.high(relative, 0, "Failed to parse MUnit XML: " + e.getMessage(), + "Fix MUnit XML syntax before validating test purpose or coverage.")); + } + return suite; + } + + private static List collectMunitTests(Document document) { + List tests = new ArrayList<>(); + NodeList testNodes = document.getElementsByTagNameNS("*", "test"); + for (int i = 0; i < testNodes.getLength(); i++) { + Element testElement = (Element) testNodes.item(i); + MunitTestSummary test = new MunitTestSummary(testElement.getAttribute("name"), + testElement.getAttribute("description")); + NodeList descendants = testElement.getElementsByTagName("*"); + for (int j = 0; j < descendants.getLength(); j++) { + if (!(descendants.item(j) instanceof Element element)) { + continue; + } + collectMunitTestElement(test, element); + } + tests.add(test); + } + return tests; + } + + private static void collectMunitTestElement(MunitTestSummary test, Element element) { + String qualifiedName = qualifiedName(element); + test.processors.add(qualifiedName); + String localName = localName(element); + if ("execution".equals(localName)) { + test.hasExecution = true; + } else if ("validation".equals(localName)) { + test.hasValidation = true; + } else if ("assert-that".equals(localName) || "assert-equals".equals(localName) + || "assert-true".equals(localName) || "assert-false".equals(localName)) { + test.assertions++; + } else if ("mock-when".equals(localName)) { + addProcessorAttribute(test.mockedProcessors, element); + } else if ("spy".equals(localName)) { + addProcessorAttribute(test.spiedProcessors, element); + } else if ("verify-call".equals(localName)) { + addProcessorAttribute(test.verifiedProcessors, element); + } else if ("flow-ref".equals(localName)) { + String flowName = element.getAttribute("name"); + if (!flowName.isBlank()) { + test.flowRefs.add(flowName); + } + } + if (element.hasAttribute("expression")) { + test.assertedExpressions.add(element.getAttribute("expression")); + } + } + + private static void addMunitStructureDiagnostics(MunitSuiteSummary suite, List diagnostics) { + if (!suite.hasMunitNamespace) { + diagnostics.add(MuleDiagnostic.high(suite.relativePath, 0, "MUnit suite is missing the munit namespace.", + "Declare xmlns:munit=\"http://www.mulesoft.org/schema/mule/munit\".")); + } + if (!suite.hasMunitToolsNamespace) { + diagnostics.add(MuleDiagnostic.high(suite.relativePath, 0, "MUnit suite is missing the munit-tools namespace.", + "Declare xmlns:munit-tools=\"http://www.mulesoft.org/schema/mule/munit-tools\".")); + } + if (!suite.hasMunitSchema || !suite.hasMunitToolsSchema) { + diagnostics.add(MuleDiagnostic.medium(suite.relativePath, 0, + "MUnit suite schemaLocation is missing MUnit schema entries.", + "Include mule-munit.xsd and mule-munit-tools.xsd schema locations.")); + } + if (!suite.hasConfig) { + diagnostics.add(MuleDiagnostic.high(suite.relativePath, 0, "MUnit suite is missing munit:config.", + "Add munit:config to identify the file as an MUnit suite.")); + } + if (suite.tests.isEmpty()) { + diagnostics.add(MuleDiagnostic.high(suite.relativePath, 0, "MUnit suite contains no munit:test elements.", + "Add MUnit tests with execution and validation scopes.")); + } + } + + private static void validateMunitPurposeAndCoverage(Path projectPath, String targetFlow, + Map> flowComponents, List suites, + List diagnostics) { + Map> testsByFlow = mapTestsToFlows(targetFlow, flowComponents.keySet(), suites); + for (MunitSuiteSummary suite : suites) { + validateIndividualMunitTests(suite, diagnostics); + } + List flowNames = targetFlow.isBlank() ? new ArrayList<>(flowComponents.keySet()) : List.of(targetFlow); + for (String flowName : flowNames) { + List tests = testsByFlow.getOrDefault(flowName, List.of()); + List components = flowComponents.getOrDefault(flowName, List.of()); + if (components.isEmpty()) { + diagnostics.add(MuleDiagnostic.high("src/main/mule", 0, "Target flow was not found: " + flowName, + "Pass an existing Mule flow name or validate all flows.")); + continue; + } + validateFlowTestCoverage(projectPath, flowName, components, tests, diagnostics); + } + } + + private static void validateIndividualMunitTests(MunitSuiteSummary suite, List diagnostics) { + for (MunitTestSummary test : suite.tests) { + String testLabel = suite.relativePath + ":" + emptyToDefault(test.name, "unnamed-test"); + if (!test.hasExecution) { + diagnostics.add(MuleDiagnostic.high(suite.relativePath, 0, "MUnit test has no execution scope: " + test.name, + "Add munit:execution and call the target flow, usually with flow-ref.")); + } + if (!test.hasValidation) { + diagnostics.add(MuleDiagnostic.high(suite.relativePath, 0, "MUnit test has no validation scope: " + test.name, + "Add munit:validation with assertions or verify-call processors.")); + } + if (!test.hasMeaningfulValidation()) { + diagnostics.add(MuleDiagnostic.high(suite.relativePath, 0, + "MUnit test appears to have no logical validation purpose: " + testLabel, + "Assert payload, variables, attributes, errors, or verify expected processor calls.")); + } + if (test.description.isBlank()) { + diagnostics.add(MuleDiagnostic.low(suite.relativePath, 0, "MUnit test has no description: " + test.name, + "Describe the business scenario or edge case covered by the test.")); + } + } + } + + private static void validateFlowTestCoverage(Path projectPath, String flowName, List components, + List tests, List diagnostics) { + if (tests.isEmpty()) { + diagnostics.add(MuleDiagnostic.high("src/test/munit", 0, "No MUnit tests target flow: " + flowName, + "Add tests that execute the flow and validate its outputs and side effects.")); + return; + } + Set coveredProcessors = tests.stream().flatMap(test -> test.coveredProcessors().stream()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + List uncovered = components.stream() + .filter(component -> !isComponentCovered(component.qualifiedName, coveredProcessors)) + .map(MuleFlowComponent::displayLabel).distinct().toList(); + if (!uncovered.isEmpty()) { + diagnostics.add(MuleDiagnostic.medium(components.get(0).relativeFile, 0, + "Not all flow components are explicitly mocked, spied, verified, or asserted for flow: " + flowName, + "Add coverage for: " + String.join(", ", uncovered))); + } + validateExternalConnectorCoverage(flowName, components, tests, diagnostics); + validateBranchAndErrorCoverage(projectPath, flowName, components, tests, diagnostics); + } + + private static void validateExternalConnectorCoverage(String flowName, List components, + List tests, List diagnostics) { + Set mockedProcessors = tests.stream().flatMap(test -> test.mockedProcessors.stream()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + List unmocked = components.stream() + .filter(component -> component.external && !isComponentCovered(component.qualifiedName, mockedProcessors)) + .map(MuleFlowComponent::displayLabel).distinct().toList(); + if (!unmocked.isEmpty()) { + diagnostics.add(MuleDiagnostic.high(components.get(0).relativeFile, 0, + "External connector calls are not mocked for flow: " + flowName, + "Use munit-tools:mock-when for: " + String.join(", ", unmocked))); + } + } + + private static void validateBranchAndErrorCoverage(Path projectPath, String flowName, + List components, List tests, List diagnostics) { + boolean hasBranch = components.stream().anyMatch(component -> component.branch); + if (hasBranch && tests.size() < 2) { + diagnostics.add(MuleDiagnostic.medium(components.get(0).relativeFile, 0, + "Flow contains branching but has fewer than two MUnit tests: " + flowName, + "Add separate tests for true/false, route, or choice outcomes.")); + } + if (flowHasErrorHandler(projectPath, components.get(0).relativeFile, flowName) + && tests.stream().noneMatch(MunitTestSummary::looksLikeErrorTest)) { + diagnostics.add(MuleDiagnostic.medium(components.get(0).relativeFile, 0, + "Flow has error handling but no obvious error-path MUnit test: " + flowName, + "Add a negative test that mocks a failure and validates the error contract.")); + } + } + + private static void addMunitReviewDiagnostics(Path projectPath, String targetFlow, + Map> flowComponents, List suites, + List diagnostics) { + Map> testsByFlow = mapTestsToFlows(targetFlow, flowComponents.keySet(), suites); + List flowNames = targetFlow.isBlank() ? new ArrayList<>(flowComponents.keySet()) : List.of(targetFlow); + for (String flowName : flowNames) { + List tests = testsByFlow.getOrDefault(flowName, List.of()); + List components = flowComponents.getOrDefault(flowName, List.of()); + if (components.isEmpty()) { + continue; + } + addMunitScenarioDiagnostics(projectPath, flowName, components, tests, diagnostics); + addMunitAssertionQualityDiagnostics(flowName, components, tests, diagnostics); + } + for (MunitSuiteSummary suite : suites) { + for (MunitTestSummary test : suite.tests) { + if (isGenericTestName(test.name)) { + diagnostics.add(MuleDiagnostic.low(suite.relativePath, 0, + "MUnit test name does not communicate the scenario: " + emptyToDefault(test.name, "unnamed-test"), + "Name tests after behavior, such as get-account-success or get-account-connector-timeout.")); + } + } + } + } + + private static void addMunitScenarioDiagnostics(Path projectPath, String flowName, + List components, List tests, List diagnostics) { + if (tests.isEmpty()) { + return; + } + boolean hasBranch = components.stream().anyMatch(component -> component.branch); + boolean hasExternal = components.stream().anyMatch(component -> component.external); + boolean hasErrorHandler = flowHasErrorHandler(projectPath, components.get(0).relativeFile, flowName); + if (tests.size() == 1 && (hasBranch || hasExternal || hasErrorHandler)) { + diagnostics.add(MuleDiagnostic.medium(components.get(0).relativeFile, 0, + "MUnit coverage is too shallow for the flow complexity: " + flowName, + "Add separate tests for happy path, edge data, external failure, and error mapping.")); + } + if (hasExternal && tests.stream().noneMatch(MunitTestSummary::looksLikeFailureTest)) { + diagnostics.add(MuleDiagnostic.medium(components.get(0).relativeFile, 0, + "Flow calls external systems but has no obvious connector-failure MUnit scenario: " + flowName, + "Mock connector failures and assert the fallback, retry, or error response behavior.")); + } + } + + private static void addMunitAssertionQualityDiagnostics(String flowName, List components, + List tests, List diagnostics) { + if (tests.isEmpty()) { + return; + } + boolean assertsPayload = tests.stream().anyMatch(test -> test.assertsExpression("payload")); + boolean assertsVariables = tests.stream().anyMatch(test -> test.assertsExpression("vars.")); + boolean assertsAttributes = tests.stream().anyMatch(test -> test.assertsExpression("attributes")); + if (!assertsPayload) { + diagnostics.add(MuleDiagnostic.medium(components.get(0).relativeFile, 0, + "MUnit tests do not obviously assert the response payload for flow: " + flowName, + "Add assertions that prove the transformed business payload is correct.")); + } + if (!assertsAttributes && tests.stream().anyMatch(MunitTestSummary::hasHttpAssertionOpportunity)) { + diagnostics.add(MuleDiagnostic.low(components.get(0).relativeFile, 0, + "MUnit tests do not obviously assert HTTP attributes for flow: " + flowName, + "Assert status codes, headers, or outbound request attributes where they define the contract.")); + } + boolean hasSetVariable = components.stream() + .anyMatch(component -> component.qualifiedName.contains("set-variable")); + if (!assertsVariables && hasSetVariable) { + diagnostics.add(MuleDiagnostic.low(components.get(0).relativeFile, 0, + "Flow sets variables but MUnit tests do not obviously assert them: " + flowName, + "Assert important vars or verify the downstream call that consumes them.")); + } + } + + private static void addMunitCadenceDiagnostics(String targetFlow, Map> flowComponents, + List suites, List diagnostics) { + Map> testsByFlow = mapTestsToFlows(targetFlow, flowComponents.keySet(), suites); + List flowNames = targetFlow.isBlank() ? new ArrayList<>(flowComponents.keySet()) : List.of(targetFlow); + int testCount = suites.stream().mapToInt(suite -> suite.tests.size()).sum(); + if (testCount == 0) { + diagnostics.add(MuleDiagnostic.high("src/test/munit", 0, "No MUnit test cadence exists for this project.", + "Start with one focused happy-path test for each public flow, then add negative and edge scenarios.")); + return; + } + List untestedFlows = flowNames.stream() + .filter(flowName -> testsByFlow.getOrDefault(flowName, List.of()).isEmpty()).toList(); + if (!untestedFlows.isEmpty()) { + diagnostics.add(MuleDiagnostic.high("src/test/munit", 0, + "MUnit cadence does not cover every target flow.", + "Add tests for flows: " + String.join(", ", untestedFlows))); + } + long flowsBelowBaseline = flowNames.stream() + .filter(flowName -> testsByFlow.getOrDefault(flowName, List.of()).size() < 2).count(); + if (flowsBelowBaseline > 0) { + diagnostics.add(MuleDiagnostic.medium("src/test/munit", 0, + "Some flows have fewer than two MUnit scenarios.", + "Use at least happy-path and negative-path tests for changed or externally exposed flows.")); + } + } + + private static void addMunitReviewArtifacts(MuleToolResponse response, + Map> flowComponents, List suites, String targetFlow) { + int testCount = suites.stream().mapToInt(suite -> suite.tests.size()).sum(); + int assertionCount = suites.stream().flatMap(suite -> suite.tests.stream()).mapToInt(test -> test.assertions).sum(); + int mockCount = suites.stream().flatMap(suite -> suite.tests.stream()) + .mapToInt(test -> test.mockedProcessors.size()).sum(); + int verifyCount = suites.stream().flatMap(suite -> suite.tests.stream()) + .mapToInt(test -> test.verifiedProcessors.size()).sum(); + response.addArtifact("targetFlow=" + emptyToDefault(targetFlow, "all flows")); + response.addArtifact("flowCount=" + flowComponents.size()); + response.addArtifact("munitSuiteCount=" + suites.size()); + response.addArtifact("munitTestCount=" + testCount); + response.addArtifact("assertionCount=" + assertionCount); + response.addArtifact("mockWhenCount=" + mockCount); + response.addArtifact("verifyCallCount=" + verifyCount); + } + + private static Map> mapTestsToFlows(String targetFlow, Set flowNames, + List suites) { + Map> testsByFlow = new LinkedHashMap<>(); + for (MunitSuiteSummary suite : suites) { + for (MunitTestSummary test : suite.tests) { + for (String flowName : flowNames) { + if (test.targetsFlow(flowName)) { + testsByFlow.computeIfAbsent(flowName, ignored -> new ArrayList<>()).add(test); + } + } + } + } + return testsByFlow; + } + + private static void addPresenceDiagnostic(Path schemaPath, String content, String marker, String message, + List diagnostics, String recommendation) { + if (!content.contains(marker)) { + diagnostics.add(MuleDiagnostic.medium(schemaPath.toString(), 0, message, recommendation)); + } + } + + private static boolean flowHasErrorHandler(Path projectPath, String relativeFile, String flowName) { + try { + Document document = parseXml(projectPath.resolve(relativeFile)); + NodeList flows = document.getElementsByTagNameNS("*", "flow"); + for (int i = 0; i < flows.getLength(); i++) { + Element flow = (Element) flows.item(i); + if (flowName.equals(flow.getAttribute("name")) + && flow.getElementsByTagNameNS("*", "error-handler").getLength() > 0) { + return true; + } + } + } catch (Exception ignored) { + return false; + } + return false; + } + + private static boolean hasNamespace(Element root, String prefix, String uri) { + String value = root.getAttribute("xmlns:" + prefix); + return uri.equals(value); + } + + private static void addProcessorAttribute(Set processors, Element element) { + String processor = element.getAttribute("processor"); + if (!processor.isBlank()) { + processors.add(processor); + } + } + + private static boolean isStructuralFlowElement(Element element) { + String localName = localName(element); + String qualifiedName = qualifiedName(element); + return "flow".equals(localName) || "error-handler".equals(localName) || localName.startsWith("on-error-") + || "when".equals(localName) || "otherwise".equals(localName) || "ee:message".equals(qualifiedName) + || "ee:variables".equals(qualifiedName) || "ee:set-payload".equals(qualifiedName); + } + + private static boolean isExternalProcessor(String qualifiedName) { + if ("http:listener".equals(qualifiedName)) { + return false; + } + String prefix = qualifiedName.contains(":") ? qualifiedName.substring(0, qualifiedName.indexOf(':')) : ""; + return !prefix.isBlank() && !Set.of("mule", "munit", "munit-tools", "ee", "doc").contains(prefix) + && !qualifiedName.startsWith("core:"); + } + + private static boolean isBranchProcessor(Element element) { + String localName = localName(element); + return "choice".equals(localName) || "foreach".equals(localName) || "until-successful".equals(localName) + || "scatter-gather".equals(localName) || "parallel-foreach".equals(localName); + } + + private static boolean isComponentCovered(String qualifiedName, Set processors) { + if (processors.contains(qualifiedName)) { + return true; + } + return !qualifiedName.contains(":") && processors.contains("mule:" + qualifiedName); + } + + private static boolean isGenericTestName(String name) { + String normalized = name == null ? "" : name.toLowerCase(Locale.ROOT).replace("_", "-"); + return normalized.isBlank() || normalized.matches(".*test-[0-9]+.*") || normalized.matches(".*munit-test.*") + || normalized.equals("test") || normalized.endsWith("-test"); + } + + private static String componentDisplayName(Element element) { + String docName = element.getAttribute("doc:name"); + if (!docName.isBlank()) { + return docName; + } + String name = element.getAttribute("name"); + return name.isBlank() ? "" : name; + } + + private static void analyzeFlowDetails(Document document, MuleProjectAnalysis analysis) { + NodeList flows = document.getElementsByTagNameNS("*", "flow"); + for (int i = 0; i < flows.getLength(); i++) { + if (!(flows.item(i) instanceof Element flow)) { + continue; + } + String flowName = flow.getAttribute("name"); + if (flowName.isBlank()) { + continue; + } + detectSchedulerSource(flow, flowName, analysis); + detectCorrelationIdUsage(flow, flowName, analysis); + detectFlowErrorHandlerType(flow, flowName, analysis); + } + } + + private static void detectSchedulerSource(Element flow, String flowName, MuleProjectAnalysis analysis) { + NodeList children = flow.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + if (!(children.item(i) instanceof Element firstChild)) { + continue; + } + String lname = localName(firstChild); + if ("scheduler".equals(lname) || "poll".equals(lname)) { + analysis.schedulerFlows.add(flowName); + } + return; // Only inspect the first element child (the source) + } + } + + private static void detectCorrelationIdUsage(Element flow, String flowName, MuleProjectAnalysis analysis) { + NodeList setVars = flow.getElementsByTagNameNS("*", "set-variable"); + for (int i = 0; i < setVars.getLength(); i++) { + if (!(setVars.item(i) instanceof Element setVar)) { + continue; + } + String varName = setVar.getAttribute("variableName"); + if ("correlationId".equalsIgnoreCase(varName) || "correlationID".equalsIgnoreCase(varName)) { + analysis.flowsWithCorrelationId.add(flowName); + return; + } + String value = setVar.getAttribute("value"); + if (value.contains("X-Correlation-ID") || value.contains("correlationId")) { + analysis.flowsWithCorrelationId.add(flowName); + return; + } + } + } + + private static void detectFlowErrorHandlerType(Element flow, String flowName, MuleProjectAnalysis analysis) { + NodeList errorHandlers = flow.getElementsByTagNameNS("*", "error-handler"); + if (errorHandlers.getLength() == 0) { + analysis.flowErrorHandlerTypes.put(flowName, "none"); + return; + } + Element errorHandler = (Element) errorHandlers.item(0); + boolean allTyped = true; + int handlerCount = 0; + NodeList onErrors = errorHandler.getChildNodes(); + for (int i = 0; i < onErrors.getLength(); i++) { + if (!(onErrors.item(i) instanceof Element onError)) { + continue; + } + String lname = localName(onError); + if ("on-error-propagate".equals(lname) || "on-error-continue".equals(lname)) { + handlerCount++; + if (onError.getAttribute("type").isBlank()) { + allTyped = false; + } + } + } + analysis.flowErrorHandlerTypes.put(flowName, handlerCount == 0 ? "none" : (allTyped ? "typed" : "catch-all")); + } + + private static void parseLog4j2(Path log4j2Path, MuleProjectAnalysis analysis) { + try { + Document doc = parseXml(log4j2Path); + for (String tagName : List.of("Root", "AsyncRoot")) { + NodeList nodes = doc.getElementsByTagName(tagName); + if (nodes.getLength() > 0) { + String level = ((Element) nodes.item(0)).getAttribute("level"); + if (!level.isBlank()) { + analysis.log4j2RootLevel = level; + return; + } + } + } + } catch (Exception ignored) { + // log4j2.xml parse failure is non-critical + } + } + + private static Document parseXml(Path file) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + trySetFeature(factory, XMLConstants.FEATURE_SECURE_PROCESSING, true); + trySetFeature(factory, "http://apache.org/xml/features/disallow-doctype-decl", true); + trySetFeature(factory, "http://xml.org/sax/features/external-general-entities", false); + trySetFeature(factory, "http://xml.org/sax/features/external-parameter-entities", false); + try (InputStream inputStream = Files.newInputStream(file)) { + return factory.newDocumentBuilder().parse(inputStream); + } + } + + private static void trySetFeature(DocumentBuilderFactory factory, String feature, boolean enabled) { + try { + factory.setFeature(feature, enabled); + } catch (Exception ignored) { + // Some XML parsers do not expose every hardening feature. + } + } + + private static List listFiles(Path root, String extension) { + if (!Files.isDirectory(root)) { + return List.of(); + } + try (Stream stream = Files.walk(root)) { + return stream.filter(Files::isRegularFile) + .filter(file -> extension == null || file.getFileName().toString().endsWith(extension)).sorted() + .limit(MAX_FILES).collect(Collectors.toList()); + } catch (Exception e) { + return List.of(); + } + } + + private static boolean isApiSpec(Path file) { + String name = file.getFileName().toString().toLowerCase(Locale.ROOT); + return API_SPEC_EXTENSIONS.stream().anyMatch(name::endsWith); + } + + private static String inferSchemaType(Path schemaPath, String schemaType, String content) { + if (schemaType != null && !schemaType.isBlank()) { + return schemaType.toLowerCase(Locale.ROOT).replace("jsonschema", "jsonschema"); + } + String name = schemaPath.getFileName().toString().toLowerCase(Locale.ROOT); + if (name.endsWith(".raml") || content.startsWith("#%RAML")) { + return "raml"; + } + if (name.endsWith(".wsdl")) { + return "wsdl"; + } + if (name.endsWith(".xsd")) { + return "xsd"; + } + if (name.endsWith(".graphql")) { + return "graphql"; + } + if (name.endsWith(".avsc")) { + return "avro"; + } + if (content.contains("openapi:") || content.contains("\"openapi\"")) { + return "openapi"; + } + if (name.endsWith(".json")) { + return "jsonschema"; + } + if (name.endsWith(".yaml") || name.endsWith(".yml")) { + return "openapi"; + } + if (name.endsWith(".csv")) { + return "csv"; + } + return "unknown"; + } + + private static void collectPlaceholders(String text, Set placeholders) { + Matcher matcher = PLACEHOLDER_PATTERN.matcher(text == null ? "" : text); + while (matcher.find()) { + placeholders.add(matcher.group(1)); + } + } + + private static void detectDuplicateNames(String label, Set names, List diagnostics) { + Set seen = new LinkedHashSet<>(); + for (String value : names) { + String name = value.substring(value.indexOf(':') + 1); + if (!seen.add(name)) { + diagnostics.add(MuleDiagnostic.high("src/main/mule", 0, "Duplicate " + label + " name detected: " + name, + "Rename or consolidate duplicate Mule flows to avoid route ambiguity.")); + } + } + } + + private static boolean isLiteralSecret(String value) { + String trimmed = value == null ? "" : value.trim(); + return !trimmed.isBlank() && !trimmed.contains("${") && !trimmed.contains("$[") + && !trimmed.equalsIgnoreCase("changeme") + && !trimmed.equalsIgnoreCase("password"); + } + + private static void addNamedElement(Element element, Set target) { + String name = element.getAttribute("name"); + if (!name.isBlank()) { + target.add(localName(element) + ":" + name); + } + } + + private static String firstChildText(Element element, String localName) { + NodeList children = element.getElementsByTagNameNS("*", localName); + if (children.getLength() == 0) { + return ""; + } + return children.item(0).getTextContent().trim(); + } + + private static String localName(Element element) { + return element.getLocalName() != null ? element.getLocalName() : element.getNodeName(); + } + + private static String qualifiedName(Element element) { + String prefix = element.getPrefix(); + return prefix == null || prefix.isBlank() ? localName(element) : prefix + ":" + localName(element); + } + + private static String optionalName(Element element) { + String name = element.getAttribute("name"); + return name.isBlank() ? "" : "(" + name + ")"; + } + + private static String relativize(Path root, Path file) { + try { + return root.relativize(file).toString(); + } catch (Exception e) { + return file.toString(); + } + } + + private static String blankToUnknown(String value) { + return value == null || value.isBlank() ? "unknown" : value; + } + + private static String emptyToDefault(String value, String defaultValue) { + return value == null || value.isBlank() ? defaultValue : value; + } + + private static int countMatches(String content, Pattern pattern) { + int count = 0; + Matcher matcher = pattern.matcher(content); + while (matcher.find()) { + count++; + } + return count; + } + + private static void appendList(StringBuilder builder, String label, Iterable values) { + builder.append(label).append(":").append(System.lineSeparator()); + boolean hasValue = false; + for (String value : values) { + builder.append("- ").append(value).append(System.lineSeparator()); + hasValue = true; + } + if (!hasValue) { + builder.append("- none").append(System.lineSeparator()); + } + } + + private record MuleFlowComponent(String relativeFile, String flowName, String qualifiedName, String displayName, + boolean external, boolean branch) { + private String displayLabel() { + return displayName.isBlank() ? qualifiedName : qualifiedName + "(" + displayName + ")"; + } + } + + private static final class MunitSuiteSummary { + private final String relativePath; + private final List tests = new ArrayList<>(); + private boolean hasMunitNamespace; + private boolean hasMunitToolsNamespace; + private boolean hasMunitSchema; + private boolean hasMunitToolsSchema; + private boolean hasConfig; + + private MunitSuiteSummary(String relativePath) { + this.relativePath = relativePath; + } + } + + private static final class MunitTestSummary { + private final String name; + private final String description; + private final Set processors = new LinkedHashSet<>(); + private final Set flowRefs = new LinkedHashSet<>(); + private final Set mockedProcessors = new LinkedHashSet<>(); + private final Set spiedProcessors = new LinkedHashSet<>(); + private final Set verifiedProcessors = new LinkedHashSet<>(); + private final Set assertedExpressions = new LinkedHashSet<>(); + private boolean hasExecution; + private boolean hasValidation; + private int assertions; + + private MunitTestSummary(String name, String description) { + this.name = name; + this.description = description; + } + + private boolean hasMeaningfulValidation() { + return assertions > 0 || !verifiedProcessors.isEmpty() || !spiedProcessors.isEmpty() + || assertedExpressions.stream().anyMatch(expression -> !expression.isBlank()); + } + + private boolean targetsFlow(String flowName) { + return flowRefs.contains(flowName) || name.toLowerCase(Locale.ROOT).contains(flowName.toLowerCase(Locale.ROOT)); + } + + private Set coveredProcessors() { + Set covered = new LinkedHashSet<>(); + covered.addAll(mockedProcessors); + covered.addAll(spiedProcessors); + covered.addAll(verifiedProcessors); + covered.addAll(processors); + return covered; + } + + private boolean looksLikeErrorTest() { + String lowerName = name.toLowerCase(Locale.ROOT); + return lowerName.contains("error") || lowerName.contains("exception") || lowerName.contains("failure") + || mockedProcessors.stream().anyMatch(processor -> processor.toLowerCase(Locale.ROOT).contains("raise-error")) + || assertedExpressions.stream().anyMatch(expression -> expression.toLowerCase(Locale.ROOT).contains("error")); + } + + private boolean looksLikeFailureTest() { + String lowerName = name.toLowerCase(Locale.ROOT); + return lowerName.contains("error") || lowerName.contains("exception") || lowerName.contains("failure") + || lowerName.contains("timeout") || lowerName.contains("unavailable"); + } + + private boolean assertsExpression(String marker) { + return assertedExpressions.stream().anyMatch(expression -> expression.toLowerCase(Locale.ROOT) + .contains(marker.toLowerCase(Locale.ROOT))); + } + + private boolean hasHttpAssertionOpportunity() { + return processors.stream().anyMatch(processor -> processor.startsWith("http:")) + || flowRefs.stream().anyMatch(flowRef -> flowRef.toLowerCase(Locale.ROOT).contains("api")); + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectErrorsTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectErrorsTool.java new file mode 100644 index 00000000..57694cdc --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectErrorsTool.java @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.core.resources.IContainer; +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.jdt.annotation.Nullable; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchema; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchemaPropertyValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Reads Eclipse problem markers for a Mule project. + */ +public class MuleProjectErrorsTool extends BaseTool { + private static final String TOOL_NAME = "get_mule_project_errors"; + private static final String PROJECT_PATH = "projectPath"; + + /** + * Creates a Mule project marker reader. + */ + public MuleProjectErrorsTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Read Anypoint Studio Mule project problem markers"); + toolInfo.setDescription(""" + Get Eclipse problem markers for a MuleSoft Anypoint Studio project. + Use this after inspecting or editing Mule XML, DataWeave, RAML/OpenAPI, or MUnit files to see + validation errors from the same workspace problem marker system that Anypoint Studio uses. + This tool is read-only. + """); + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + Map properties = new LinkedHashMap<>(); + properties.put(PROJECT_PATH, new InputSchemaPropertyValue("string", "Absolute path to the Mule project folder")); + inputSchema.setProperties(properties); + inputSchema.setRequired(List.of(PROJECT_PATH)); + toolInfo.setInputSchema(inputSchema); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path projectPath = getProjectPath(input.get(PROJECT_PATH)); + if (projectPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("projectPath must be an absolute path to an existing Mule project folder."); + } else { + String errors = getErrors(projectPath); + result.setStatus(ToolInvocationStatus.success); + result.addContent(errors); + } + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to read Mule project errors: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + private String getErrors(Path projectPath) throws CoreException { + IContainer[] containers = + ResourcesPlugin.getWorkspace().getRoot().findContainersForLocationURI(projectPath.toUri()); + if (containers == null || containers.length == 0) { + return "No Eclipse workspace project or folder is mapped to " + projectPath; + } + + StringBuilder builder = new StringBuilder(); + for (IContainer container : containers) { + appendMarkers(container, builder); + } + return builder.length() == 0 ? "No error markers found for " + projectPath : builder.toString(); + } + + private void appendMarkers(IContainer container, StringBuilder builder) throws CoreException { + IMarker[] markers = container.findMarkers(IMarker.PROBLEM, true, IResource.DEPTH_INFINITE); + for (IMarker marker : markers) { + if (marker.getAttribute(IMarker.SEVERITY, IMarker.SEVERITY_INFO) != IMarker.SEVERITY_ERROR) { + continue; + } + IResource resource = marker.getResource(); + Object line = marker.getAttribute(IMarker.LINE_NUMBER); + Object message = marker.getAttribute(IMarker.MESSAGE); + builder.append(resource == null ? container.getName() : resource.getProjectRelativePath().toString()); + if (line != null) { + builder.append(":").append(line); + } + builder.append(" - ").append(message == null ? "Unknown problem marker" : message).append(System.lineSeparator()); + } + } + + @Nullable + private static Path getProjectPath(Object value) { + if (!(value instanceof String pathString) || pathString.isBlank()) { + return null; + } + Path path = Path.of(pathString).toAbsolutePath().normalize(); + if (!Files.isDirectory(path)) { + return null; + } + URI uri = path.toUri(); + return uri == null ? null : path; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectScanTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectScanTool.java new file mode 100644 index 00000000..61ea9fc5 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectScanTool.java @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Deterministic Mule project scanner for Agent Mode. + */ +public class MuleProjectScanTool extends BaseTool { + static final String TOOL_NAME = "mule_project_scan"; + + /** + * Creates a Mule project scanner tool. + */ + public MuleProjectScanTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Scan Mule project structure and metadata"); + toolInfo.setDescription(""" + Detect Mule project structure and metadata. Run this first on any Mule task before code review, security review, + or XML edits. Returns: Mule runtime version, all Mule XML file paths, flow and sub-flow names, API spec paths + (RAML, OpenAPI, WSDL), MUnit suite paths and test counts, connector dependencies with versions, APIkit usage, + deployment plugins (CloudHub, Runtime Fabric), property placeholder patterns, and immediate diagnostics + (missing mule-artifact.json, missing POM, no MUnit coverage, no API spec). + Use the runtime version and connector list to check version compatibility before suggesting upgrades. + Use the MUnit coverage data to identify flows with no test coverage. + This tool is read-only. + """); + toolInfo.setInputSchema(MuleToolInputs.projectPathSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path projectPath = MuleToolInputs.existingDirectory(input.get(MuleToolInputs.PROJECT_PATH)); + if (projectPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("projectPath must be an absolute path to an existing Mule project folder."); + } else { + result.setStatus(ToolInvocationStatus.success); + result.addContent(MuleProjectAnalyzer.projectScanResponse(projectPath).toJson()); + } + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to scan Mule project: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectSummaryTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectSummaryTool.java new file mode 100644 index 00000000..04d8bd86 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleProjectSummaryTool.java @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Read-only Mule project summarizer for Agent Mode. + */ +public class MuleProjectSummaryTool extends BaseTool { + private static final String TOOL_NAME = "summarize_mule_project"; + + /** + * Creates a Mule project summary tool. + */ + public MuleProjectSummaryTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Summarize Mule XML flows and project metadata"); + toolInfo.setDescription(""" + Summarize a MuleSoft Anypoint Studio project by reading Mule XML files under src/main/mule, + project metadata, API specs, MUnit suites, connectors, deployment plugins, namespaces, + flows, sub-flows, global configs, processors, and property placeholders. + Also surfaces: hasApikit, hasSecureProperties, hasBatchJob, hasReconnectForever, + log4j2RootLevel, hasDbPoolConfig, hasHttpRequestTimeout, scheduler-triggered flows, + flows with correlationId set, and a diagnostic count. + Use mule_project_scan for a full structured JSON response including all diagnostics. + This tool is read-only and returns a human-readable text summary. + """); + toolInfo.setInputSchema(MuleToolInputs.projectPathSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path projectPath = MuleToolInputs.existingDirectory(input.get(MuleToolInputs.PROJECT_PATH)); + if (projectPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("projectPath must be an absolute path to an existing Mule project folder."); + } else { + result.setStatus(ToolInvocationStatus.success); + result.addContent(MuleProjectAnalyzer.renderSummary(MuleProjectAnalyzer.scan(projectPath))); + } + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to summarize Mule project: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleSecurityReviewTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleSecurityReviewTool.java new file mode 100644 index 00000000..7ded12f8 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleSecurityReviewTool.java @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * MuleSoft security review tool. + */ +public class MuleSecurityReviewTool extends BaseTool { + static final String TOOL_NAME = "mule_security_review"; + + /** + * Creates a Mule security review tool. + */ + public MuleSecurityReviewTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Run MuleSoft security review"); + toolInfo.setDescription(""" + Perform a security review for MuleSoft projects by scanning Mule XML, property files, POM metadata, + and API specs. Detects: hardcoded credentials (password, secret, token, apikey, clientsecret patterns in XML + or property files), plain ${property} references for sensitive values that should use ${secure::property}, + missing Secure Configuration Properties module dependency, insecure HTTP Listener endpoints (HTTP not HTTPS), + outbound HTTP Request configs with insecure="true" or missing TLS context, Database connector queries + with string-concatenated SQL (SQL injection risk), unsafe payload logging in Logger components, + flows with no authentication mechanism on HTTP-facing endpoints, and missing API policy coverage. + Common high-severity findings: base64-encoded credentials in XML attributes, passwords in config-default.yaml, + HTTP Listener on port 8081 without TLS in a production-bound project. + This tool is read-only and classifies findings as critical, high, medium, or low. + """); + toolInfo.setInputSchema(MuleToolInputs.securityReviewSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path projectPath = MuleToolInputs.existingDirectory(input.get(MuleToolInputs.PROJECT_PATH)); + if (projectPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("projectPath must be an absolute path to an existing Mule project folder."); + } else { + result.setStatus(ToolInvocationStatus.success); + result.addContent(MuleProjectAnalyzer.securityReviewResponse(projectPath, + MuleToolInputs.optionalString(input.get(MuleToolInputs.SCOPE)), + MuleToolInputs.optionalString(input.get(MuleToolInputs.API_EXPOSURE))).toJson()); + } + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to run Mule security review: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleToolInputs.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleToolInputs.java new file mode 100644 index 00000000..4d83a31b --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleToolInputs.java @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.Nullable; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchema; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchemaPropertyValue; + +/** + * Shared input helpers for MuleSoft tools. + */ +final class MuleToolInputs { + static final String PROJECT_PATH = "projectPath"; + static final String SCHEMA_PATH = "schemaPath"; + static final String SCHEMA_TYPE = "schemaType"; + static final String RULESET_PATH = "rulesetPath"; + static final String FILES = "files"; + static final String FLOW_NAME = "flowName"; + static final String MUNIT_PATH = "munitPath"; + static final String REVIEW_TYPE = "reviewType"; + static final String SCOPE = "scope"; + static final String API_EXPOSURE = "apiExposure"; + static final String XML_FILE_PATH = "xmlFilePath"; + static final String DWL_FILE_PATH = "dwlFilePath"; + static final String TRANSFORM_NAME = "transformName"; + static final String TRANSFORM_ID = "transformId"; + static final String DWL_SCRIPT = "dwlScript"; + static final String VARIABLE_NAME = "variableName"; + static final String TARGET = "target"; + static final String LAYER = "layer"; + static final String TARGET_ENVIRONMENT = "targetEnvironment"; + static final String MAVEN_PROFILE = "mavenProfile"; + static final String INCLUDE_COMMENTS = "includeComments"; + static final String APPLY_FIXES = "applyFixes"; + + private MuleToolInputs() { + } + + static InputSchema projectPathSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of(PROJECT_PATH, + new InputSchemaPropertyValue("string", "Absolute path to the Mule project folder"))); + inputSchema.setRequired(List.of(PROJECT_PATH)); + return inputSchema; + } + + static InputSchema schemaAnalyzeSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of( + SCHEMA_PATH, new InputSchemaPropertyValue("string", + "Absolute path to RAML/OpenAPI/OData/AsyncAPI/WSDL/XSD/JSON schema file"), + SCHEMA_TYPE, new InputSchemaPropertyValue("string", + "Optional schema type such as raml, openapi, odata, asyncapi, graphql, wsdl, xsd, jsonschema, or avro"), + RULESET_PATH, new InputSchemaPropertyValue("string", "Optional absolute path to governance ruleset"))); + inputSchema.setRequired(List.of(SCHEMA_PATH)); + return inputSchema; + } + + static InputSchema codeReviewSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + InputSchemaPropertyValue files = new InputSchemaPropertyValue("array", + "Optional project-relative files to review; omit for full project"); + files.setItems(new InputSchemaPropertyValue("string", "Project-relative file path")); + inputSchema.setProperties(Map.of( + PROJECT_PATH, new InputSchemaPropertyValue("string", "Absolute path to the Mule project folder"), + FILES, files, + REVIEW_TYPE, new InputSchemaPropertyValue("string", "architecture, code, pr, or full"), + LAYER, new InputSchemaPropertyValue("string", "API-led layer: experience, process, system, or unknown"))); + inputSchema.setRequired(List.of(PROJECT_PATH)); + return inputSchema; + } + + static InputSchema securityReviewSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of( + PROJECT_PATH, new InputSchemaPropertyValue("string", "Absolute path to the Mule project folder"), + SCOPE, new InputSchemaPropertyValue("string", "full, changed-files, or active-file"), + API_EXPOSURE, new InputSchemaPropertyValue("string", "public, partner, or internal"), + TARGET_ENVIRONMENT, + new InputSchemaPropertyValue("string", "cloudhub, cloudhub2, rtf, standalone, or unknown"))); + inputSchema.setRequired(List.of(PROJECT_PATH)); + return inputSchema; + } + + static InputSchema munitValidationSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of( + PROJECT_PATH, new InputSchemaPropertyValue("string", "Absolute path to the Mule project folder"), + FLOW_NAME, new InputSchemaPropertyValue("string", "Optional Mule flow name to validate test coverage for"), + MUNIT_PATH, new InputSchemaPropertyValue("string", "Optional absolute path to one MUnit suite file"), + LAYER, new InputSchemaPropertyValue("string", "API-led layer: experience, process, system, or unknown"))); + inputSchema.setRequired(List.of(PROJECT_PATH)); + return inputSchema; + } + + @Nullable + static Path existingDirectory(Object value) { + if (!(value instanceof String pathString) || pathString.isBlank()) { + return null; + } + Path path = Path.of(pathString).toAbsolutePath().normalize(); + return Files.isDirectory(path) ? path : null; + } + + @Nullable + static Path existingFile(Object value) { + if (!(value instanceof String pathString) || pathString.isBlank()) { + return null; + } + Path path = Path.of(pathString).toAbsolutePath().normalize(); + return Files.isRegularFile(path) ? path : null; + } + + @Nullable + static Path optionalPath(Object value) { + if (!(value instanceof String pathString) || pathString.isBlank()) { + return null; + } + return Path.of(pathString).toAbsolutePath().normalize(); + } + + static String optionalString(Object value) { + return value instanceof String string ? string : ""; + } + + static List optionalStringList(Object value) { + if (value instanceof List list && list.stream().allMatch(String.class::isInstance)) { + return list.stream().map(String.class::cast).filter(item -> !item.isBlank()).toList(); + } + return List.of(); + } + + static InputSchema transformReadSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of( + XML_FILE_PATH, new InputSchemaPropertyValue("string", + "Absolute path to a Mule XML file containing ee:transform elements"), + TRANSFORM_NAME, new InputSchemaPropertyValue("string", + "Optional doc:name of the Transform Message component to read"), + TRANSFORM_ID, new InputSchemaPropertyValue("string", + "Optional doc:id of the Transform Message component to read"))); + inputSchema.setRequired(List.of(XML_FILE_PATH)); + return inputSchema; + } + + static InputSchema transformWriteSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of( + XML_FILE_PATH, new InputSchemaPropertyValue("string", + "Absolute path to the Mule XML file containing the target ee:transform element"), + TRANSFORM_NAME, new InputSchemaPropertyValue("string", + "doc:name of the Transform Message component to update"), + TRANSFORM_ID, new InputSchemaPropertyValue("string", + "doc:id of the Transform Message component to update"), + TARGET, new InputSchemaPropertyValue("string", + "What to update: 'payload', 'attributes', 'variable:name', or a variable name"), + DWL_SCRIPT, new InputSchemaPropertyValue("string", + "Complete DataWeave 2.0 script starting with %dw 2.0 and output directive"))); + inputSchema.setRequired(List.of(XML_FILE_PATH, DWL_SCRIPT)); + return inputSchema; + } + + static InputSchema dwlReadSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of( + DWL_FILE_PATH, new InputSchemaPropertyValue("string", + "Absolute path to a standalone DataWeave module (.dwl) file"))); + inputSchema.setRequired(List.of(DWL_FILE_PATH)); + return inputSchema; + } + + static InputSchema dwlWriteSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of( + DWL_FILE_PATH, new InputSchemaPropertyValue("string", + "Absolute path to the .dwl file to write"), + DWL_SCRIPT, new InputSchemaPropertyValue("string", + "Complete replacement DataWeave 2.0 script (should start with %dw 2.0 and output directive)"))); + inputSchema.setRequired(List.of(DWL_FILE_PATH, DWL_SCRIPT)); + return inputSchema; + } + + static InputSchema dwlOptimizeSchema() { + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + inputSchema.setProperties(Map.of( + DWL_FILE_PATH, new InputSchemaPropertyValue("string", + "Absolute path to the .dwl file to analyze and optimize"), + INCLUDE_COMMENTS, new InputSchemaPropertyValue("boolean", + "Whether to add inline comments to undocumented functions (default: true)"), + APPLY_FIXES, new InputSchemaPropertyValue("boolean", + "Whether to write the optimized script back to the file (default: false β€” preview only)"))); + inputSchema.setRequired(List.of(DWL_FILE_PATH)); + return inputSchema; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleToolResponse.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleToolResponse.java new file mode 100644 index 00000000..35f4267a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleToolResponse.java @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.util.ArrayList; +import java.util.List; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +/** + * Structured response shape for MuleSoft agent tools. + */ +final class MuleToolResponse { + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create(); + + private final String status; + private final String summary; + private final List artifacts = new ArrayList<>(); + private final List diagnostics = new ArrayList<>(); + private final List patches = new ArrayList<>(); + private final List nextActions = new ArrayList<>(); + + MuleToolResponse(String status, String summary) { + this.status = status; + this.summary = summary; + } + + void addArtifact(String artifact) { + if (artifact != null && !artifact.isBlank()) { + artifacts.add(artifact); + } + } + + void addDiagnostic(MuleDiagnostic diagnostic) { + if (diagnostic != null) { + diagnostics.add(diagnostic); + } + } + + void addDiagnostics(List values) { + if (values != null) { + values.forEach(this::addDiagnostic); + } + } + + void addNextAction(String nextAction) { + if (nextAction != null && !nextAction.isBlank()) { + nextActions.add(nextAction); + } + } + + String toJson() { + return GSON.toJson(this); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformReadTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformReadTool.java new file mode 100644 index 00000000..efd119f8 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformReadTool.java @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Reads DataWeave scripts from Transform Message (ee:transform) components in Mule XML files. + * Returns the payload, attributes, and variable DWL scripts along with their output type declarations, + * giving Copilot the context needed to generate or improve DataWeave mappings. + */ +public class MuleTransformReadTool extends BaseTool { + static final String TOOL_NAME = "mule_read_transform"; + + /** + * Creates a Mule transform read tool. + */ + public MuleTransformReadTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Read DataWeave scripts from Transform Message components"); + toolInfo.setDescription(""" + Read the DataWeave 2.0 scripts inside Transform Message (ee:transform) components in a Mule XML file. + Returns set-payload, set-attributes, and set-variable scripts with output types. + External DWL resources are reported and read from src/main/resources when the project root can be inferred. + Use this before writing or reviewing a DataWeave mapping to understand the current state and type context. + Optionally filter by doc:name or doc:id to target a specific Transform Message component. + This tool is read-only. + """); + toolInfo.setInputSchema(MuleToolInputs.transformReadSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path xmlPath = MuleToolInputs.existingFile(input.get(MuleToolInputs.XML_FILE_PATH)); + if (xmlPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("xmlFilePath must be an absolute path to an existing Mule XML file."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + String transformName = MuleToolInputs.optionalString(input.get(MuleToolInputs.TRANSFORM_NAME)); + String transformId = MuleToolInputs.optionalString(input.get(MuleToolInputs.TRANSFORM_ID)); + + ReadOutcome outcome = readTransforms(xmlPath, transformName, transformId); + result.setStatus(outcome.success() ? ToolInvocationStatus.success : ToolInvocationStatus.error); + result.addContent(outcome.message()); + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to read Transform Message: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + private static ReadOutcome readTransforms(Path xmlPath, String transformName, String transformId) throws Exception { + Document document = MuleTransformSupport.parseXml(xmlPath); + List matched = MuleTransformSupport.findTransforms(document, transformName, transformId); + if (matched.isEmpty()) { + return new ReadOutcome(false, + "No ee:transform element matched the given transformName or transformId in " + xmlPath.getFileName()); + } + + StringBuilder sb = new StringBuilder(); + sb.append("file=").append(xmlPath.toAbsolutePath()).append(System.lineSeparator()); + sb.append("transformCount=").append(matched.size()).append(System.lineSeparator()); + + for (Element transform : matched) { + sb.append(System.lineSeparator()); + sb.append("--- Transform: ").append(MuleTransformSupport.transformLabel(transform)).append(" ---") + .append(System.lineSeparator()); + + appendScriptsFromMessage(xmlPath, transform, sb); + appendScriptsFromVariables(xmlPath, transform, sb); + } + + return new ReadOutcome(true, sb.toString()); + } + + private static void appendScriptsFromMessage(Path xmlPath, Element transform, StringBuilder sb) { + for (Element messageEl : MuleTransformSupport.directChildren(transform, "message")) { + for (Element setPayloadEl : MuleTransformSupport.directChildren(messageEl, "set-payload")) { + appendScript(xmlPath, sb, MuleTransformSupport.TARGET_PAYLOAD, setPayloadEl); + } + for (Element setAttributesEl : MuleTransformSupport.directChildren(messageEl, "set-attributes")) { + appendScript(xmlPath, sb, MuleTransformSupport.TARGET_ATTRIBUTES, setAttributesEl); + } + } + } + + private static void appendScriptsFromVariables(Path xmlPath, Element transform, StringBuilder sb) { + for (Element variablesEl : MuleTransformSupport.directChildren(transform, "variables")) { + for (Element setVarEl : MuleTransformSupport.directChildren(variablesEl, "set-variable")) { + String varName = setVarEl.getAttribute("variableName"); + appendScript(xmlPath, sb, MuleTransformSupport.TARGET_VARIABLE_PREFIX + varName, setVarEl); + } + } + } + + private static void appendScript(Path xmlPath, StringBuilder sb, String target, Element element) { + MuleTransformSupport.ScriptContent content = MuleTransformSupport.readScriptContent(element, xmlPath); + String script = content.script(); + String outputType = extractOutputType(script); + sb.append("target=").append(target).append(System.lineSeparator()); + if (!content.resource().isBlank()) { + sb.append("resource=").append(content.resource()).append(System.lineSeparator()); + sb.append("resourceStatus=").append(content.resourceStatus()).append(System.lineSeparator()); + if (content.resourcePath() != null) { + sb.append("resourcePath=").append(content.resourcePath()).append(System.lineSeparator()); + } + } + sb.append("outputType=").append(outputType).append(System.lineSeparator()); + sb.append("script:").append(System.lineSeparator()).append(script).append(System.lineSeparator()); + } + + private static String extractOutputType(String script) { + for (String line : script.split("\\r?\\n")) { + String trimmed = line.trim(); + if (trimmed.startsWith("output ")) { + return trimmed.substring("output ".length()).trim(); + } + } + return "unknown"; + } + + private record ReadOutcome(boolean success, String message) { + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformSupport.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformSupport.java new file mode 100644 index 00000000..3d266cc2 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformSupport.java @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.io.InputStream; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import org.w3c.dom.CDATASection; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +/** + * Shared XML and DataWeave helpers for Transform Message tools. + */ +public final class MuleTransformSupport { + public static final String EE_NS = "http://www.mulesoft.org/schema/mule/ee/core"; + public static final String DOC_NS = "http://www.mulesoft.org/schema/mule/documentation"; + public static final String TARGET_ATTRIBUTES = "attributes"; + public static final String TARGET_PAYLOAD = "payload"; + public static final String TARGET_VARIABLE_PREFIX = "variable:"; + + private static final Path MULE_SOURCE_PATH = Path.of("src", "main", "mule"); + private static final Path RESOURCES_PATH = Path.of("src", "main", "resources"); + + private MuleTransformSupport() { + } + + public static Document parseXml(Path file) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + trySetFeature(factory, XMLConstants.FEATURE_SECURE_PROCESSING, true); + trySetFeature(factory, "http://apache.org/xml/features/disallow-doctype-decl", true); + trySetFeature(factory, "http://xml.org/sax/features/external-general-entities", false); + trySetFeature(factory, "http://xml.org/sax/features/external-parameter-entities", false); + try (InputStream inputStream = Files.newInputStream(file)) { + return factory.newDocumentBuilder().parse(inputStream); + } + } + + static void serializeDocument(Document document, Path xmlPath) throws Exception { + String original = Files.readString(xmlPath, StandardCharsets.UTF_8); + boolean hasXmlDeclaration = original.stripLeading().startsWith(" findTransforms(Document document, String transformName, String transformId) { + List matched = new ArrayList<>(); + var transforms = document.getElementsByTagNameNS(EE_NS, "transform"); + for (int i = 0; i < transforms.getLength(); i++) { + if (transforms.item(i) instanceof Element element + && matchesTransform(element, transformName, transformId, transformName.isBlank() && transformId.isBlank())) { + matched.add(element); + } + } + return matched; + } + + public static List directChildren(Element parent, String localName) { + List children = new ArrayList<>(); + for (Node child = parent.getFirstChild(); child != null; child = child.getNextSibling()) { + if (child instanceof Element element && EE_NS.equals(element.getNamespaceURI()) + && localName.equals(element.getLocalName())) { + children.add(element); + } + } + return children; + } + + public static Element firstDirectChild(Element parent, String localName) { + List children = directChildren(parent, localName); + return children.isEmpty() ? null : children.get(0); + } + + public static ScriptContent readScriptContent(Element element, Path xmlPath) { + String resource = element.getAttribute("resource"); + if (resource.isBlank()) { + return new ScriptContent(element.getTextContent().trim(), "", null, ""); + } + ResourceResolution resourceResolution = resolveResource(xmlPath, resource); + if (resourceResolution.path() != null && Files.isRegularFile(resourceResolution.path())) { + try { + String script = Files.readString(resourceResolution.path(), StandardCharsets.UTF_8).trim(); + return new ScriptContent(script, resource, resourceResolution.path(), "resolved"); + } catch (Exception e) { + return new ScriptContent("", resource, resourceResolution.path(), "unreadable: " + e.getMessage()); + } + } + return new ScriptContent("", resource, resourceResolution.path(), resourceResolution.status()); + } + + static WriteContentResult writeScriptContent(Document document, Element element, Path xmlPath, String dwlScript) + throws Exception { + String resource = element.getAttribute("resource"); + if (!resource.isBlank()) { + ResourceResolution resourceResolution = resolveResource(xmlPath, resource); + if (resourceResolution.path() == null || !Files.isRegularFile(resourceResolution.path())) { + return new WriteContentResult(false, false, null, + "External DWL resource could not be resolved: " + resource + " (" + resourceResolution.status() + ")"); + } + Files.writeString(resourceResolution.path(), dwlScript, StandardCharsets.UTF_8); + return new WriteContentResult(true, false, resourceResolution.path(), + "Updated external DWL resource " + resourceResolution.path().getFileName()); + } + + while (element.hasChildNodes()) { + element.removeChild(element.getFirstChild()); + } + CDATASection cdata = document.createCDATASection(dwlScript); + element.appendChild(cdata); + return new WriteContentResult(true, true, xmlPath, "Updated inline DataWeave script"); + } + + public static String transformLabel(Element transform) { + String docName = transform.getAttributeNS(DOC_NS, "name"); + if (docName.isBlank()) { + docName = transform.getAttribute("doc:name"); + } + String docId = transform.getAttributeNS(DOC_NS, "id"); + if (docId.isBlank()) { + docId = transform.getAttribute("doc:id"); + } + return docName.isBlank() ? "(unnamed)" : docName + (docId.isBlank() ? "" : " [id=" + docId + "]"); + } + + private static boolean matchesTransform(Element transform, String transformName, String transformId, + boolean matchAll) { + if (matchAll) { + return true; + } + String docName = transform.getAttributeNS(DOC_NS, "name"); + if (docName.isBlank()) { + docName = transform.getAttribute("doc:name"); + } + String docId = transform.getAttributeNS(DOC_NS, "id"); + if (docId.isBlank()) { + docId = transform.getAttribute("doc:id"); + } + if (!transformName.isBlank() && !transformId.isBlank()) { + return matchesName(transformName, docName) && matchesId(transformId, docId); + } + return (!transformName.isBlank() && matchesName(transformName, docName)) + || (!transformId.isBlank() && matchesId(transformId, docId)); + } + + private static boolean matchesName(String filter, String docName) { + String f = filter.trim(); + String n = docName.trim(); + // Exact match first, then case-insensitive, then substring (handles partial names from AI) + return n.equals(f) || n.equalsIgnoreCase(f) || n.toLowerCase().contains(f.toLowerCase()); + } + + private static boolean matchesId(String filter, String docId) { + String f = filter.trim(); + String d = docId.trim(); + return d.equals(f) || d.equalsIgnoreCase(f); + } + + private static ResourceResolution resolveResource(Path xmlPath, String resource) { + Path projectRoot = findProjectRoot(xmlPath); + if (projectRoot == null) { + return new ResourceResolution(null, "projectRootNotFound"); + } + Path resourcesRoot = projectRoot.resolve(RESOURCES_PATH).toAbsolutePath().normalize(); + Path resourcePath = Path.of(resource); + Path candidate = resourcePath.isAbsolute() ? resourcePath.normalize() + : resourcesRoot.resolve(resourcePath).normalize(); + if (!candidate.startsWith(resourcesRoot)) { + return new ResourceResolution(candidate, "outsideResources"); + } + return new ResourceResolution(candidate, Files.isRegularFile(candidate) ? "resolved" : "notFound"); + } + + private static Path findProjectRoot(Path xmlPath) { + Path current = xmlPath.toAbsolutePath().normalize().getParent(); + while (current != null) { + if (current.endsWith(MULE_SOURCE_PATH)) { + Path srcMain = current.getParent(); + Path src = srcMain == null ? null : srcMain.getParent(); + return src == null ? null : src.getParent(); + } + current = current.getParent(); + } + + current = xmlPath.toAbsolutePath().normalize().getParent(); + while (current != null) { + if (Files.isRegularFile(current.resolve("pom.xml"))) { + return current; + } + current = current.getParent(); + } + return null; + } + + private static void trySetFeature(DocumentBuilderFactory factory, String feature, boolean enabled) { + try { + factory.setFeature(feature, enabled); + } catch (Exception ignored) { + // Some XML parsers do not expose every hardening feature. + } + } + + private static void trySetAttribute(TransformerFactory factory, String attribute, String value) { + try { + factory.setAttribute(attribute, value); + } catch (Exception ignored) { + // Some transformer implementations do not expose every hardening attribute. + } + } + + public record ScriptContent(String script, String resource, Path resourcePath, String resourceStatus) { + } + + record WriteContentResult(boolean success, boolean xmlModified, Path modifiedPath, String message) { + } + + private record ResourceResolution(Path path, String status) { + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformWriteTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformWriteTool.java new file mode 100644 index 00000000..79902d8a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MuleTransformWriteTool.java @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConfirmationMessages; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Writes a DataWeave 2.0 script into a Transform Message (ee:transform) component in a Mule XML file. + * Targets the ee:set-payload, ee:set-attributes, or a named ee:set-variable element. + * Requires user confirmation before modifying the XML file. + */ +public class MuleTransformWriteTool extends BaseTool { + static final String TOOL_NAME = "mule_write_transform"; + + /** + * Creates a Mule transform write tool. + */ + public MuleTransformWriteTool() { + this.name = TOOL_NAME; + } + + @Override + public boolean needConfirmation() { + return true; + } + + @Override + public ConfirmationMessages getConfirmationMessages() { + ConfirmationMessages messages = new ConfirmationMessages(); + messages.setTitle("Update Transform Message DataWeave Script"); + messages.setMessage( + "This will replace the DataWeave script inside the Transform Message component in the Mule XML file. " + + "Continue?"); + return messages; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Write a DataWeave script into a Transform Message component"); + toolInfo.setDescription(""" + Replace the DataWeave 2.0 script inside a Transform Message (ee:transform) component in a Mule XML file. + Use target 'payload', 'attributes', 'variable:name', or a variable name. + Identify the transform by transformName (doc:name) or transformId (doc:id). + If the target uses resource=\"...\", the external DWL file under src/main/resources is updated. + The dwlScript must be a complete DataWeave 2.0 script starting with %dw 2.0 and an output directive. + Always run mule_read_transform first to confirm the current state before writing. + Requires user confirmation before modifying the file. + """); + toolInfo.setInputSchema(MuleToolInputs.transformWriteSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path xmlPath = MuleToolInputs.existingFile(input.get(MuleToolInputs.XML_FILE_PATH)); + if (xmlPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("xmlFilePath must be an absolute path to an existing Mule XML file."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + String dwlScript = MuleToolInputs.optionalString(input.get(MuleToolInputs.DWL_SCRIPT)); + if (dwlScript.isBlank()) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("dwlScript is required and must not be blank."); + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + String target = MuleToolInputs.optionalString(input.get(MuleToolInputs.TARGET)); + String transformName = MuleToolInputs.optionalString(input.get(MuleToolInputs.TRANSFORM_NAME)); + String transformId = MuleToolInputs.optionalString(input.get(MuleToolInputs.TRANSFORM_ID)); + + WriteOutcome outcome = writeTransform(xmlPath, transformName, transformId, target, dwlScript); + if (outcome.refreshPath() != null) { + refreshWorkspaceFile(outcome.refreshPath()); + } + + result.setStatus(outcome.success() ? ToolInvocationStatus.success : ToolInvocationStatus.error); + result.addContent(outcome.message()); + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to write Transform Message: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + private static WriteOutcome writeTransform(Path xmlPath, String transformName, String transformId, + String target, String dwlScript) throws Exception { + Document document = MuleTransformSupport.parseXml(xmlPath); + SingleTransformMatch match = findSingleTransform(document, transformName, transformId); + if (!match.success()) { + return new WriteOutcome(false, match.message(), null); + } + + TargetElement targetElement = findTargetElement(match.transform(), target); + if (!targetElement.success()) { + return new WriteOutcome(false, targetElement.message(), null); + } + + MuleTransformSupport.WriteContentResult writeResult = + MuleTransformSupport.writeScriptContent(document, targetElement.element(), xmlPath, dwlScript); + if (!writeResult.success()) { + return new WriteOutcome(false, writeResult.message(), null); + } + if (writeResult.xmlModified()) { + MuleTransformSupport.serializeDocument(document, xmlPath); + } + + String message = writeResult.message() + " for Transform Message '" + + MuleTransformSupport.transformLabel(match.transform()) + "' target='" + targetElement.label() + + "' in " + (writeResult.modifiedPath() == null ? xmlPath.getFileName() : writeResult.modifiedPath()); + return new WriteOutcome(true, message, writeResult.modifiedPath()); + } + + private static SingleTransformMatch findSingleTransform(Document document, String transformName, String transformId) { + List transforms = MuleTransformSupport.findTransforms(document, transformName, transformId); + if (transforms.isEmpty()) { + return new SingleTransformMatch(false, + "No matching ee:transform element found. Provide transformName or transformId, or verify the file path.", + null); + } + if (transforms.size() > 1) { + return new SingleTransformMatch(false, + "Multiple ee:transform elements matched. Provide a unique transformName or transformId.", null); + } + return new SingleTransformMatch(true, "", transforms.get(0)); + } + + private static TargetElement findTargetElement(Element transform, String target) { + String normalizedTarget = normalizeTarget(target); + if (MuleTransformSupport.TARGET_PAYLOAD.equals(normalizedTarget)) { + Element payload = firstMessageChild(transform, "set-payload"); + return payload == null ? missingTarget(normalizedTarget) : new TargetElement(true, "", payload, normalizedTarget); + } + if (MuleTransformSupport.TARGET_ATTRIBUTES.equals(normalizedTarget)) { + Element attributes = firstMessageChild(transform, "set-attributes"); + return attributes == null ? missingTarget(normalizedTarget) + : new TargetElement(true, "", attributes, normalizedTarget); + } + + String variableName = normalizedTarget.startsWith(MuleTransformSupport.TARGET_VARIABLE_PREFIX) + ? normalizedTarget.substring(MuleTransformSupport.TARGET_VARIABLE_PREFIX.length()) : normalizedTarget; + for (Element variablesEl : MuleTransformSupport.directChildren(transform, "variables")) { + for (Element setVarEl : MuleTransformSupport.directChildren(variablesEl, "set-variable")) { + if (variableName.equals(setVarEl.getAttribute("variableName"))) { + return new TargetElement(true, "", setVarEl, MuleTransformSupport.TARGET_VARIABLE_PREFIX + variableName); + } + } + } + return missingTarget(MuleTransformSupport.TARGET_VARIABLE_PREFIX + variableName); + } + + private static Element firstMessageChild(Element transform, String localName) { + for (Element messageEl : MuleTransformSupport.directChildren(transform, "message")) { + Element child = MuleTransformSupport.firstDirectChild(messageEl, localName); + if (child != null) { + return child; + } + } + return null; + } + + private static String normalizeTarget(String target) { + String normalized = target == null ? "" : target.trim(); + return normalized.isBlank() ? MuleTransformSupport.TARGET_PAYLOAD : normalized; + } + + private static TargetElement missingTarget(String target) { + return new TargetElement(false, + "Target element not found in transform. Verify that target '" + target + + "' exists in the Transform Message component.", + null, target); + } + + private static void refreshWorkspaceFile(Path xmlPath) { + try { + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + IFile file = root.getFileForLocation( + new org.eclipse.core.runtime.Path(xmlPath.toAbsolutePath().toString())); + if (file != null && file.exists()) { + file.refreshLocal(IFile.DEPTH_ZERO, null); + } + } catch (Exception e) { + // Non-fatal: the file was written successfully; workspace refresh will happen on next build + } + } + + private record SingleTransformMatch(boolean success, String message, Element transform) { + } + + private record TargetElement(boolean success, String message, Element element, String label) { + } + + private record WriteOutcome(boolean success, String message, Path refreshPath) { + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitFullReviewTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitFullReviewTool.java new file mode 100644 index 00000000..0ce5594c --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitFullReviewTool.java @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Reviews MUnit suites for purpose, coverage, and assertion quality. + */ +public class MunitFullReviewTool extends BaseTool { + static final String TOOL_NAME = "munit_full_review"; + + /** + * Creates an MUnit full review tool. + */ + public MunitFullReviewTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Review MUnit purpose, coverage, and quality"); + toolInfo.setDescription(""" + Perform a comprehensive read-only MUnit review for Mule flows. Combines all validation checks from + munit_validate_flow_tests with deeper scenario analysis: identifies which of happy-path, negative-path, + edge-data, connector-failure, and error-contract scenarios are missing per flow; flags assertion quality + issues (asserting implementation details instead of output contracts, no assertions on error handler behavior); + checks mock coverage completeness (every external connector call mocked vs. only some); reviews Choice + router branch coverage and scatter-gather route coverage; and identifies tests that duplicate each other. + Use this for a full suite audit before a release or when test quality is unclear. + Returns structured findings with missing scenario descriptions and recommended additional test cases. + This tool is read-only. + """); + toolInfo.setInputSchema(MuleToolInputs.munitValidationSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path projectPath = MuleToolInputs.existingDirectory(input.get(MuleToolInputs.PROJECT_PATH)); + if (projectPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("projectPath must be an absolute path to an existing Mule project folder."); + } else { + result.setStatus(ToolInvocationStatus.success); + result.addContent(MuleProjectAnalyzer.munitFullReviewResponse(projectPath, + MuleToolInputs.optionalString(input.get(MuleToolInputs.FLOW_NAME)), + MuleToolInputs.optionalPath(input.get(MuleToolInputs.MUNIT_PATH))).toJson()); + } + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to review MUnit suites: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitImprovementSuggestionsTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitImprovementSuggestionsTool.java new file mode 100644 index 00000000..304cc35f --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitImprovementSuggestionsTool.java @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Suggests improvements for MUnit coverage cadence and maintainability. + */ +public class MunitImprovementSuggestionsTool extends BaseTool { + static final String TOOL_NAME = "munit_improvement_suggestions"; + + /** + * Creates an MUnit improvement suggestion tool. + */ + public MunitImprovementSuggestionsTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Suggest MUnit cadence and coverage improvements"); + toolInfo.setDescription(""" + Review MUnit coverage cadence for a Mule project or flow. Suggests focused improvements for scenario mix, + assertion depth, external connector mocking, branch and error-path tests, and maintainable test naming. This + tool is read-only and returns structured recommendations. + """); + toolInfo.setInputSchema(MuleToolInputs.munitValidationSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path projectPath = MuleToolInputs.existingDirectory(input.get(MuleToolInputs.PROJECT_PATH)); + if (projectPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("projectPath must be an absolute path to an existing Mule project folder."); + } else { + result.setStatus(ToolInvocationStatus.success); + result.addContent(MuleProjectAnalyzer.munitImprovementSuggestionsResponse(projectPath, + MuleToolInputs.optionalString(input.get(MuleToolInputs.FLOW_NAME)), + MuleToolInputs.optionalPath(input.get(MuleToolInputs.MUNIT_PATH))).toJson()); + } + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to suggest MUnit improvements: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitValidateFlowTestsTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitValidateFlowTestsTool.java new file mode 100644 index 00000000..a2849089 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/MunitValidateFlowTestsTool.java @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Validates whether MUnit suites meaningfully test Mule flows. + */ +public class MunitValidateFlowTestsTool extends BaseTool { + static final String TOOL_NAME = "munit_validate_flow_tests"; + + /** + * Creates an MUnit flow test validator tool. + */ + public MunitValidateFlowTestsTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Validate MUnit purpose, structure, and flow coverage"); + toolInfo.setDescription(""" + Validate MUnit suites for Mule flows. Checks: MUnit and MUnit Tools namespace declarations, munit:config + presence, test execution elements (munit:execution), assertion elements (munit:assert-that or munit-tools), + mock-when usage for external connectors, spy and verify-call usage, and whether each test has a clear + logical purpose tied to a specific flow scenario (happy path, error path, branch path). + Coverage checks: identifies flows with no corresponding MUnit test, identifies munit:test elements with + no assertions (tests that never fail), identifies missing connector mocks (tests that would make real + external calls), and identifies untested Choice router branches. + Use this tool after generating tests to confirm structural completeness before running Maven. + This tool is read-only. + """); + toolInfo.setInputSchema(MuleToolInputs.munitValidationSchema()); + return toolInfo; + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path projectPath = MuleToolInputs.existingDirectory(input.get(MuleToolInputs.PROJECT_PATH)); + if (projectPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("projectPath must be an absolute path to an existing Mule project folder."); + } else { + result.setStatus(ToolInvocationStatus.success); + result.addContent(MuleProjectAnalyzer.munitValidationResponse(projectPath, + MuleToolInputs.optionalString(input.get(MuleToolInputs.FLOW_NAME)), + MuleToolInputs.optionalPath(input.get(MuleToolInputs.MUNIT_PATH))).toJson()); + } + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to validate MUnit suites: " + e.getMessage()); + } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunMuleMavenTestsTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunMuleMavenTestsTool.java new file mode 100644 index 00000000..15aa228a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunMuleMavenTestsTool.java @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.Nullable; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConfirmationMessages; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchema; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchemaPropertyValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.chat.ChatView; + +/** + * Runs Maven validation for Mule projects. + */ +public class RunMuleMavenTestsTool extends BaseTool { + private static final String TOOL_NAME = "run_mule_maven_tests"; + private static final String PROJECT_PATH = "projectPath"; + private static final String GOALS = "goals"; + private static final String MAX_OUTPUT_CHARS = "maxOutputChars"; + private static final String MAVEN_PROFILE = "mavenProfile"; + private static final int DEFAULT_MAX_OUTPUT_CHARS = 12000; + private static final Duration TIMEOUT = Duration.ofMinutes(10); + + /** + * Creates a Mule Maven validation tool. + */ + public RunMuleMavenTestsTool() { + this.name = TOOL_NAME; + } + + @Override + public LanguageModelToolInformation getToolInformation() { + LanguageModelToolInformation toolInfo = super.getToolInformation(); + toolInfo.setName(TOOL_NAME); + toolInfo.setDisplayDescription("Run Maven or MUnit validation for a Mule project"); + toolInfo.setDescription(""" + Run Maven validation for a MuleSoft project and return command output. + Use this after generating or modifying Mule XML, DataWeave, RAML/OpenAPI, or MUnit tests. + Default goal is "test" which runs the full Maven test lifecycle including MUnit suites. + MUnit-specific flags: pass "-Dmunit.test=.xml" in goals to run a single suite, + or "-DskipMunitTests=false" to force MUnit execution when tests are skipped by profile. + Multi-module projects: add "-pl " to the goals array to target a specific module. + Use mavenProfile to activate an environment-specific Maven profile (e.g., "dev", "test"). + """); + InputSchema inputSchema = new InputSchema(); + inputSchema.setType("object"); + Map properties = new LinkedHashMap<>(); + properties.put(PROJECT_PATH, new InputSchemaPropertyValue("string", "Absolute path to the Mule project folder")); + InputSchemaPropertyValue goals = new InputSchemaPropertyValue("array", + "Maven goals and arguments, e.g. [\"test\"] or [\"-Dmunit.test=mySuite.xml\", \"test\"]"); + goals.setItems(new InputSchemaPropertyValue("string", "A Maven goal or argument flag")); + properties.put(GOALS, goals); + properties.put(MAVEN_PROFILE, + new InputSchemaPropertyValue("string", "Optional Maven profile to activate with -P, e.g. dev or test")); + properties.put(MAX_OUTPUT_CHARS, new InputSchemaPropertyValue("number", "Maximum output characters to return")); + inputSchema.setProperties(properties); + inputSchema.setRequired(List.of(PROJECT_PATH)); + toolInfo.setInputSchema(inputSchema); + return toolInfo; + } + + @Override + public boolean needConfirmation() { + return true; + } + + @Override + public ConfirmationMessages getConfirmationMessages() { + return new ConfirmationMessages("Run Mule Maven validation", + "Copilot wants to run Maven in a Mule project. Review the project path and goals before continuing."); + } + + @Override + public CompletableFuture invoke(Map input, ChatView chatView) { + return CompletableFuture.supplyAsync(() -> { + LanguageModelToolResult result = new LanguageModelToolResult(); + try { + Path projectPath = getProjectPath(input.get(PROJECT_PATH)); + if (projectPath == null) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("projectPath must be an absolute path to an existing Mule project folder."); + return new LanguageModelToolResult[] { result }; + } + + List command = buildCommand(projectPath, input.get(GOALS), input.get(MAVEN_PROFILE)); + ProcessResult processResult = run(projectPath, command, getMaxOutputChars(input.get(MAX_OUTPUT_CHARS))); + result.setStatus(processResult.exitCode == 0 ? ToolInvocationStatus.success : ToolInvocationStatus.error); + result.addContent(processResult.render(command)); + } catch (Exception e) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed to run Mule Maven validation: " + e.getMessage()); + } + return new LanguageModelToolResult[] { result }; + }); + } + + private ProcessResult run(Path projectPath, List command, int maxOutputChars) throws Exception { + ProcessBuilder builder = new ProcessBuilder(command); + builder.directory(projectPath.toFile()); + builder.redirectErrorStream(true); + Process process = builder.start(); + StringBuilder output = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (output.length() < maxOutputChars) { + output.append(line).append(System.lineSeparator()); + } + } + } + if (!process.waitFor(TIMEOUT.toSeconds(), TimeUnit.SECONDS)) { + process.destroyForcibly(); + return new ProcessResult(-1, "Timed out after " + TIMEOUT.toMinutes() + " minutes."); + } + return new ProcessResult(process.exitValue(), output.toString()); + } + + private List buildCommand(Path projectPath, Object goalsInput, Object profileInput) { + List command = new ArrayList<>(); + command.add(findMavenExecutable(projectPath)); + List goals = parseGoals(goalsInput); + command.addAll(goals.isEmpty() ? List.of("test") : goals); + if (profileInput instanceof String profile && !profile.isBlank()) { + command.add("-P"); + command.add(profile.trim()); + } + return command; + } + + private List parseGoals(Object goalsInput) { + if (goalsInput instanceof List values && values.stream().allMatch(String.class::isInstance)) { + return values.stream().map(String.class::cast).filter(value -> !value.isBlank()).toList(); + } + if (goalsInput instanceof String value && !value.isBlank()) { + return Arrays.asList(value.trim().split("\\s+")); + } + return List.of(); + } + + private String findMavenExecutable(Path projectPath) { + boolean windows = System.getProperty("os.name", "").toLowerCase().contains("win"); + Path wrapper = projectPath.resolve(windows ? "mvnw.cmd" : "mvnw"); + if (Files.isRegularFile(wrapper)) { + return wrapper.toFile().getAbsolutePath(); + } + return windows ? "mvn.cmd" : "mvn"; + } + + private int getMaxOutputChars(Object value) { + if (value instanceof Number number) { + return Math.max(1000, number.intValue()); + } + return DEFAULT_MAX_OUTPUT_CHARS; + } + + @Nullable + private static Path getProjectPath(Object value) { + if (!(value instanceof String pathString) || pathString.isBlank()) { + return null; + } + Path path = Path.of(pathString).toAbsolutePath().normalize(); + return Files.isDirectory(path) ? path : null; + } + + private record ProcessResult(int exitCode, String output) { + private String render(List command) { + return "Command: " + String.join(" ", command) + System.lineSeparator() + "Exit code: " + exitCode + + System.lineSeparator() + output; + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java index c7619f7f..7176e9e2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java @@ -4,9 +4,7 @@ package com.microsoft.copilot.eclipse.ui.chat.tools; import java.io.IOException; -import java.util.List; -import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.CoreException; /** @@ -18,12 +16,7 @@ public interface WorkingSetHandler { * * @param file the file to keep changes for */ - void onKeepChange(IFile file) throws IOException, CoreException; - - /** - * Handles the action of keeping all changes to files. - */ - void onKeepAllChanges(List files) throws IOException, CoreException; + void onKeepChange(ChangedFile file) throws IOException, CoreException; /** * Handles the action of undoing changes to a file. @@ -33,22 +26,14 @@ public interface WorkingSetHandler { * @throws CoreException if an error occurs during the undo operation, such as a failure to delete a file * @throws IOException if an error occurs while writing to the file */ - void onUndoChange(IFile file) throws CoreException, IOException; - - /** - * Handles the action of undoing all changes to files. - * - * @throws CoreException if error occurs during the undo all operation, such as a failure to delete a file - * @throws IOException if an error occurs while writing to the file - */ - void onUndoAllChanges(List files) throws CoreException, IOException; + void onUndoChange(ChangedFile file) throws CoreException, IOException; /** * Handles the action of viewing the diff of a file. * * @param file the file to view the diff for */ - void onViewDiff(IFile file); + void onViewDiff(ChangedFile file); /** * Handles the action of click done button to resolve all changes. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/completion/DataWeaveContentAssistProcessor.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/completion/DataWeaveContentAssistProcessor.java new file mode 100644 index 00000000..0a84727d --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/completion/DataWeaveContentAssistProcessor.java @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.completion; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.core.resources.IFile; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.ITextViewer; +import org.eclipse.jface.text.contentassist.CompletionProposal; +import org.eclipse.jface.text.contentassist.ICompletionProposal; +import org.eclipse.jface.text.contentassist.IContentAssistProcessor; +import org.eclipse.jface.text.contentassist.IContextInformation; +import org.eclipse.jface.text.contentassist.IContextInformationValidator; +import org.eclipse.lsp4j.Position; + +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.completion.CompletionListener; +import com.microsoft.copilot.eclipse.core.completion.CompletionProvider; +import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompletionItem; +import com.microsoft.copilot.eclipse.core.utils.FileUtils; +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; + +/** + * Content assist processor that delivers Copilot inline completions inside DataWeave (.dwl) editors. + * + *

Eclipse registers this processor via the {@code org.eclipse.ui.workbench.texteditor.contentAssist} + * extension point for the {@code com.microsoft.copilot.eclipse.ui.dataweaveFile} content type. When the + * user triggers content assist (Ctrl+Space) in a .dwl editor, Eclipse calls + * {@link #computeCompletionProposals}, which fires a Copilot LSP completion request and waits briefly for + * the result before returning proposals to the standard Eclipse content assist popup. + */ +public class DataWeaveContentAssistProcessor implements IContentAssistProcessor { + + private static final long COMPLETION_WAIT_MS = 3_000; + + @Override + public ICompletionProposal[] computeCompletionProposals(ITextViewer viewer, int offset) { + IDocument document = viewer.getDocument(); + if (document == null) { + return new ICompletionProposal[0]; + } + + IFile file = getFileForDocument(document); + if (file == null) { + // Unable to determine the file for this document + return new ICompletionProposal[0]; + } + + Position position = toPosition(document, offset); + if (position == null) { + return new ICompletionProposal[0]; + } + + // Ensure the document is connected to the Copilot LSP. EditorLifecycleListener handles this for + // standard ITextEditor parts, but the DataWeave embedded editor is a custom SWT widget that does + // not adapt to ITextEditor, so connectDocumentIfNecessary skips it. We connect here explicitly + // using the IDocument we already have from the viewer. + CopilotLanguageServerConnection lsConnection = CopilotCore.getPlugin().getCopilotLanguageServer(); + if (lsConnection != null) { + lsConnection.connectDocument(document, file); + } + + CompletionProvider provider = CopilotCore.getPlugin().getCompletionProvider(); + if (provider == null) { + return new ICompletionProposal[0]; + } + + String fileUri = FileUtils.getResourceUri(file); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference> resultRef = new AtomicReference<>(); + + CompletionListener listener = new CompletionListener() { + @Override + public void onCompletionResolved(String uriString, List completions) { + if (fileUri != null && fileUri.equals(uriString)) { + resultRef.set(completions); + latch.countDown(); + } + } + }; + + provider.addCompletionListener(listener); + try { + int documentVersion = document.hashCode(); + provider.triggerCompletion(file, position, documentVersion, false); + latch.await(COMPLETION_WAIT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + provider.removeCompletionListener(listener); + } + + List items = resultRef.get(); + if (items == null || items.isEmpty()) { + return new ICompletionProposal[0]; + } + + return toProposals(items, offset); + } + + @Override + public IContextInformation[] computeContextInformation(ITextViewer viewer, int offset) { + return null; + } + + @Override + public char[] getCompletionProposalAutoActivationCharacters() { + return null; + } + + @Override + public char[] getContextInformationAutoActivationCharacters() { + return null; + } + + @Override + public String getErrorMessage() { + return null; + } + + @Override + public IContextInformationValidator getContextInformationValidator() { + return null; + } + + private IFile getFileForDocument(IDocument document) { + // Try to get the file from the active editor + IFile file = UiUtils.getCurrentFile(); + if (file != null) { + return file; + } + + // Fallback: look for an open .dwl file. This helps support custom editors + // that don't integrate fully with Eclipse's editor infrastructure. + try { + List openFiles = UiUtils.getOpenedFiles(); + for (IFile openFile : openFiles) { + if ("dwl".equals(openFile.getFileExtension()) && openFile.exists()) { + return openFile; + } + } + } catch (Exception e) { + // Ignore and continue + } + + return null; + } + + private Position toPosition(IDocument document, int offset) { + try { + int line = document.getLineOfOffset(offset); + int lineStart = document.getLineOffset(line); + return new Position(line, offset - lineStart); + } catch (Exception e) { + return null; + } + } + + private ICompletionProposal[] toProposals(List items, int offset) { + List proposals = new ArrayList<>(); + for (CompletionItem item : items) { + String insertText = item.getText(); + if (insertText == null || insertText.isBlank()) { + continue; + } + // Use the displayText as the proposal label, falling back to the insert text itself + String displayText = item.getDisplayText(); + String label = (displayText != null && !displayText.isBlank()) ? displayText : insertText; + // Replace from the start of the current token (offset) with the full completion text + proposals.add(new CompletionProposal(insertText, offset, 0, insertText.length(), null, label, null, null)); + } + return proposals.toArray(new ICompletionProposal[0]); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java index 1aef96fc..f8ecd839 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java @@ -224,6 +224,7 @@ public final class Messages extends NLS { public static String context_window_messages; public static String context_window_files; public static String context_window_tool_results; + public static String chat_compacting_conversation; public static String chat_rateLimitBanner_getMoreInfo; public static String chat_rateLimitBanner_closeTooltip; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties index 4cb8235e..16e4f4f1 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties @@ -218,6 +218,7 @@ context_window_user_context=User Context context_window_messages=Messages context_window_files=Attached Files context_window_tool_results=Tool Results +chat_compacting_conversation=Compacting conversation... chat_rateLimitBanner_getMoreInfo=Get more info chat_rateLimitBanner_closeTooltip=Dismiss diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddApiKeyDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddApiKeyDialog.java index aa6f8eaa..b130799d 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddApiKeyDialog.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddApiKeyDialog.java @@ -91,6 +91,7 @@ protected Control createDialogArea(Composite parent) { apiKeyText.setEchoChar('*'); apiKeyText.setText(apiKey != null ? apiKey : ""); apiKeyText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + PreferencePageUtils.styleTextInput(apiKeyText); apiKeyText.addModifyListener(this::onFieldChanged); eyeOpenImg = UiUtils diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddByokModelDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddByokModelDialog.java index 4d20ddf6..858a41cd 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddByokModelDialog.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddByokModelDialog.java @@ -82,6 +82,7 @@ protected Control createDialogArea(Composite parent) { new Label(container, SWT.NONE).setText(Messages.preferences_page_byok_addModel_modelId); modelIdText = new Text(container, SWT.BORDER); modelIdText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + PreferencePageUtils.styleTextInput(modelIdText); modelIdText.addModifyListener(this::onFieldChanged); // Provider-specific fields @@ -93,6 +94,7 @@ protected Control createDialogArea(Composite parent) { new Label(container, SWT.NONE).setText(Messages.preferences_page_byok_addModel_displayName); displayNameText = new Text(container, SWT.BORDER); displayNameText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + PreferencePageUtils.styleTextInput(displayNameText); // Capabilities Composite caps = new Composite(container, SWT.NONE); @@ -119,6 +121,7 @@ private void createAzureSpecificFields(Composite container) { new Label(container, SWT.NONE).setText(Messages.preferences_page_byok_addModel_deploymentUrl); deploymentUrlText = new Text(container, SWT.BORDER); deploymentUrlText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + PreferencePageUtils.styleTextInput(deploymentUrlText); deploymentUrlText.addModifyListener(this::onFieldChanged); // API Key * @@ -133,6 +136,7 @@ private void createAzureSpecificFields(Composite container) { apiKeyText = new Text(apiKeyRow, SWT.BORDER); apiKeyText.setEchoChar('*'); apiKeyText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + PreferencePageUtils.styleTextInput(apiKeyText); apiKeyText.addModifyListener(this::onFieldChanged); eyeOpenImg = UiUtils diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java index 11a791ef..15059719 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java @@ -60,6 +60,24 @@ public void createFieldEditors() { addNote(parent, Messages.preferences_page_watched_files_note_content); addSeparator(parent); + Composite consoleContextComposite = createSectionComposite(parent, gdf); + BooleanFieldEditor consoleContextField = new BooleanFieldEditor(Constants.CONSOLE_CONTEXT_ENABLED, + Messages.preferences_page_console_context, SWT.WRAP, consoleContextComposite); + applyFieldWidthHint(consoleContextField, consoleContextComposite); + addField(consoleContextField); + + addNote(parent, Messages.preferences_page_console_context_note_content); + addSeparator(parent); + + Composite transformContextComposite = createSectionComposite(parent, gdf); + BooleanFieldEditor transformContextField = new BooleanFieldEditor(Constants.TRANSFORM_CONTEXT_ENABLED, + Messages.preferences_page_transform_context, SWT.WRAP, transformContextComposite); + applyFieldWidthHint(transformContextField, transformContextComposite); + addField(transformContextField); + + addNote(parent, Messages.preferences_page_transform_context_note_content); + addSeparator(parent); + // Add sub-agent toggle Composite subAgentComposite = createSectionComposite(parent, gdf); boolean policyAllowsSubAgent = isPolicyAllowsSubAgent(); @@ -103,6 +121,7 @@ public void createFieldEditors() { Messages.preferences_page_agent_max_requests, agentMaxRequestsComposite); agentMaxRequestsField.setValidRange(1, 500); agentMaxRequestsField.setErrorMessage(Messages.preferences_page_agent_max_requests_validation_error); + PreferencePageUtils.styleTextInput(agentMaxRequestsField.getTextControl(agentMaxRequestsComposite)); addField(agentMaxRequestsField); addNote(parent, Messages.preferences_page_agent_max_requests_desc); @@ -255,4 +274,4 @@ private void updateSubAgentToolConfiguration(boolean subAgentEnabled) { CopilotCore.LOGGER.error("Failed to update sub-agent tool configuration", e); } } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java index 6b43717c..4bbef5b6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java @@ -30,7 +30,9 @@ public void initializeDefaultPreferences() { pref.setDefault(Constants.ENABLE_STRICT_SSL, true); pref.setDefault(Constants.PROXY_KERBEROS_SP, ""); pref.setDefault(Constants.GITHUB_ENTERPRISE, ""); - pref.setDefault(Constants.WORKSPACE_CONTEXT_ENABLED, false); + pref.setDefault(Constants.WORKSPACE_CONTEXT_ENABLED, true); + pref.setDefault(Constants.CONSOLE_CONTEXT_ENABLED, true); + pref.setDefault(Constants.TRANSFORM_CONTEXT_ENABLED, false); pref.setDefault(Constants.SUB_AGENT_ENABLED, true); pref.setDefault(Constants.AGENT_MAX_REQUESTS, 25); pref.setDefault(Constants.ENABLE_SKILLS, true); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java index 437d3aba..98683a08 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java @@ -22,7 +22,6 @@ import org.eclipse.jface.text.ITextViewer; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.StyledText; -import org.eclipse.swt.events.ControlAdapter; import org.eclipse.swt.events.SelectionListener; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.layout.GridData; @@ -218,6 +217,7 @@ private void createWorkspaceInstructionsField(Composite parent, GridLayout gl) { // disable the label of the input field, so that the input box can be positioned at the beginning // of the container. workspaceInstrField.getLabelControl(workspaceInstrFieldContainer).dispose(); + PreferencePageUtils.styleTextInput(workspaceInstrField.getTextControl(workspaceInstrFieldContainer)); addField(workspaceInstrField); // Add note using WrappableNoteLabel @@ -269,18 +269,7 @@ private void createProjectInstructionsField(Composite parent, GridLayout gl) { // Set the file location column to take remaining width (table width - project name column width) fileLocationColumn.setWidth(tableGridData.widthHint > 0 ? tableGridData.widthHint - 150 : 400); - // Add resize listener to make the file location column take remaining width - table.addControlListener(new ControlAdapter() { - @Override - public void controlResized(org.eclipse.swt.events.ControlEvent e) { - int tableWidth = table.getClientArea().width; - int projectNameWidth = projectNameColumn.getWidth(); - int remainingWidth = tableWidth - projectNameWidth; - if (remainingWidth > 100) { // Minimum width for file location column - fileLocationColumn.setWidth(remainingWidth); - } - } - }); + SwtUtils.resizeColumnToFillTable(table, fileLocationColumn, 100, projectNameColumn); // Populate table with actual workspace projects populateProjectTable(table); @@ -333,6 +322,7 @@ private void createGitCommitInstructionsField(Composite parent, GridLayout gl) { // disable the label of the input field, so that the input box can be positioned at the beginning // of the container. gitCommitInstrField.getLabelControl(gitCommitInstrFieldContainer).dispose(); + PreferencePageUtils.styleTextInput(gitCommitInstrField.getTextControl(gitCommitInstrFieldContainer)); addField(gitCommitInstrField); // Add note using WrappableNoteLabel diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomModesPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomModesPreferencePage.java index 49657b8e..22f13ef4 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomModesPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomModesPreferencePage.java @@ -306,6 +306,7 @@ protected Control createDialogArea(Composite parent) { GridData nameData = new GridData(SWT.FILL, SWT.CENTER, true, false); nameData.widthHint = 300; nameText.setLayoutData(nameData); + PreferencePageUtils.styleTextInput(nameText); // Only show folder selection if there are multiple folders if (folders.size() > 1) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java index 93bc9d21..c299d40a 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java @@ -112,13 +112,14 @@ private void createContents() { private void createTable(Composite parent) { tableViewer = new TableViewer(parent, - SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE); + SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE | SWT.V_SCROLL); Table table = tableViewer.getTable(); GridData tableData = new GridData(SWT.FILL, SWT.FILL, true, false); tableData.heightHint = TABLE_HEIGHT_HINT; table.setLayoutData(tableData); table.setHeaderVisible(true); table.setLinesVisible(true); + SwtUtils.forwardVerticalMouseWheelToParentScrollerAtBoundary(table); TableViewerColumn patternCol = new TableViewerColumn(tableViewer, SWT.NONE); @@ -177,6 +178,8 @@ public Color getForeground(Object element) { ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; } }); + SwtUtils.resizeColumnToFillTable(table, statusCol.getColumn(), 100, + patternCol.getColumn(), descCol.getColumn()); tableViewer.setContentProvider(ArrayContentProvider.getInstance()); tableViewer.addSelectionChangedListener(e -> updateButtonState()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GeneralPreferencesPage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GeneralPreferencesPage.java index 6b7da3e3..2d46bbcd 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GeneralPreferencesPage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GeneralPreferencesPage.java @@ -84,6 +84,7 @@ public void createFieldEditors() { StringFieldEditor sftGhe = new StringFieldEditor(Constants.GITHUB_ENTERPRISE, Messages.preferences_page_github_enterprise, ctnGhe); sftGhe.getLabelControl(ctnGhe).setToolTipText(Messages.preferences_page_github_enterprise_tooltip); + PreferencePageUtils.styleTextInput(sftGhe.getTextControl(ctnGhe)); addField(sftGhe); // What's new group @@ -131,4 +132,4 @@ public boolean performOk() { return result; } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java index e3b78dad..32f53bd7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java @@ -92,6 +92,8 @@ public LanguageServerSettingManager(CopilotLanguageServerConnection conn, IProxy .setAgentMaxRequests(preferenceStore.getInt(Constants.AGENT_MAX_REQUESTS)); getSettings().getGithubSettings().getCopilotSettings().getAgent() .setEnableSkills(PreferencesUtils.isSkillsEnabled()); + getSettings().getGithubSettings().getCopilotSettings().getAgent() + .setAutoCompress(true); // Set transcript directory for CLS session persistence and restoration getSettings().getGithubSettings().getCopilotSettings().getAgent() @@ -125,6 +127,8 @@ public LanguageServerSettingManager(CopilotLanguageServerConnection conn, IProxy syncMcpRegistrationConfiguration(); } }); + eventBroker.subscribe(CopilotEventConstants.TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED, + event -> syncMcpRegistrationConfiguration((String) event.getProperty(IEventBroker.DATA))); } /** @@ -162,7 +166,7 @@ public void propertyChange(PropertyChangeEvent event) { settings.getGithubEnterprise().setUri(preferenceStore.getString(Constants.GITHUB_ENTERPRISE)); singleSetting = new CopilotLanguageServerSettings(null, null, settings.getGithubEnterprise(), null); break; - case Constants.MCP, Constants.MCP_EXTENSION_POINT_CONTRIB: + case Constants.MCP: syncMcpRegistrationConfiguration(); return; case Constants.MCP_TOOLS_STATUS: @@ -253,13 +257,40 @@ private void updateGithubPanicErrorReport() { * Sync MCP registration from both extension points and preference store. */ public void syncMcpRegistrationConfiguration() { + String approvedExtMcpServers = null; + if (CopilotCore.getPlugin().getFeatureFlags().isMcpContributionPointEnabled()) { + // Defensive null-chain: callers from very early startup paths (e.g. CopilotUi.start()) may + // run before the ChatServiceManager singleton field is assigned. The extension-point flow + // delivers its own state via TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED, so missing it + // here just means the LSP gets the manual MCP state for now and the verified state arrives + // shortly after via that subscription. + var chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); + if (chatServiceManager != null) { + McpExtensionPointManager mgr = chatServiceManager.getMcpExtensionPointManager(); + if (mgr != null) { + approvedExtMcpServers = mgr.getApprovedExtMcpServers(); + } + } + } + syncMcpRegistrationConfiguration(approvedExtMcpServers); + } + + /** + * Sync MCP registration to the language server using the supplied extension-contributed approved + * servers JSON. Used by callers that already have the approved JSON in hand - notably the + * subscriber to {@link CopilotEventConstants#TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED} - + * so they do not need to traverse {@code CopilotUi.getChatServiceManager()} to look it up. + * + * @param approvedExtMcpServers JSON for the approved extension-contributed MCP servers, or + * {@code null} when none are approved or the contribution point is disabled. + */ + public void syncMcpRegistrationConfiguration(String approvedExtMcpServers) { // From manual configuration settings.setMcpServers(preferenceStore.getString(Constants.MCP)); // From McpRegistration extension point if (CopilotCore.getPlugin().getFeatureFlags().isMcpContributionPointEnabled()) { - McpExtensionPointManager mgr = CopilotUi.getPlugin().getChatServiceManager().getMcpExtensionPointManager(); - settings.addMcpServers(mgr.getApprovedExtMcpServers()); + settings.addMcpServers(approvedExtMcpServers); } syncSingleConfiguration(new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings())); @@ -318,6 +349,9 @@ private void syncFileOperationRulesToCls() { * Custom agent modes get their tool configuration from the LSP/file, not from preferences. */ public void initializeMcpToolsStatus() { + // Migrate old preferences if needed (strip plugin display name prefixes from server names) + migrateMcpToolsStatusIfNeeded(); + // Load per-mode tool status String savedModeToolsStatus = preferenceStore.getString(Constants.MCP_TOOLS_MODE_STATUS); @@ -418,7 +452,9 @@ private void updateMcpToolsStatus(String mcpToolsStatus, String modeId) { // This is an MCP server McpServerToolsStatusCollection serverToolsStatus = new McpServerToolsStatusCollection(); - serverToolsStatus.setName(serverName); + // Extract the simple server name (strip plugin prefix if present) + String simpleName = extractSimpleServerName(serverName); + serverToolsStatus.setName(simpleName); List toolStatusList = new ArrayList<>(); serverToolsStatus.setTools(toolStatusList); @@ -611,6 +647,87 @@ public void setAutoShowCompletion(boolean autoShowCompletion) { preferenceStore.setValue(Constants.AUTO_SHOW_COMPLETION, autoShowCompletion); } + /** + * Migrates old MCP tools status preferences to remove plugin display name prefixes from server names. + * This handles backward compatibility when server registration changed from prefixed names to simple names. + */ + private void migrateMcpToolsStatusIfNeeded() { + try { + // Migrate MCP_TOOLS_MODE_STATUS (per-mode tool status) + String savedModeToolsStatus = preferenceStore.getString(Constants.MCP_TOOLS_MODE_STATUS); + if (StringUtils.isNotBlank(savedModeToolsStatus) && savedModeToolsStatus.contains(": ")) { + Map>> modeToolStatus = GsonUtils.getDefault() + .fromJson(savedModeToolsStatus, new TypeToken>>>() { + }.getType()); + + boolean modified = false; + for (Map> modeTools : modeToolStatus.values()) { + // Create a new map with migrated server names + Map> migratedTools = new LinkedHashMap<>(); + for (Map.Entry> entry : modeTools.entrySet()) { + String simpleName = extractSimpleServerName(entry.getKey()); + if (!simpleName.equals(entry.getKey())) { + modified = true; + } + migratedTools.put(simpleName, entry.getValue()); + } + modeTools.clear(); + modeTools.putAll(migratedTools); + } + + if (modified) { + String migratedJson = GsonUtils.getDefault().toJson(modeToolStatus); + preferenceStore.setValue(Constants.MCP_TOOLS_MODE_STATUS, migratedJson); + } + } + + // Migrate MCP_TOOLS_STATUS (legacy agent mode tool status) + String savedMcpToolsStatus = preferenceStore.getString(Constants.MCP_TOOLS_STATUS); + if (StringUtils.isNotBlank(savedMcpToolsStatus) && savedMcpToolsStatus.contains(": ")) { + Map> toolStatusMap = GsonUtils.getDefault() + .fromJson(savedMcpToolsStatus, new TypeToken>>() { + }.getType()); + + // Create a new map with migrated server names + Map> migratedTools = new LinkedHashMap<>(); + boolean modified = false; + for (Map.Entry> entry : toolStatusMap.entrySet()) { + String simpleName = extractSimpleServerName(entry.getKey()); + if (!simpleName.equals(entry.getKey())) { + modified = true; + } + migratedTools.put(simpleName, entry.getValue()); + } + + if (modified) { + String migratedJson = GsonUtils.getDefault().toJson(migratedTools); + preferenceStore.setValue(Constants.MCP_TOOLS_STATUS, migratedJson); + } + } + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to migrate MCP tools status preferences", e); + } + } + + /** + * Extracts the simple server name from a potentially prefixed display name. + * If the server name contains ": " (plugin display name prefix), returns the part after it. + * Otherwise, returns the server name as-is. + * + * @param displayName the display name that may include a plugin prefix + * @return the simple server name without the plugin prefix + */ + private String extractSimpleServerName(String displayName) { + if (StringUtils.isBlank(displayName)) { + return displayName; + } + int colonIndex = displayName.indexOf(": "); + if (colonIndex > 0) { + return displayName.substring(colonIndex + 2); + } + return displayName; + } + /** * Disposes the resources of this LanguageServerSettingManager. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java index bc52d63e..357234b4 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java @@ -90,10 +90,11 @@ private void createContents() { // Tree viewer for server/tool approval treeViewer = new CheckboxTreeViewer(group, - SWT.BORDER | SWT.FULL_SELECTION); + SWT.BORDER | SWT.FULL_SELECTION | SWT.V_SCROLL); GridData treeData = new GridData(SWT.FILL, SWT.FILL, true, false); treeData.heightHint = TREE_HEIGHT_HINT; treeViewer.getTree().setLayoutData(treeData); + SwtUtils.forwardVerticalMouseWheelToParentScrollerAtBoundary(treeViewer.getTree()); treeViewer.setContentProvider(new McpTreeContentProvider()); treeViewer.setLabelProvider(new McpTreeLabelProvider()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java index a3682cf9..56af5132 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java @@ -214,6 +214,7 @@ protected void doFillIntoGrid(Composite parent, int numColumns) { }; mcpField.getLabelControl(mcpFieldContainer).setToolTipText(Messages.preferences_page_mcp_tooltip); + PreferencePageUtils.styleTextInput(mcpField.getTextControl(mcpFieldContainer)); // @formatter:off mcpField.getLabelControl(mcpFieldContainer).setLayoutData(new GridData( SWT.LEFT, @@ -313,6 +314,7 @@ protected void doLoadDefault() { updateRegistryButtonState(); } }; + PreferencePageUtils.styleTextInput(mcpRegistryField.getTextControl(mcpRegistryFieldContainer)); addField(mcpRegistryField); // Add Open MCP Registry button @@ -431,11 +433,7 @@ private void createExtMcpRegistrationArea(Composite parent) { extMcpButton.addSelectionListener(new SelectionAdapter() { @Override public void widgetSelected(SelectionEvent e) { - String res = CopilotUi.getPlugin().getChatServiceManager().getMcpExtensionPointManager() - .approveExtMcpRegistration(); - if (StringUtils.isNotBlank(res)) { - CopilotUi.getPlugin().getLanguageServerSettingManager().syncMcpRegistrationConfiguration(); - } + CopilotUi.getPlugin().getChatServiceManager().getMcpExtensionPointManager().approveExtMcpRegistration(); } }); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java index 7582fd7a..ecad06d9 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java @@ -57,6 +57,10 @@ public class Messages extends NLS { public static String preferences_page_github_enterprise; public static String preferences_page_watched_files; public static String preferences_page_watched_files_note_content; + public static String preferences_page_console_context; + public static String preferences_page_console_context_note_content; + public static String preferences_page_transform_context; + public static String preferences_page_transform_context_note_content; public static String preferences_page_restart_question; public static String preferences_page_sub_agent; public static String preferences_page_sub_agent_note_content; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java index ab676ebf..e8a7f3e7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java @@ -5,25 +5,32 @@ import java.net.MalformedURLException; import java.net.URL; +import java.util.List; import java.util.function.Consumer; import org.eclipse.swt.SWT; import org.eclipse.swt.events.SelectionAdapter; import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Color; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Link; import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; import org.eclipse.ui.PartInitException; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.dialogs.PreferencesUtil; import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** * Utility class for Copilot preference pages. */ public final class PreferencePageUtils { + private static final String TEXT_INPUT_CSS_CLASS = "copilot-preference-text-input"; // Private constructor to prevent instantiation private PreferencePageUtils() { @@ -68,6 +75,7 @@ private static void createLink(Composite composite, String label, String tooltip link.setText(label); link.setToolTipText(tooltip); link.setLayoutData(new GridData(SWT.FILL, SWT.BEGINNING, true, false, 2, 1)); + inheritParentBackground(link); link.addSelectionListener(new SelectionAdapter() { @Override public void widgetSelected(SelectionEvent e) { @@ -99,4 +107,86 @@ private static void openUrlInBrowser(SelectionEvent event) { private static void openPreferencePage(Shell shell, String preferenceId, SelectionEvent event) { PreferencesUtil.createPreferenceDialogOn(shell, preferenceId, null, event); } -} \ No newline at end of file + + /** + * Applies a control's parent background to layout-only preference controls and their children. + * + * @param control the control whose background should match its parent + */ + public static void inheritParentBackground(Control control) { + if (control == null || control.isDisposed() || control.getParent() == null + || control.getParent().isDisposed()) { + return; + } + + applyBackground(control, control.getParent().getBackground()); + } + + /** + * Applies readable dark-mode colors to editable text inputs in Copilot preference surfaces. + * + * @param text the text input to style + */ + public static void styleTextInput(Text text) { + if (text == null || text.isDisposed() || !UiUtils.isDarkTheme()) { + return; + } + + appendCssClass(text, TEXT_INPUT_CSS_CLASS); + Color background = CssConstants.getChatBackgroundColor(text.getDisplay()); + Color foreground = CssConstants.getChatForegroundColor(text.getDisplay()); + applyTextInputColors(text, background, foreground); + text.getDisplay().asyncExec(() -> applyTextInputColors(text, background, foreground)); + text.addListener(SWT.Settings, e -> applyTextInputColors(text, background, foreground)); + text.addListener(SWT.FocusIn, e -> applyTextInputColors(text, background, foreground)); + text.addListener(SWT.FocusOut, e -> applyTextInputColors(text, background, foreground)); + text.addListener(SWT.Modify, e -> applyTextInputColors(text, background, foreground)); + text.addDisposeListener(e -> { + disposeColor(background); + disposeColor(foreground); + }); + } + + private static void applyTextInputColors(Text text, Color background, Color foreground) { + if (text == null || text.isDisposed() || background == null || background.isDisposed() + || foreground == null || foreground.isDisposed()) { + return; + } + + text.setBackground(background); + text.setForeground(foreground); + text.redraw(); + } + + private static void appendCssClass(Control control, String className) { + Object currentClassNames = control.getData(CssConstants.CSS_CLASS_NAME_KEY); + if (currentClassNames instanceof String names && !names.isBlank()) { + if (!List.of(names.split("\\s+")).contains(className)) { + control.setData(CssConstants.CSS_CLASS_NAME_KEY, names + " " + className); + } + return; + } + + control.setData(CssConstants.CSS_CLASS_NAME_KEY, className); + } + + private static void applyBackground(Control control, Color background) { + if (control == null || control.isDisposed() || background == null || background.isDisposed()) { + return; + } + + control.setBackground(background); + if (control instanceof Composite composite) { + composite.setBackgroundMode(SWT.INHERIT_FORCE); + for (Control child : composite.getChildren()) { + applyBackground(child, background); + } + } + } + + private static void disposeColor(Color color) { + if (color != null && !color.isDisposed()) { + color.dispose(); + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java index db41cb10..46664360 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java @@ -30,6 +30,7 @@ import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; import com.microsoft.copilot.eclipse.ui.chat.confirmation.TerminalConfirmationHandler; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; /** * Terminal auto-approve section with a rule table, action buttons, and @@ -90,13 +91,14 @@ private void createContents() { private void createTable(Composite parent) { tableViewer = new TableViewer(parent, - SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE); + SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE | SWT.V_SCROLL); Table table = tableViewer.getTable(); GridData tableData = new GridData(SWT.FILL, SWT.FILL, true, false); tableData.heightHint = TABLE_HEIGHT_HINT; table.setLayoutData(tableData); table.setHeaderVisible(true); table.setLinesVisible(true); + SwtUtils.forwardVerticalMouseWheelToParentScrollerAtBoundary(table); TableViewerColumn commandCol = new TableViewerColumn(tableViewer, SWT.NONE); @@ -123,6 +125,8 @@ public String getText(Object element) { : Messages.preferences_page_auto_approve_deny; } }); + SwtUtils.resizeColumnToFillTable(table, statusCol.getColumn(), 100, + commandCol.getColumn()); tableViewer.setContentProvider(ArrayContentProvider.getInstance()); tableViewer.addSelectionChangedListener(e -> updateButtonState()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/WrappableIconLink.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/WrappableIconLink.java index 591f0617..2b4280ee 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/WrappableIconLink.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/WrappableIconLink.java @@ -137,13 +137,18 @@ public void widgetSelected(SelectionEvent e) { icon.dispose(); } }); + + PreferencePageUtils.inheritParentBackground(this); } /** * Sets up the resize listener to dynamically adjust the link width. */ private void setupResizeListener() { - parent.addControlListener(ControlListener.controlResizedAdapter(e -> updateLinkWidth())); + parent.addControlListener(ControlListener.controlResizedAdapter(e -> { + PreferencePageUtils.inheritParentBackground(this); + updateLinkWidth(); + })); } /** @@ -163,4 +168,4 @@ private void updateLinkWidth() { parent.requestLayout(); } } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/WrappableNoteLabel.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/WrappableNoteLabel.java index ce4bd16a..55ae279b 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/WrappableNoteLabel.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/WrappableNoteLabel.java @@ -96,13 +96,18 @@ private void createControls() { boldFont.dispose(); } }); + + PreferencePageUtils.inheritParentBackground(this); } /** * Sets up the resize listener to dynamically adjust the content label width. */ private void setupResizeListener() { - parentToWatch.addControlListener(ControlListener.controlResizedAdapter(e -> updateContentLabelWidth())); + parentToWatch.addControlListener(ControlListener.controlResizedAdapter(e -> { + PreferencePageUtils.inheritParentBackground(this); + updateContentLabelWidth(); + })); } /** @@ -182,4 +187,4 @@ public Label getPrefixLabel() { public Label getContentLabel() { return contentLabel; } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties index f041db59..5e19c5d7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties @@ -92,10 +92,14 @@ preferences_page_custom_instructions_chat_load_scope_all=all projects in workspa preferences_page_custom_instructions_chat_load_scope_referenced=projects inferred from chat-attached files preferences_page_custom_instructions_chat_load_scope_combo_tooltip=Decide which of the custom instructions will be used in the Copilot chat. preferences_page_watched_files= Enable workspace context (experimental) +preferences_page_console_context= Enable console context (experimental) preferences_page_custom_instructions_git_commit= Git Commit Instructions preferences_page_custom_instructions_git_commit_desc=Set custom instructions for Copilot Chat when generating commit messages. preferences_page_custom_instructions_git_commit_note= Access this feature in the Git Staging view by clicking the Copilot icon. You can find this view in the Git perspective or add it via the 'Window' > 'Show View' menu. preferences_page_watched_files_note_content= Allow the use of @workspace in Ask Mode. Enabling this feature may affect startup performance. +preferences_page_console_context_note_content= Allow the use of @console in Ask, Agent, and Plan modes. Console output is attached only when @console starts the message. +preferences_page_transform_context= Enable transform context (experimental) +preferences_page_transform_context_note_content= Allow the use of @transform in Ask, Agent, and Plan modes. The active Mule XML editor's Transform Message elements are attached only when @transform starts the message. preferences_page_restart_question=You need to restart Eclipse to apply the changes. Would you like to restart now? preferences_page_sub_agent= Enable sub-agent preferences_page_sub_agent_note_content= Allow Copilot to use sub-agents for complex multi-step tasks. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/CssConstants.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/CssConstants.java index b8a6621c..e3804909 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/CssConstants.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/CssConstants.java @@ -43,6 +43,28 @@ public static Color getInputPlaceHolderColor(Display display) { return new Color(display, 128, 128, 128); } + /** + * Get the chat surface background color based on the current theme. + */ + public static Color getChatBackgroundColor(Display display) { + if (UiUtils.isDarkTheme()) { + return new Color(display, 47, 47, 47); + // return new Color(display, 222, 225, 229); + // return new Color(display, 30, 31, 34); + } + return new Color(display, 255, 255, 255); + } + + /** + * Get the chat surface text color based on the current theme. + */ + public static Color getChatForegroundColor(Display display) { + if (UiUtils.isDarkTheme()) { + return new Color(display, 255, 255, 255); + } + return new Color(display, 0, 0, 0); + } + /** * Get the border color for UI elements based on the current theme. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownButton.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownButton.java index 6799c4a0..3c709c10 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownButton.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownButton.java @@ -50,6 +50,7 @@ public class DropdownButton extends Composite { private List itemGroups; private String selectedItemId; private boolean mouseHover; + private boolean useParentBackground; /** * Creates a new dropdown button. @@ -149,6 +150,14 @@ public void setSelectionListener(Consumer listener) { popup.setSelectionListener(listener); } + /** + * Uses the parent background for the button face when not hovered. + */ + public void setUseParentBackground(boolean useParentBackground) { + this.useParentBackground = useParentBackground; + redraw(); + } + /** * Sets the accessibility name used by screen readers. * @@ -187,7 +196,7 @@ private void paintControl(GC gc) { Display display = getDisplay(); DropdownItem selected = findItemById(selectedItemId); Image selectedIcon = getSelectedItemIcon(selected); - Color bg = mouseHover ? CssConstants.getButtonFocusBgColor(display) : getBackground(); + Color bg = mouseHover ? CssConstants.getButtonFocusBgColor(display) : getButtonBackground(); gc.setBackground(bg); gc.fillRectangle(bounds); @@ -223,6 +232,13 @@ private String resolveDisplayText(DropdownItem selected) { return selectedLabel != null ? selectedLabel : selected.getLabel(); } + private Color getButtonBackground() { + if (useParentBackground && getParent() != null && !getParent().isDisposed()) { + return getParent().getBackground(); + } + return getBackground(); + } + private DropdownItem findItemById(String id) { if (id == null || itemGroups == null) { return null; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownPopup.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownPopup.java index 7b9dc2f0..e3910214 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownPopup.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownPopup.java @@ -154,6 +154,7 @@ void open(Point location, List groups, String selectedItemId, items.clear(); focusedIndex = -1; populateGroups(container, groups); + applyPopupBackgroundRecursively(container, popupBg); Point contentSize = container.computeSize(SWT.DEFAULT, SWT.DEFAULT); container.setSize(contentSize); @@ -506,6 +507,7 @@ private void openHoverShell(DropdownItem item, Composite anchorItem) { hoverContent.setLayout(contentLayout); item.getHoverProvider().configureHover(hoverContent, item, this::close); + applyPopupBackgroundRecursively(hoverContent, popupBg); final Color borderColor = CssConstants.getBorderColor(display); hoverShell.addPaintListener(e -> { @@ -710,6 +712,18 @@ private void styleControl(Control control) { } } + private void applyPopupBackgroundRecursively(Control control, Color background) { + if (control == null || control.isDisposed()) { + return; + } + control.setBackground(background); + if (control instanceof Composite composite) { + for (Control child : composite.getChildren()) { + applyPopupBackgroundRecursively(child, background); + } + } + } + private void setCssClassOnly(Control control, String className) { if (stylingEngine != null) { stylingEngine.setClassname(control, className); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ItemController.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ItemController.java index 92726b19..7906b660 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ItemController.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ItemController.java @@ -8,6 +8,7 @@ import org.eclipse.swt.events.MouseEvent; import org.eclipse.swt.events.MouseTrackAdapter; import org.eclipse.swt.events.MouseTrackListener; +import org.eclipse.swt.graphics.Color; import org.eclipse.swt.graphics.GC; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.graphics.Rectangle; @@ -55,6 +56,7 @@ public final class ItemController { private final IStylingEngine styling; private String baseCssId; private boolean focused; + private Color focusedBackground; private ItemController(Composite row, IStylingEngine styling, String baseCssId) { this.row = row; @@ -76,6 +78,7 @@ public static ItemController attach(Composite row, IStylingEngine styling, Strin ItemController controller = new ItemController(row, styling, baseCssId); row.setData(CONTROLLER_DATA_KEY, controller); row.addPaintListener(e -> controller.paintFocusedBackground(e.gc)); + row.addDisposeListener(e -> controller.dispose()); controller.applyState(); return controller; } @@ -176,8 +179,10 @@ private void applyState() { String descendantCssId = focused ? CSS_FOCUSED_ID : baseCssId; row.setRedraw(false); applyCssId(row, baseCssId); + Color descendantBackground = focused ? getFocusedBackground() : row.getBackground(); for (Control child : row.getChildren()) { applyCssIdRecursively(child, descendantCssId); + applyRowBackgroundRecursively(child, descendantBackground); } row.setRedraw(true); row.redraw(); @@ -192,10 +197,24 @@ private void paintFocusedBackground(GC gc) { return; } gc.setAntialias(SWT.ON); - gc.setBackground(CssConstants.getPopupItemFocusBgColor(row.getDisplay())); + gc.setBackground(getFocusedBackground()); gc.fillRoundRectangle(0, 0, bounds.width, bounds.height, FOCUS_ARC, FOCUS_ARC); } + private Color getFocusedBackground() { + if (focusedBackground == null || focusedBackground.isDisposed()) { + focusedBackground = CssConstants.getPopupItemFocusBgColor(row.getDisplay()); + } + return focusedBackground; + } + + private void dispose() { + if (focusedBackground != null && !focusedBackground.isDisposed()) { + focusedBackground.dispose(); + } + focusedBackground = null; + } + private void applyCssId(Control control, String cssId) { if (control.isDisposed()) { return; @@ -217,6 +236,18 @@ private void applyCssIdRecursively(Control control, String cssId) { } } + private void applyRowBackgroundRecursively(Control control, Color background) { + if (control.isDisposed()) { + return; + } + control.setBackground(background); + if (control instanceof Composite composite) { + for (Control child : composite.getChildren()) { + applyRowBackgroundRecursively(child, background); + } + } + } + private static boolean isCursorInside(Control control) { if (control.isDisposed()) { return false; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java index 20f0285d..6c731aef 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java @@ -50,6 +50,24 @@ public static boolean isSkillsEnabled() { && flags != null && flags.isClientPreviewFeatureEnabled(); } + /** + * Returns whether console context is enabled for chat. + * + * @return {@code true} if the user enabled console context, {@code false} otherwise + */ + public static boolean isConsoleContextEnabled() { + CopilotUi plugin = CopilotUi.getPlugin(); + return plugin != null && plugin.getPreferenceStore().getBoolean(Constants.CONSOLE_CONTEXT_ENABLED); + } + + /** + * @return {@code true} if the user enabled transform context (@transform command), {@code false} otherwise + */ + public static boolean isTransformContextEnabled() { + CopilotUi plugin = CopilotUi.getPlugin(); + return plugin != null && plugin.getPreferenceStore().getBoolean(Constants.TRANSFORM_CONTEXT_ENABLED); + } + /** * Returns the current value for the scope used for loading custom instructions in the chat. * diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java index ea44da6f..0a054490 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java @@ -12,15 +12,25 @@ import org.eclipse.jface.resource.ColorRegistry; import org.eclipse.jface.resource.JFaceResources; import org.eclipse.jface.text.ITextViewer; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.ScrolledComposite; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.dnd.Clipboard; import org.eclipse.swt.dnd.TextTransfer; import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.events.ControlAdapter; +import org.eclipse.swt.events.ControlEvent; import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Point; import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.ScrollBar; +import org.eclipse.swt.widgets.Scrollable; import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableColumn; import org.eclipse.ui.IEditorPart; import org.eclipse.ui.IWorkbench; import org.eclipse.ui.IWorkbenchPage; @@ -272,6 +282,88 @@ public static Color getDefaultGhostTextColor(Display display) { return new Color(display, new RGB(DEFAULT_GHOST_TEXT_SCALE, DEFAULT_GHOST_TEXT_SCALE, DEFAULT_GHOST_TEXT_SCALE)); } + /** + * Forwards vertical mouse wheel scrolling from a nested scrollable to its nearest parent scroller when the nested + * control is already at the scroll boundary. + */ + public static void forwardVerticalMouseWheelToParentScrollerAtBoundary(Scrollable scrollable) { + scrollable.addListener(SWT.MouseWheel, event -> { + if (event.count == 0 || scrollable.isDisposed() + || canScrollVertically(scrollable.getVerticalBar(), event.count)) { + return; + } + + ScrolledComposite parentScroller = findParentScroller(scrollable); + if (parentScroller != null + && canScrollVertically(parentScroller.getVerticalBar(), event.count)) { + event.doit = false; + scrollParentVertically(parentScroller, event.count); + } + }); + } + + private static ScrolledComposite findParentScroller(Scrollable scrollable) { + Composite parent = scrollable.getParent(); + while (parent != null) { + if (parent instanceof ScrolledComposite scrolledComposite) { + return scrolledComposite; + } + parent = parent.getParent(); + } + return null; + } + + private static void scrollParentVertically(ScrolledComposite scrolledComposite, int wheelCount) { + ScrollBar verticalBar = scrolledComposite.getVerticalBar(); + if (verticalBar == null || verticalBar.isDisposed()) { + return; + } + + Point origin = scrolledComposite.getOrigin(); + int minimum = verticalBar.getMinimum(); + int maximum = Math.max(minimum, + verticalBar.getMaximum() - verticalBar.getThumb()); + int delta = -wheelCount * Math.max(1, verticalBar.getIncrement()); + int nextY = Math.max(minimum, Math.min(maximum, origin.y + delta)); + scrolledComposite.setOrigin(origin.x, nextY); + } + + private static boolean canScrollVertically(ScrollBar verticalBar, int wheelCount) { + if (verticalBar == null || verticalBar.isDisposed() + || !verticalBar.getEnabled()) { + return false; + } + + int minimum = verticalBar.getMinimum(); + int maximum = Math.max(minimum, + verticalBar.getMaximum() - verticalBar.getThumb()); + int selection = Math.max(minimum, + Math.min(maximum, verticalBar.getSelection())); + if (wheelCount > 0) { + return selection > minimum; + } + return selection < maximum; + } + + /** + * Resizes a table column to fill the table client area not occupied by the fixed-width columns. + */ + public static void resizeColumnToFillTable(Table table, TableColumn fillColumn, + int minWidth, TableColumn... fixedColumns) { + table.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { + int remainingWidth = table.getClientArea().width; + for (TableColumn fixedColumn : fixedColumns) { + remainingWidth -= fixedColumn.getWidth(); + } + if (remainingWidth > minWidth) { + fillColumn.setWidth(remainingWidth); + } + } + }); + } + /** * Copy the given text to the clipboard. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java index 7370d76f..6d4e920d 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java @@ -8,6 +8,8 @@ import java.io.InputStream; import java.net.URI; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; @@ -29,6 +31,8 @@ import org.eclipse.core.commands.NotHandledException; import org.eclipse.core.commands.ParameterizedCommand; import org.eclipse.core.commands.common.NotDefinedException; +import org.eclipse.core.filesystem.EFS; +import org.eclipse.core.filesystem.IFileStore; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; import org.eclipse.core.runtime.preferences.InstanceScope; @@ -96,6 +100,8 @@ * Utilities for Eclipse UI. */ public class UiUtils { + private static final String ICON_BUTTON_USE_PARENT_BACKGROUND_KEY = + "com.microsoft.copilot.eclipse.ui.iconButtonUseParentBackground"; public static final String HAIR_SPACE = "\u200A"; private static final int MAX_SPACE_TO_ADD = 500; @@ -221,6 +227,27 @@ public static IEditorPart openInEditor(IFile file) { return null; } + /** + * Opens the given local filesystem file in an editor. + */ + public static IEditorPart openLocalFileInEditor(Path file) { + if (file == null || !Files.exists(file)) { + CopilotCore.LOGGER.error(new IllegalArgumentException("Cannot open editor: local file is null or doesn't exist")); + return null; + } + + try { + IWorkbenchPage page = getActivePage(); + if (page != null) { + IFileStore fileStore = EFS.getLocalFileSystem().getStore(file.toUri()); + return IDE.openEditorOnFileStore(page, fileStore); + } + } catch (PartInitException e) { + CopilotCore.LOGGER.error(e); + } + return null; + } + /** * Opens the file in the editor. */ @@ -517,6 +544,7 @@ public static Button createIconButton(Composite parent, int style) { final boolean[] mouseEntered = new boolean[1]; result.addPaintListener(e -> { Rectangle bounds = result.getBounds(); + e.gc.setBackground(getIconButtonBackground(result, mouseEntered[0])); e.gc.fillRectangle(0, 0, bounds.width, bounds.height); // Draw focus indicator border for accessibility @@ -541,29 +569,63 @@ public static Button createIconButton(Composite parent, int style) { e.gc.setAlpha(oldAlpha); } }); + result.addListener(SWT.Settings, e -> result.redraw()); + result.addListener(SWT.Resize, e -> result.redraw()); result.addMouseTrackListener(new org.eclipse.swt.events.MouseTrackAdapter() { - private Color background = result.getBackground(); + private Color background = getIconButtonDefaultBackground(result); private Color hoverBackground = CssConstants.getButtonFocusBgColor(result.getDisplay()); @Override public void mouseEnter(org.eclipse.swt.events.MouseEvent e) { // The above background initialization will not take the css color, so here we need to re-fetch // the color when the mouse enters. - background = result.getBackground(); + background = getIconButtonDefaultBackground(result); result.setBackground(hoverBackground); mouseEntered[0] = true; } @Override public void mouseExit(org.eclipse.swt.events.MouseEvent e) { - result.setBackground(background); + result.setBackground(shouldUseParentBackgroundForIconButton(result) ? getIconButtonDefaultBackground(result) + : background); mouseEntered[0] = false; } }); return result; } + /** + * Paints an icon button with its parent background when it is not hovered, making the button appear flat. + */ + public static void useParentBackgroundForIconButton(Button button) { + if (button == null || button.isDisposed()) { + return; + } + button.setData(ICON_BUTTON_USE_PARENT_BACKGROUND_KEY, Boolean.TRUE); + button.setBackground(getIconButtonDefaultBackground(button)); + button.redraw(); + } + + private static Color getIconButtonBackground(Button button, boolean mouseEntered) { + if (mouseEntered) { + return button.getBackground(); + } + return getIconButtonDefaultBackground(button); + } + + private static Color getIconButtonDefaultBackground(Button button) { + if (shouldUseParentBackgroundForIconButton(button) && button.getParent() != null + && !button.getParent().isDisposed()) { + return button.getParent().getBackground(); + } + return button.getBackground(); + } + + private static boolean shouldUseParentBackgroundForIconButton(Button button) { + return Boolean.TRUE.equals(button.getData(ICON_BUTTON_USE_PARENT_BACKGROUND_KEY)); + } + /** * Returns a bold version of the given font. */ diff --git a/docs/CHAT_WINDOW_CONTAINERS.md b/docs/CHAT_WINDOW_CONTAINERS.md new file mode 100644 index 00000000..eb254fa7 --- /dev/null +++ b/docs/CHAT_WINDOW_CONTAINERS.md @@ -0,0 +1,128 @@ +# GitHub Copilot Chat Window Containers + +This document maps the main SWT containers that make up the GitHub Copilot chat window. Styling is applied through Eclipse e4 CSS data keys: + +![GitHub Copilot chat window container map](chat-window-containers.svg) + +- CSS class key: `CssConstants.CSS_CLASS_NAME_KEY` +- CSS ID key: `CssConstants.CSS_ID_KEY` + +## Root Layout + +| Container | Java type | CSS selector / key | Purpose | Source | +| --- | --- | --- | --- | --- | +| Chat root | `Composite` owned by `ChatView` | `#chat-container` | Root container for the Copilot chat view. All top-level chat UI is attached here. | `ChatView#createPartControl` | +| Top banner | `TopBanner` | `#chat-top-banner` | Header/title area at the top of the chat view. | `TopBanner` | +| Content wrapper | `Composite` | `#chat-content-wrapper` | Hosts the active central page: conversation, welcome page, agent mode page, or chat history. | `ChatView#createContentWrapper` | +| Handoff container | `HandoffContainer` | Styled by `#chat-container > HandoffContainer` | Optional container shown above the input area when the current mode exposes handoff actions. | `ChatView#createHandoffContainer`, `HandoffContainer` | +| Action bar wrapper | `ActionBar` | `#chat-action-bar-wrapper` | Outer wrapper for the chat input stack. | `ChatView#createActionBar`, `ActionBar` | + +Typical signed-in chat/agent layout: + +```text +#chat-container + TopBanner (#chat-top-banner) + Composite (#chat-content-wrapper) + ChatContentViewer (#chat-content-viewer) + Composite (cmpContent) + UserTurnWidget + CopilotTurnWidget + HandoffContainer (optional) + ActionBar (#chat-action-bar-wrapper) +``` + +## Conversation Content + +| Container | Java type | CSS selector / key | Purpose | Source | +| --- | --- | --- | --- | --- | +| Conversation scroller | `ChatContentViewer` | `#chat-content-viewer` | Scrollable conversation surface. | `ChatContentViewer` | +| Turn list content | `Composite` field `cmpContent` | Styled through `#chat-content-viewer > Composite` | Inner content composite that receives turn widgets and error widgets. | `ChatContentViewer` | +| User message container | `UserTurnWidget` | SWTBot key `user-turn`; styled by `#chat-content-viewer > Composite > UserTurnWidget` | One user chat turn. Extends `BaseTurnWidget`. | `UserTurnWidget` | +| Copilot reply container | `CopilotTurnWidget` | SWTBot key `copilot-turn`; styled by `#chat-content-viewer > Composite > CopilotTurnWidget` | One Copilot chat turn. Extends `ThinkingTurnWidget` and supports thinking/model footer content. | `CopilotTurnWidget` | +| Shared turn container | `BaseTurnWidget` | No direct CSS ID | Shared base layout for user, Copilot, and subagent turns. Creates avatar/title row and text/code/warning/tool content. | `BaseTurnWidget` | +| Message text | `StyledText` from `SourceViewer` / `ChatMarkupViewer` | `.chat-message-text` | Rendered text content inside a turn. | `UserTurnWidget`, `ChatMarkupViewer` | +| Code block container | `SourceViewerComposite` | No direct CSS ID | Rendered code block inside a turn. | `BaseTurnWidget#createCodeBlock` | +| Warning container | `WarnWidget` | No direct CSS ID | Inline warning or quota/error warning inside a turn. | `BaseTurnWidget#createWarnDialog` | +| Error container | `ErrorWidget` | No direct CSS ID | Error banner rendered inside the conversation content list. | `ChatContentViewer#renderErrorMessage` | +| Tool confirmation container | `InvokeToolConfirmationDialog` | `.bg-command-panel`, `.btn-primary` for child styling | Inline tool invocation confirmation inside a Copilot turn. | `BaseTurnWidget`, `InvokeToolConfirmationDialog` | + +## Handoff Area + +| Container | Java type | CSS selector / key | Purpose | Source | +| --- | --- | --- | --- | --- | +| Handoff container | `HandoffContainer` | Styled by `#chat-container > HandoffContainer` | Shows mode handoff options, hidden when no handoffs exist. | `HandoffContainer` | +| Handoff label | `Label` | `.text-secondary` | Text label such as `PROCEED FROM ...`. | `HandoffContainer#show` | +| Handoff buttons row | `Composite` local `buttonsContainer` | No direct CSS ID | Row-layout container for handoff buttons. | `HandoffContainer#show` | +| Handoff button | `HandoffButtonWidget` | No direct CSS ID | Individual handoff action button. | `HandoffButtonWidget` | + +## Chat Input Area + +| Container | Java type | CSS selector / key | Purpose | Source | +| --- | --- | --- | --- | --- | +| Action bar wrapper | `ActionBar` | `#chat-action-bar-wrapper` | Outer input stack, including optional banners and bars. | `ActionBar` | +| Static banner | `StaticBanner` | No direct CSS ID | Optional warning/info banner displayed above the input area. | `ActionBar#showStaticBanner`, `StaticBanner` | +| Input area | `Composite` field `inputArea` | No direct CSS ID | Transparent wrapper for optional todo/working-set bars and the bordered input. | `ActionBar` | +| Todo list bar | `TodoListBar` | `#todo-list-title` for title child | Optional task list bar shown above the input when agent todo data exists. | `TodoListService`, `TodoListBar` | +| Working set bar | `WorkingSetBar` | Uses `#file-row` / `#file-row-hover` for file rows | Optional changed-files summary bar shown above the input. | `FileToolService`, `WorkingSetBar` | +| Bordered input container | `Composite` local `borderedActionBar` | `#chat-action-bar` | Visual input box containing references, text input, and bottom controls. | `ActionBar` | +| References row | `Composite` field `cmpFileRef` | No direct CSS ID | Holds `AddContextButton`, current file reference, and referenced files. | `ActionBar` | +| Add context control | `AddContextButton` | No direct CSS ID | Button for adding context/references. | `ActionBar`, `AddContextButton` | +| Current file reference | `CurrentReferencedFile` | Child labels use `.text-secondary` | Shows current referenced file state. | `ActionBar`, `CurrentReferencedFile` | +| Referenced file item | `ReferencedFile` | `#normal-referenced-file-name`, `#not-supported-referenced-file-name` for filename label | Individual referenced file pill/item. | `ReferencedFile` | +| Chat input text | `ChatInputTextViewer` | Child text widget may receive CSS classes through `CssConstants.CSS_CLASS_NAME_KEY` | Actual editable chat input. | `ActionBar`, `ChatInputTextViewer` | +| Action area | `Composite` field `cmpActionArea` | No direct CSS ID | Bottom row of controls under the text input. | `ActionBar` | +| Control bar | `Composite` local `cmpControlBar` | No direct CSS ID | Left-side controls: chat mode, model picker, breakpoint, MCP tools, context-size donut. | `ActionBar` | +| Right button group | `Composite` field `bottomRightButtonsComposite` | No direct CSS ID | Right-side send/cancel/job buttons. | `ActionBar` | + +## Welcome, Loading, And History Pages + +These are mutually exclusive content pages hosted either directly under `#chat-container` or inside `#chat-content-wrapper`, depending on authentication and chat state. + +| Container | Java type | CSS selector / key | Purpose | Source | +| --- | --- | --- | --- | --- | +| Loading page | `LoadingViewer` | Styled by `#chat-container > LoadingViewer` | Initial loading state while chat services initialize. | `ChatView#createLoadingPage` | +| Before-login welcome page | `BeforeLoginWelcomeViewer` | Styled by `#chat-container > BeforeLoginWelcomeViewer` | Signed-out welcome/sign-in page. | `ChatView#createBeforeLoginWelcomePage` | +| No-subscription page | `NoSubscriptionViewer` | Styled by `#chat-container > NoSubscriptionViewer` | Signed-in but no usable Copilot subscription state. | `ChatView#createNoSubscriptionPage` | +| After-login welcome page | `AfterLoginWelcomeViewer` | Styled by `#chat-content-wrapper > AfterLoginWelcomeViewer` | Default empty chat welcome page. | `ChatView#createAfterLoginWelcomePage` | +| Agent mode page | `AgentModeViewer` | Styled by `#chat-content-wrapper > AgentModeViewer` | Empty agent-mode landing page. | `ChatView#createAgentModePage` | +| Chat history page | `ChatHistoryViewer` | `#chat-history-viewer` | Conversation history list. | `ChatView#showChatHistory`, `ChatHistoryViewer` | + +## Agent And Subagent Containers + +| Container | Java type | CSS selector / key | Purpose | Source | +| --- | --- | --- | --- | --- | +| Thinking turn | `ThinkingTurnWidget` | No direct CSS ID | Turn type that supports streamed thinking blocks. | `ThinkingTurnWidget` | +| Thinking block | `ThinkingBlock` | Child labels use `.text-secondary` | Collapsible/rendered thinking content within a Copilot turn. | `ThinkingBlock` | +| Thinking section | `ThinkingSection` | No direct CSS ID | Rendered section within a thinking block. | `ThinkingSection` | +| Agent status label | `AgentStatusLabel` | Child labels use `.text-secondary` | Displays agent/tool progress status. | `BaseTurnWidget#appendToolCallStatus`, `AgentStatusLabel` | +| Agent tool cancel label | `AgentToolCancelLabel` | Child label uses `.text-secondary` | Cancellation/status label for agent tool execution. | `AgentToolCancelLabel` | +| Agent message widget | `AgentMessageWidget` | Buttons use `.btn-primary` / `.btn-secondary` | Rendered GitHub coding-agent/job message. | `BaseTurnWidget#appendAgentMessage` | +| Subagent block | `SubagentMessageBlock` | `.subagent-message-block` | Bordered container for subagent execution within a Copilot turn. | `SubagentMessageBlock` | +| Subagent content area | `Composite` field `contentArea` | Styled through `.subagent-message-block > Composite` | Inner container for the subagent turn widget. | `SubagentMessageBlock` | +| Subagent turn | `SubagentTurnWidget` | Styled through `.subagent-message-block > Composite > SubagentTurnWidget` | Turn widget used inside a subagent block. | `SubagentTurnWidget` | + +## Primary Theme Selectors + +The main chat containers are styled in `com.microsoft.copilot.eclipse.ui/css/light.css` and `com.microsoft.copilot.eclipse.ui/css/dark.css`. + +Important selectors: + +```css +#chat-container +#chat-top-banner +#chat-content-wrapper +#chat-action-bar-wrapper +#chat-action-bar +#chat-container > HandoffContainer +#chat-content-viewer +#chat-content-viewer > Composite +#chat-content-viewer > Composite > UserTurnWidget +#chat-content-viewer > Composite > CopilotTurnWidget +#chat-content-viewer StyledText.chat-message-text +#chat-content-viewer .chat-message-text +#chat-content-viewer > Composite > CopilotTurnWidget > .subagent-message-block +#chat-history-viewer +#file-row +#file-row-hover +#todo-list-title +``` diff --git a/docs/DEVELOPER_NOTES_MULESOFT_SUPPORT.md b/docs/DEVELOPER_NOTES_MULESOFT_SUPPORT.md new file mode 100644 index 00000000..89c393f4 --- /dev/null +++ b/docs/DEVELOPER_NOTES_MULESOFT_SUPPORT.md @@ -0,0 +1,69 @@ +# Developer Notes: MuleSoft Development Support Hardening + +## What Changed + +- Added two Mule Transform Message tools: + - `mule_read_transform` to inspect `ee:transform` content in Mule XML. + - `mule_write_transform` to update Transform Message DataWeave safely. +- Added shared transform support utilities in `MuleTransformSupport` so read/write tools use the same XML parsing, transform lookup, resource resolution, and serialization rules. +- Updated `FormatOptionProvider` so `.dwl` files use the DataWeave language id and receive normal formatting fallback behavior. +- Expanded the MuleSoft agent assets so they expose both local Studio/project tools and official MuleSoft MCP tools using the repo-supported `mulesoft/` syntax. +- Corrected the MUnit MCP tool name in agent assets from `generate_or_modify_munit` to `mulesoft/generate_or_modify_munit_test`. + +## Behavior Added + +- Transform reads now cover: + - inline `ee:set-payload` + - inline `ee:set-attributes` + - inline `ee:set-variable` + - `resource="..."` backed DWL files resolved from `src/main/resources` +- Transform writes now cover: + - `payload` + - `attributes` + - `variable:name` and plain variable names + - external DWL resources when the target element uses `resource="..."` +- Transform write operations now fail cleanly when: + - no transform matches + - more than one transform matches + - the requested target does not exist + - the DataWeave script is blank +- XML handling is hardened with secure parser settings and serialization that avoids unnecessary XML declaration churn. + +## Agent And Prompt Assets + +- Updated [com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.anypoint/templates/mulesoft-agent.agent.md) to: + - keep local MuleSoft tools available + - register official MCP tools with `mulesoft/` prefixes + - mention transform read/write workflow explicitly +- Updated the bundled MuleSoft assets under: + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/agents/mulesoft-engineer.agent.md) + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-spec-review.prompt.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/api-spec-review.prompt.md) + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/deployment-readiness.prompt.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/deployment-readiness.prompt.md) + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/generate-munit-tests.prompt.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/generate-munit-tests.prompt.md) + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-code-review.prompt.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-code-review.prompt.md) + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-performance-review.prompt.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-performance-review.prompt.md) + - [com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-security-review.prompt.md](/Users/ajaykontham/Work/GitProjects/copilot-for-eclipse/com.microsoft.copilot.eclipse.ui/mulesoft-copilot/.github/prompts/mule-security-review.prompt.md) + +## Tests Added + +- `MuleAgentToolsTest` + - verifies read/write transform tool metadata + - verifies payload, attributes, variable, and external DWL read behavior + - verifies payload, attributes, and variable write behavior + - verifies no-op/error write paths do not modify XML + - verifies local MuleSoft agent assets expose the expected tool names +- `FormatOptionProviderTests` + - verifies `.dwl` receives default formatting fallback behavior + +## Validation + +- Passed: + - `./mvnw -pl com.microsoft.copilot.eclipse.core,com.microsoft.copilot.eclipse.ui,com.microsoft.copilot.eclipse.anypoint,com.microsoft.copilot.eclipse.ui.test -am -Dcheckstyle.skip=true -Dtest=MuleAgentToolsTest verify` + - `./mvnw -pl com.microsoft.copilot.eclipse.core.test -am -Dcheckstyle.skip=true -Dtest=FormatOptionProviderTests verify` +- Not rerun in normal mode: + - the repo still has the unrelated `ChatInputTextViewer.java` Checkstyle issue outside this MuleSoft change set + +## Notes + +- I left unrelated worktree changes alone, including `.gitignore` edits and the generated Tycho consumer POM churn. +- The transform tool implementation now shares parsing/serialization logic in `MuleTransformSupport` to keep future Mule XML edits consistent. diff --git a/docs/MULESOFT_COPILOT_IMPROVEMENTS.md b/docs/MULESOFT_COPILOT_IMPROVEMENTS.md new file mode 100644 index 00000000..d2bea928 --- /dev/null +++ b/docs/MULESOFT_COPILOT_IMPROVEMENTS.md @@ -0,0 +1,446 @@ +# MuleSoft Copilot Chat Improvements + +**Date**: May 21, 2026 +**Scope**: Enhanced agent instructions, prompt files, and tool descriptions for Mulesoft/Anypoint Studio integration +**Impact**: Domain-specific guidance for niche Mulesoft platform; addresses gaps in API-led architecture, DataWeave, error handling, security threats, logging, connector governance, and deployment + +--- + +## Why These Changes + +The GitHub Copilot Chat plugin was already well-integrated with Mulesoft (14 local tools, MCP server, 6 prompts), but because **Mulesoft is a niche platform** unlike Python or Java: + +1. **Thin prompts**: Original 6 prompts were 4–9 lines, naming what to check but not **how** or **what good looks like** +2. **Generic tool descriptions**: Tools didn't explain *why* each matters for Mulesoft or *when* to invoke them +3. **Missing domain depth**: No guidance on API-led layering, Mulesoft-specific injection attacks (XXE, XPath), DataWeave patterns, or platform-specific deployment +4. **Agent instructions too surface-level**: Mentioned API-led architecture without defining it; error handling without specific patterns + +**Result**: The AI had good mechanics but lacked the **Mulesoft expertise** to give developer advice beyond generic integration patterns. + +--- + +## What Changed + +### 1. Six Prompt Files Expanded (4–9 lines β†’ 40–70 lines) + +Prompt files are templates that guide the AI when a user invokes a slash command like `/mule-code-review`. Each was rewritten with **concrete guidance, Mulesoft anti-patterns, and actionable checklists**. + +#### `mule-code-review.prompt.md` +**Purpose**: General code quality review +**New Content**: +- Flow naming conventions: camelCase verb-noun (e.g., `getCustomerByIdFlow`) +- When to use sub-flows vs. private flows +- Every HTTP-facing flow **must** have an `` handler +- On Error Continue vs. On Error Propagate rules (Continue only for optional enrichment) +- Correlation ID must be set at HTTP Listener and propagated in logs and outbound calls +- Global config deduplication rules +- DataWeave null-safety patterns (`default` operator on optional fields) +- APIkit route coverage β€” every endpoint in RAML/OpenAPI must have a router flow + +**Use it**: User types `/mule-code-review` β†’ Agent gets detailed checklist instead of generic "review flows" + +--- + +#### `mule-security-review.prompt.md` +**Purpose**: Security vulnerability detection +**New Content** (Mulesoft-specific threats): +- **XPath injection**: Parameterize XPath expressions or reject user input in XPath queries +- **XML External Entity (XXE)**: Secure XML parsers to prevent external entity expansion +- **SQL injection**: Database connector queries must be parameterized (`:variable`), never concatenated +- **Insecure deserialization**: DataWeave `read()` on untrusted input without schema validation +- **Transport security**: HTTPS only on public endpoints, TLS context configured, no `insecure="true"` +- **Authentication**: Every public flow must validate credentials (API key, OAuth, JWT, Basic Auth) +- **Logging safety**: Never log passwords, tokens, PII fields without masking +- **Secure properties**: All secrets must use `${secure::property.name}`, not plain `${property.name}` + +**Use it**: User types `/mule-security-review` β†’ Agent scans for Mulesoft-specific injection attacks, not just secrets + +--- + +#### `mule-performance-review.prompt.md` +**Purpose**: Performance and scalability +**New Content** (Mulesoft-specific optimization): +- **DataWeave streaming**: Payloads > 1 MB should use `streaming=true` to avoid materializing in memory +- **Nested maps (O(nΒ²) anti-pattern)**: Flag and rewrite using `groupBy` for indexing +- **Batch job sizing**: Balance memory pressure vs. throughput; default 100 records/block may be wrong +- **maxConcurrency tuning**: Match CPU cores for compute-bound, higher for IO-bound flows +- **Scatter-gather**: Flag when maxConcurrency is not set and route count is dynamic +- **Database connector pooling**: minPoolSize, maxPoolSize, maxWait must be configured +- **N+1 queries**: Detect DB select inside loops; recommend bulk queries with `IN (...)` or joins +- **Caching**: In-memory cache via `` for repeated static/slow-changing data + +**Use it**: User types `/mule-performance-review` β†’ Agent identifies DataWeave materialization, batch sizing, pooling config issues, not just generic optimization + +--- + +#### `deployment-readiness.prompt.md` +**Purpose**: Pre-deployment validation +**New Content** (Platform-specific): +- **Universal**: Health endpoint contract, log levels, MUnit pass rate, secure properties +- **CloudHub 1.0**: Worker sizing (minimum `Medium`), persistent queues for VM/MQ, static IP +- **CloudHub 2.0 / Runtime Fabric**: Resource limits, replica count (2+ for HA), liveness probes +- **On-premises**: Cluster config, JVM heap tuning, application path permissions +- **Smoke test checklist**: Post-deployment validation steps (health endpoint, key endpoint, logs, monitoring) + +**Use it**: User types `/deployment-readiness` β†’ Agent asks for target platform and returns platform-specific checklist + +--- + +#### `api-spec-review.prompt.md` +**Purpose**: API contract validation +**New Content**: +- **APIkit binding**: Every spec endpoint **must** have a corresponding router flow; flag orphaned routes +- **Error contract**: Error codes in spec must match error handlers in Mule; error responses must match schema +- **Examples validation**: Examples must be valid against their declared schema +- **Security scheme**: OAuth/API key/JWT must be defined **and implemented** in flows +- **Versioning**: Use URL path (e.g., `/v1/`) not header-based versioning; document breaking changes +- **Named schemas**: Avoid inline anonymous objects; all reusable types in `types` section + +**Use it**: User types `/api-spec-review` β†’ Agent validates spec completeness and APIkit router coverage + +--- + +#### `generate-munit-tests.prompt.md` +**Purpose**: Test generation and coverage +**New Content** (Mulesoft test scenarios): +- **Happy path, negative path, error path, edge data**: Four required scenarios per flow +- **Async flow testing**: Use VM queue polling with timeout; assert side effects (DB writes, MQ publishes) +- **Batch job testing**: Unit-test individual steps, integration-test full batch with 3–5 fixture records including one that fails +- **Scatter-gather**: Mock each route independently; add one test with a failing route +- **Transactional flows**: Mock second connector to fail and verify first connector's write was rolled back +- **Choice router branches**: Each when-condition + otherwise branch needs its own test +- **Correlation ID assertions**: Verify correlation ID is logged in all flow entry/error handler outputs +- **Test naming**: Descriptive names (`getCustomer_validId_returns200`) not `test1` + +**Use it**: User types `/generate-munit-tests` β†’ Agent generates tests covering async, batch, scatter-gather, transactional flowsβ€”not just happy path + +--- + +### 2. Three New Prompt Files (90–120 lines each) + +These address domains that had no dedicated prompt in the original configuration. + +#### `dataweave-best-practices.prompt.md` +**Why**: DataWeave is Mulesoft's unique transformation language. No other platform has it. No dedicated quality review existed. + +**Content**: +- **Output type declaration**: Mandatory `output application/json` etc.; missing output causes type inference bugs +- **Null-safety**: `default` operator on all optional accesses; no exception-based null handling +- **Functional patterns**: `map`, `filter`, `reduce`, `groupBy` over imperative `if/else` loops +- **Performance anti-patterns**: Nested maps (O(nΒ²)), inline regex compiled per iteration, unnecessary serialization +- **Streaming**: `streaming=true` for unknown-size payloads; know the limitations (no `sizeOf()`, `[-1]`, `reverse()`) +- **Modularity**: Repeated logic extracted to `.dwl` modules in `src/main/resources/dwl/` +- **Type safety**: Document input types; use named type definitions + +**Use it**: User types `/dataweave-best-practices` β†’ Agent reviews all Transform Message components for null-safety, performance, and functional patterns + +--- + +#### `connector-governance.prompt.md` +**Why**: Connector configuration was mentioned but never systematically reviewed. Gaps in version compatibility, pooling, and deprecated connectors. + +**Content**: +- **Version compatibility**: Check Mulesoft compatibility matrix; flag EOL connectors (HTTP v1, File v1) +- **Redundant connectors**: Flag two versions of same connector in POM or duplicate global configs +- **Connection pooling**: Database/JMS/HTTP must have explicit pool config (minPoolSize, maxPoolSize, maxWait, connectionIdleTimeout) +- **Timeout and retry**: Every HTTP Request must have `responseTimeout`; flag `reconnect-forever` in production +- **Authentication consistency**: Same upstream service should use same auth method across flows +- **Deprecated and risky**: Flag Groovy/JS/Python scripting, Java Module static method calls, VM for cross-app messaging + +**Use it**: User types `/connector-governance` β†’ Agent audits connector versions, pooling config, and deprecated patterns + +--- + +#### `logging-observability.prompt.md` +**Why**: Logging was reduced to "redact PII"β€”no positive guidance on structured logging, correlation IDs, or metrics. + +**Content**: +- **Correlation ID strategy**: Set at HTTP Listener from header or generate `uuid()`; propagate in all outbound HTTP headers +- **Log levels**: ERROR for failures, WARN for retries, INFO for flow entry/exit, DEBUG for diagnostics (prod disabled) +- **Structured logging**: JSON format not string concatenation; include `correlationId`, `flowName`, `operation`, metadata +- **PII/secrets**: Never log passwords/tokens/keys; mask if unavoidable (e.g., `email[0..2] ++ "***"`) +- **Error handler logging**: Every `` logs with `correlationId`, `flowName`, `errorType`, `errorDescription` +- **Log4j2 config**: Root logger `INFO` in prod; async appender for high throughput; JSON layout preferred +- **Anypoint Monitoring**: Enable for visibility; custom metrics for performance tracking + +**Use it**: User types `/logging-observability` β†’ Agent reviews log4j2, Logger components, and correlation ID propagation + +--- + +### 3. Both Agent Files Deepened (65 lines β†’ 110 lines) + +The two main agent definitions (`mulesoft-agent.agent.md` and `mulesoft-engineer.agent.md`) now include concrete sections instead of surface-level guidance. + +#### New Sections in Both Agents: + +**API-Led Architecture** +``` +- Experience API: consumer-facing, routes to Process APIs +- Process API: orchestrates business logic across System APIs +- System API: one-to-one backend adapter, no business logic +Rule: Never let System API call Experience API; preserve boundaries +``` + +**Error Handling Contract** +``` +- All HTTP-facing flows must have with typed error matchers +- only for truly optional enrichment steps +- Every error handler logs: correlationId, flow.name, error.errorType, error.description (JSON) +- Error responses: consistent JSON shape { "code", "message", "correlationId" }, correct HTTP status codes +- Correlation ID set at HTTP Listener, propagated in all outbound HTTP headers and logs +``` + +**DataWeave Standards** +``` +- Always read_transform before editing +- Every script must declare output type +- All optional field accesses use default operator +- Prefer map/filter/reduce over imperative patterns +- Flag nested maps over large collections (pre-index with groupBy) +- Streaming: output application/json streaming=true for unknown/large payloads +- Extract repeated logic to .dwl modules +``` + +**Logging Discipline** +``` +- INFO: flow entry/exit with correlationId, flowName, key identifiers (no full payload) +- ERROR: every with correlationId, flowName, errorType, errorDescription +- DEBUG: connector calls, DataWeave diagnostics (disabled in production) +- Never log passwords, tokens, API keys, or PII fields without masking +- Use structured JSON format in Logger message expressions +``` + +**Connector Governance** +``` +- Align versions with Mule runtime compatibility matrix +- Database configs: set minPoolSize, maxPoolSize, maxWait +- HTTP Request configs: set responseTimeout +- Outbound HTTP: HTTPS only, TLS context configured, insecure="true" never allowed +- Retry: finite reconnect with count/frequency, never reconnect-forever in production +- Flag deprecated: HTTP v1, File Connector v1, Scripting Module (Groovy/JS/Python) +``` + +**MUnit Testing** +``` +- Every public flow: happy path, invalid input, connector failure, error-response contract +- Mock all external connectors by doc:name, not sub-flows +- Cover every Choice branch including otherwise +- Run munit_validate_flow_tests after generating +- Use munit_full_review for suite audits before release +``` + +**Use it**: The agent now asks smarter questions and catches issues automatically because it understands these Mulesoft patterns. + +--- + +### 4. Six Tool Descriptions Enriched (3–4 lines β†’ 8–15 lines) + +Tool descriptions appear when the AI considers whether to invoke a tool. Enriched descriptions help the AI know **when and why** to use each tool. + +#### Before vs. After Examples: + +**MuleProjectScanTool** +- Before: "Scan Mule project structure and metadata" +- After: Includes what data is returned (runtime version, connectors, flows, API specs, MUnit coverage, diagnostics), how to use results (check version compatibility, identify missing test coverage), and when to invoke (before code review or XML edits) + +**MuleSecurityReviewTool** +- Before: "Hardcoded secrets, insecure HTTP, missing validation, unsafe logging" +- After: Specific Mulesoft threats (SQL injection via DB connector string concatenation, XXE in XML parsing, XPath injection, parameter masking), common high-severity findings, classification severity levels + +**ApiSchemaAnalyzeTool** +- Before: "Governance-oriented diagnostics" +- After: Concrete governance issues (missing metadata, inline schemas vs. named types, missing error responses, no security scheme, examples don't validate against schema), APIkit compatibility checks + +**MunitValidateFlowTestsTool** +- Before: "Validate structure and flow coverage" +- After: Specific checks (munit:config presence, assertion elements, mock-when usage, no assertions = always passes, untested Choice branches), use before running Maven + +**MunitFullReviewTool** +- Before: "Broad suite reviews" +- After: Full scenario coverage analysis (happy, negative, error, edge, connector-failure, error-contract), assertion quality, mock completeness, branching coverage, test duplication, use before release + +--- + +## How to Use These Improvements + +### For End Users (Developers in Anypoint Studio) + +All improvements are **automatically available** via slash commands in the chat. No configuration needed. + +#### Example Workflow: + +1. **New project?** Start with: + ``` + /mule-code-review + ``` + Agent runs `mule_project_scan` β†’ `mule_code_review` and returns findings on flow naming, error handlers, global configs, correlation IDs, API-led boundaries + +2. **Before pushing to production?** + ``` + /mule-security-review + /mule-performance-review + /deployment-readiness + ``` + Agent detects injection risks, DataWeave materialization, batch sizing, pooling config, log levels, health endpoints + +3. **Writing a DataWeave transform?** + ``` + /dataweave-best-practices + ``` + Agent reviews all Transform Message components for null-safety, streaming, functional patterns + +4. **Configuring connectors?** + ``` + /connector-governance + ``` + Agent audits connector versions, pooling, timeouts, deprecated patterns + +5. **Adding logs or monitoring?** + ``` + /logging-observability + ``` + Agent reviews correlation ID propagation, log levels, structured format, Anypoint Monitoring setup + +6. **Generating MUnit tests?** + ``` + /generate-munit-tests + ``` + Agent creates tests covering happy, negative, error, edge, async, batch, scatter-gather, transactional scenarios + +--- + +### For Developers Asking General Questions + +The agent instructions now guide the AI to ask smarter clarifying questions: + +**Before:** +- User: "How should I structure my error handling?" +- Agent: "Use error handlers. Try/catch blocks are good." + +**After:** +- User: "How should I structure my error handling?" +- Agent: "Are this flows HTTP-facing or internal? If HTTP-facing, every public flow needs `` with typed error matchers, correlation ID logging, and consistent error response shape. What's your target platformβ€”CloudHub, Runtime Fabric, or on-prem?" + +--- + +### For Code Review + +Agent can now provide **Mulesoft-expert-level** code reviews: + +**Before:** +- Review flow: "Flow looks OK. Add error handling." + +**After:** +- Review flow: "Flow `getCustomer` lacks on-error-propagate. HTTP Listener must catch connectivity errors from DB connector. Global config `dbConnConfig` is missing pool settings; set minPoolSize=2, maxPoolSize=10, maxWait=5000. Transform Message lacks output directive. Tests missing correlation ID assertion." + +--- + +## Files Modified + +| Type | Files | Changes | +|------|-------|---------| +| **Prompts Expanded** | 6 files in `.github/prompts/` | 4–9 lines β†’ 40–70 lines each | +| **Prompts New** | 3 new files in `.github/prompts/` | 90–120 lines each | +| **Agents Deepened** | 2 files: `mulesoft-agent.agent.md`, `mulesoft-engineer.agent.md` | 65 lines β†’ 110 lines each | +| **Tool Descriptions** | 6 Java files in `chat/tools/` | 3–4 lines β†’ 8–15 lines each | + +--- + +## Validation + +To verify the improvements: + +1. **Open Eclipse Anypoint Studio** with the plugin installed +2. **Open Chat panel** (Copilot icon) +3. **Type `/` to see all commands** β€” you should see all 9 slash commands (6 original + 3 new prompts): + - `/mule-code-review` + - `/mule-security-review` + - `/mule-performance-review` + - `/deployment-readiness` + - `/api-spec-review` + - `/generate-munit-tests` + - `/dataweave-best-practices` (new) + - `/connector-governance` (new) + - `/logging-observability` (new) + +4. **Test a prompt**: Invoke `/mule-code-review` on a sample Mule project + - Agent should run `mule_project_scan` automatically + - Agent should mention flow naming conventions, error handlers, global config dedup, correlation ID propagation, APIkit coverage + - Not generic "review flows" advice + +5. **Test tool descriptions**: In the chat, look for tool names in agent output + - `mule_project_scan` description should mention "runtime version, connector inventory, flow/sub-flow counts, diagnostics" + - `mule_security_review` should mention "XXE, XPath injection, SQL injection, insecure deserialization" + - Not generic "scan project" or "review security" + +--- + +## Impact Summary + +| Category | Before | After | +|----------|--------|-------| +| **Prompt Depth** | 4–9 lines, naming what to check | 40–70 lines, concrete guidance + anti-patterns | +| **New Prompts** | 0 specialized prompts for DataWeave, connectors, logging | 3 dedicated prompts | +| **Tool Descriptions** | Generic ("review flows", "check security") | Specific ("check flow naming, error handlers, correlation ID", "detect XXE, SQL injection, deserialization risks") | +| **Agent Instructions** | Surface-level mention of API-led, error handling | Detailed rules: layer hierarchy, error contract, DataWeave patterns, logging discipline | +| **Domain Knowledge** | Generic integration patterns | Mulesoft-specific: DataWeave streaming, batch sizing, connector pooling, XXE/XPath/SQL injection, correlation ID propagation | + +--- + +## Example: Before vs. After + +### User Request: "Review my Mule flow for security issues" + +**Before:** +``` +Agent: "Let me review your flows for security issues. +- Check for hardcoded secrets? Yes, I see some. +- Missing secure properties? Yes. +- Insecure HTTP? Yes. +Good luck fixing those. Try using ${secure::property} syntax." +``` + +**After:** +``` +Agent: "Let me scan your project first... +[runs mule_project_scan] + +High-risk findings: +1. Database connector query in concatenates user input: + SELECT * FROM customers WHERE id = '${payload.customerId}' + β†’ SQL injection risk. Use parameterized query: SELECT * FROM customers WHERE id = :customerId + +2. HTTP Listener on public endpoint has no : + If DB connector fails, unhandled exception returns 500 with stack trace. + β†’ Add error handler that catches DB:QUERY_EXECUTION and returns { "code": "DB_ERROR", "message": "Customer lookup failed", "correlationId": "..." } + +3. Secure Configuration Properties module not in POM: + ${secure::dbPassword} won't encrypt values at rest. + β†’ Add dependency: mule-secure-configuration-property-module + +4. HTTP Request to external service has no TLS context: + + β†’ Add tlsContext and remove insecure="true" + +[Validation commands provided] +Test: mvn test -Dmunit.test=*Test.xml +``` + +--- + +## Next Steps for the Team + +1. **Commit and merge** these changes to the `dev/mule-support` branch +2. **Test in Eclipse Anypoint Studio** with sample projects +3. **Gather feedback** from developers using the prompts +4. **Iterate** if new Mulesoft-specific patterns emerge (e.g., batch error handling, Runtime Fabric deployment specifics) + +--- + +## Notes + +- All improvements are **backward-compatible**. Existing tool registrations and agent setup unchanged. +- New prompts are automatically indexed by the language server and appear in slash command autocomplete. +- Tool descriptions are used by the AI to decide when to invoke tools and how to interpret resultsβ€”no UI changes needed. +- Agent instructions are loaded into the AI's context at the start of each chat sessionβ€”developers see improvements immediately. + diff --git a/docs/chat-window-containers.svg b/docs/chat-window-containers.svg new file mode 100644 index 00000000..6364a103 --- /dev/null +++ b/docs/chat-window-containers.svg @@ -0,0 +1,130 @@ + + GitHub Copilot Chat Window Container Map + Diagram showing the SWT container hierarchy for the GitHub Copilot chat window. + + + + + + GitHub Copilot Chat Window Containers + SWT hierarchy, CSS IDs/classes, and optional runtime containers + + + Chat root + Composite owned by ChatView Β· #chat-container + + + TopBanner + #chat-top-banner Β· header/title row + + + + + Content Wrapper + Composite Β· #chat-content-wrapper + + + ChatContentViewer + #chat-content-viewer Β· scrollable conversation + + + cmpContent + inner Composite Β· receives turns and errors + + + UserTurnWidget + SWTBot: user-turn + + + CopilotTurnWidget + SWTBot: copilot-turn + + + + + Alternate pages in this wrapper: AfterLoginWelcomeViewer, AgentModeViewer, ChatHistoryViewer + + + + + HandoffContainer + optional Β· #chat-container > HandoffContainer + + buttonsContainer + HandoffButtonWidget rows + + + + + ActionBar + #chat-action-bar-wrapper Β· chat input stack + + StaticBanner + + TodoListBar + + WorkingSetBar + + #chat-action-bar + + + + + Inside #chat-action-bar + + cmpFileRef + AddContextButton, CurrentReferencedFile + + ChatInputTextViewer + editable input text widget + + cmpActionArea + cmpControlBar + bottomRightButtonsComposite + + + Agent/Subagent Containers + + ThinkingTurnWidget + base for streamed thinking + + ThinkingBlock / ThinkingSection + reasoning display containers + + SubagentMessageBlock + .subagent-message-block + contentArea β†’ SubagentTurnWidget + + + State Pages + Direct under #chat-container: + LoadingViewer + BeforeLoginWelcomeViewer + NoSubscriptionViewer + Under #chat-content-wrapper: + AfterLoginWelcomeViewer Β· AgentModeViewer Β· ChatHistoryViewer + + + + + + Source: docs/CHAT_WINDOW_CONTAINERS.md and com.microsoft.copilot.eclipse.ui chat SWT classes + diff --git a/pom.xml b/pom.xml index b49c8076..c1a489b3 100644 --- a/pom.xml +++ b/pom.xml @@ -25,6 +25,7 @@ com.microsoft.copilot.eclipse.core com.microsoft.copilot.eclipse.ui + com.microsoft.copilot.eclipse.anypoint com.microsoft.copilot.eclipse.ui.jobs @@ -39,6 +40,7 @@ com.microsoft.copilot.eclipse.feature + com.microsoft.copilot.eclipse.anypoint.feature com.microsoft.copilot.eclipse.repository @@ -162,4 +164,4 @@ - \ No newline at end of file +