Add blazor skills to dotnet-blazor plugin #591
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: skill-coverage | |
| # NOTE: The issue_comment trigger sources this workflow from the default branch. | |
| # The /skill-coverage slash command will only work after this workflow is merged to main. | |
| on: | |
| pull_request: | |
| paths: | |
| - 'plugins/**' | |
| - 'tests/**' | |
| issue_comment: | |
| types: [created] | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }} | |
| cancel-in-progress: true | |
| permissions: {} | |
| jobs: | |
| # ────────────────────────────────────────────────────────────── | |
| # Job 1: Analyze — runs trusted script with read-only permissions | |
| # Checks out base branch for tooling, PR head into a | |
| # separate worktree so untrusted code is never executed. | |
| # ────────────────────────────────────────────────────────────── | |
| analyze: | |
| if: >- | |
| github.event_name == 'pull_request' || | |
| (github.event_name == 'issue_comment' && | |
| github.event.issue.pull_request && | |
| (github.event.comment.body == '/skill-coverage' || | |
| startsWith(github.event.comment.body, '/skill-coverage ') || | |
| startsWith(github.event.comment.body, '/skill-coverage\n'))) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| count: ${{ steps.detect.outputs.count }} | |
| pr_number: ${{ github.event.pull_request.number || steps.pr-ref.outputs.pr_number }} | |
| steps: | |
| - name: Check commenter permissions | |
| id: check-perms | |
| if: github.event_name == 'issue_comment' | |
| uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 | |
| with: | |
| script: | | |
| const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| username: context.actor | |
| }); | |
| const allowed = ['admin', 'write', 'maintain']; | |
| if (!allowed.includes(perm.permission)) { | |
| core.setFailed(`User ${context.actor} does not have write access (permission: ${perm.permission}).`); | |
| } | |
| - name: Parse slash command arguments | |
| id: parse-command | |
| if: github.event_name == 'issue_comment' | |
| shell: bash | |
| env: | |
| COMMENT_BODY: ${{ github.event.comment.body }} | |
| run: | | |
| # Extract the first line of the command | |
| cmd_line=$(echo "$COMMENT_BODY" | head -n 1) | |
| # Strip the command prefix | |
| args="${cmd_line#/skill-coverage}" | |
| args="$(echo "$args" | xargs)" # trim whitespace | |
| if [ -z "$args" ]; then | |
| echo "mode=auto" >> "$GITHUB_OUTPUT" | |
| echo "plugin=" >> "$GITHUB_OUTPUT" | |
| echo "skill=" >> "$GITHUB_OUTPUT" | |
| else | |
| # Parse: plugin [skill] | |
| plugin=$(echo "$args" | awk '{print $1}') | |
| skill=$(echo "$args" | awk '{print $2}') | |
| echo "mode=explicit" >> "$GITHUB_OUTPUT" | |
| echo "plugin=$plugin" >> "$GITHUB_OUTPUT" | |
| echo "skill=$skill" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Add reaction to comment | |
| if: github.event_name == 'issue_comment' | |
| continue-on-error: true | |
| uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 | |
| with: | |
| script: | | |
| await github.rest.reactions.createForIssueComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: context.payload.comment.id, | |
| content: 'eyes' | |
| }); | |
| - name: Get PR ref for comment trigger | |
| id: pr-ref | |
| if: github.event_name == 'issue_comment' | |
| uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 | |
| with: | |
| script: | | |
| const pr = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: context.issue.number | |
| }); | |
| core.setOutput('sha', pr.data.head.sha); | |
| core.setOutput('base_sha', pr.data.base.sha); | |
| core.setOutput('pr_number', context.issue.number); | |
| # Checkout the default branch for trusted tooling (Measure-SkillCoverage.ps1), | |
| # then fetch the PR head into a separate worktree so untrusted code is never executed. | |
| - name: Checkout base branch (trusted tooling) | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - name: Fetch PR head into worktree | |
| shell: bash | |
| env: | |
| PR_REF: ${{ github.event_name == 'issue_comment' && format('refs/pull/{0}/head', steps.pr-ref.outputs.pr_number) || format('refs/pull/{0}/head', github.event.pull_request.number) }} | |
| run: | | |
| git fetch --depth=1 origin "$PR_REF":pr-head-ref | |
| git worktree add pr-head pr-head-ref | |
| - name: Fetch full history for diff | |
| run: git fetch --unshallow || true | |
| - name: Detect changed plugins and skills | |
| id: detect | |
| shell: pwsh | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| CMD_MODE: ${{ steps.parse-command.outputs.mode }} | |
| CMD_PLUGIN: ${{ steps.parse-command.outputs.plugin }} | |
| CMD_SKILL: ${{ steps.parse-command.outputs.skill }} | |
| PR_BASE_SHA: ${{ github.event.pull_request.base.sha || steps.pr-ref.outputs.base_sha }} | |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || steps.pr-ref.outputs.sha }} | |
| run: | | |
| # If explicit plugin/skill was provided via slash command, use that directly | |
| if ($env:EVENT_NAME -eq 'issue_comment' -and $env:CMD_MODE -eq 'explicit') { | |
| $targets = @([PSCustomObject]@{ Plugin = $env:CMD_PLUGIN; Skill = $env:CMD_SKILL }) | |
| $json = $targets | ConvertTo-Json -Compress -AsArray | |
| "targets=$json" >> $env:GITHUB_OUTPUT | |
| "count=$($targets.Count)" >> $env:GITHUB_OUTPUT | |
| return | |
| } | |
| # Detect changed files between base and head | |
| $diff = git diff --name-only "$env:PR_BASE_SHA" "$env:PR_HEAD_SHA" 2>$null | |
| if ($LASTEXITCODE -ne 0) { | |
| # Fallback: diff against merge-base | |
| $mergeBase = git merge-base "$env:PR_BASE_SHA" "$env:PR_HEAD_SHA" | |
| $diff = git diff --name-only $mergeBase "$env:PR_HEAD_SHA" | |
| } | |
| $changedFiles = $diff -split "`n" | Where-Object { $_ } | |
| # Collect unique plugin/skill pairs from changed paths | |
| $seen = @{} | |
| $pluginWide = @{} | |
| $targets = @() | |
| foreach ($file in $changedFiles) { | |
| # Match plugins/<plugin>/skills/<skill>/... | |
| if ($file -match '^plugins/([^/]+)/skills/([^/]+)/') { | |
| $key = "$($Matches[1])/$($Matches[2])" | |
| if (-not $seen.ContainsKey($key)) { | |
| $seen[$key] = $true | |
| $targets += [PSCustomObject]@{ Plugin = $Matches[1]; Skill = $Matches[2] } | |
| } | |
| } | |
| # Match tests/<plugin>/<skill>/... | |
| elseif ($file -match '^tests/([^/]+)/([^/]+)/') { | |
| $key = "$($Matches[1])/$($Matches[2])" | |
| if (-not $seen.ContainsKey($key)) { | |
| $seen[$key] = $true | |
| $targets += [PSCustomObject]@{ Plugin = $Matches[1]; Skill = $Matches[2] } | |
| } | |
| } | |
| # Match plugins/<plugin>/ level changes (plugin.json etc.) - run all skills | |
| elseif ($file -match '^plugins/([^/]+)/[^/]+$') { | |
| $pluginWide[$Matches[1]] = $true | |
| } | |
| } | |
| # For plugin-wide changes, replace per-skill targets with a single plugin-wide target | |
| foreach ($plugin in $pluginWide.Keys) { | |
| $targets = @($targets | Where-Object { $_.Plugin -ne $plugin }) | |
| $targets += [PSCustomObject]@{ Plugin = $plugin; Skill = '' } | |
| } | |
| if ($targets.Count -eq 0) { | |
| Write-Host "No plugin/skill changes detected." | |
| "count=0" >> $env:GITHUB_OUTPUT | |
| "targets=[]" >> $env:GITHUB_OUTPUT | |
| return | |
| } | |
| Write-Host "Detected $($targets.Count) target(s):" | |
| $targets | ForEach-Object { Write-Host " - $($_.Plugin)$(if ($_.Skill) { "/$($_.Skill)" })" } | |
| $json = $targets | ConvertTo-Json -Compress -AsArray | |
| "targets=$json" >> $env:GITHUB_OUTPUT | |
| "count=$($targets.Count)" >> $env:GITHUB_OUTPUT | |
| - name: Run skill coverage | |
| if: steps.detect.outputs.count != '0' | |
| id: coverage | |
| shell: pwsh | |
| env: | |
| TARGETS_JSON: ${{ steps.detect.outputs.targets }} | |
| run: | | |
| $targets = $env:TARGETS_JSON | ConvertFrom-Json | |
| $allJson = @() | |
| $summary = @() | |
| # Run the trusted script from base branch against PR content in the worktree | |
| $scriptPath = "$env:GITHUB_WORKSPACE/eng/skill-coverage/Measure-SkillCoverage.ps1" | |
| $prRoot = "$env:GITHUB_WORKSPACE/pr-head" | |
| foreach ($t in $targets) { | |
| $args = @{ Format = 'Json'; RepoRoot = $prRoot } | |
| if ($t.Plugin) { $args.PluginName = $t.Plugin } | |
| if ($t.Skill) { $args.SkillName = $t.Skill } | |
| Write-Host "::group::Coverage: $($t.Plugin)$(if ($t.Skill) { "/$($t.Skill)" })" | |
| try { | |
| $result = & $scriptPath @args | |
| $parsed = ($result | Out-String) | ConvertFrom-Json | |
| # Handle single or array results | |
| $reports = if ($parsed -is [array]) { $parsed } else { @($parsed) } | |
| foreach ($r in $reports) { | |
| $allJson += $r | |
| $pct = $r.summary.percentage | |
| $icon = if ($pct -ge 80) { ':white_check_mark:' } | |
| elseif ($pct -ge 50) { ':warning:' } | |
| else { ':x:' } | |
| $summary += "| $icon | ``$($r.plugin)`` | ``$($r.skill)`` | $($r.summary.coveredPoints)/$($r.summary.totalPoints) | **$pct%** |" | |
| } | |
| } | |
| catch { | |
| Write-Host "::warning::Failed to analyze $($t.Plugin)/$($t.Skill): $_" | |
| $summary += "| :grey_question: | ``$($t.Plugin)`` | ``$($t.Skill)`` | — | error |" | |
| } | |
| Write-Host "::endgroup::" | |
| } | |
| # Build markdown report | |
| $md = @() | |
| $md += '## Skill Coverage Report' | |
| $md += '' | |
| $md += '| | Plugin | Skill | Covered | Coverage |' | |
| $md += '|---|--------|-------|---------|----------|' | |
| $md += $summary | |
| $md += '' | |
| # Add uncovered items details if any | |
| $uncoveredDetails = @() | |
| foreach ($r in $allJson) { | |
| if ($r.uncovered.Count -gt 0) { | |
| $uncoveredDetails += "<details><summary>Uncovered: <code>$($r.plugin)/$($r.skill)</code></summary>" | |
| $uncoveredDetails += '' | |
| foreach ($u in $r.uncovered) { | |
| $uncoveredDetails += "- **[$($u.category)]** $($u.description) (line $($u.line))" | |
| } | |
| $uncoveredDetails += '' | |
| $uncoveredDetails += '</details>' | |
| $uncoveredDetails += '' | |
| } | |
| } | |
| if ($uncoveredDetails.Count -gt 0) { | |
| $md += $uncoveredDetails | |
| } | |
| # Write the report to a file for the comment job | |
| $report = $md -join "`n" | |
| $report | Out-File -FilePath coverage-report.md -Encoding utf8 | |
| # Also write to step summary | |
| $report >> $env:GITHUB_STEP_SUMMARY | |
| - name: Upload coverage report | |
| if: steps.detect.outputs.count != '0' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: coverage-report | |
| path: coverage-report.md | |
| retention-days: 1 | |
| # ────────────────────────────────────────────────────────────── | |
| # Job 2: Comment — posts results with write permissions only. | |
| # No PR code is checked out or executed in this job. | |
| # ────────────────────────────────────────────────────────────── | |
| comment: | |
| needs: analyze | |
| if: >- | |
| needs.analyze.outputs.count != '0' && | |
| (github.event_name != 'pull_request' || | |
| github.event.pull_request.head.repo.full_name == github.repository) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| pull-requests: write | |
| issues: write | |
| steps: | |
| - name: Download coverage report | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: coverage-report | |
| - name: Post or update PR comment | |
| uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 | |
| env: | |
| PR_NUMBER: ${{ needs.analyze.outputs.pr_number }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const body = fs.readFileSync('coverage-report.md', 'utf8'); | |
| const marker = '<!-- skill-coverage-report -->'; | |
| const fullBody = `${marker}\n${body}`; | |
| const prNumber = parseInt(process.env.PR_NUMBER, 10); | |
| // Paginate to find existing marker comment (handles PRs with >100 comments) | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| per_page: 100 | |
| }); | |
| const existing = comments.find(c => c.body && c.body.startsWith(marker)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body: fullBody | |
| }); | |
| core.info(`Updated comment ${existing.id}`); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: fullBody | |
| }); | |
| core.info('Created new coverage comment'); | |
| } | |
| - name: Post no-changes comment | |
| if: needs.analyze.outputs.count == '0' && github.event_name == 'issue_comment' | |
| continue-on-error: true | |
| uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 | |
| with: | |
| script: | | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body: '**Skill Coverage:** No plugin/skill changes detected in this PR. Use `/skill-coverage <plugin> [skill]` to target specific skills.' | |
| }); |