dejadoc 0.1.3

Find duplicated Rust doctests across a workspace
Documentation
name: dejadoc
description: >-
  Install dejadoc, fail the job when doctests are duplicated
  across the workspace, or post the findings as a pull request review

branding:
  icon: 'copy'
  color: 'orange'
inputs:
  threshold:
    description: Report duplicate groups with at least this many sites
    required: false
    default: '2'
  min-tokens:
    description: Skip doctest bodies with fewer tokens than this
    required: false
    default: '0'
  all-targets:
    description: Also scan bin and example targets
    required: false
    default: 'false'
  package:
    description: Restrict the scan to one workspace member by name
    required: false
    default: ''
  version:
    description: The dejadoc version to install, empty for the latest
    required: false
    default: ''
  cwd:
    description: The workspace directory to scan
    required: false
    default: ''
  pr-number:
    description: Post the findings as a pull request review on this PR instead of failing the job
    required: false
    default: ''
  dry-run:
    description: Render the review instead of posting it
    required: false
    default: 'false'

runs:
  using: 'composite'
  steps:
    - name: Install dejadoc
      shell: bash
      env:
        GH_TOKEN: ${{ github.token }}
        VERSION: ${{ inputs.version }}
      run: |
        set -euo pipefail
        repo="LucaCappelletti94/cargo-dejadoc"
        tag=''
        [ -n "$VERSION" ] && tag="v$VERSION"

        case "$RUNNER_OS/$RUNNER_ARCH" in
          Linux/X64)   target=x86_64-unknown-linux-gnu ;;
          Linux/ARM64) target=aarch64-unknown-linux-gnu ;;
          macOS/X64)   target=x86_64-apple-darwin ;;
          macOS/ARM64) target=aarch64-apple-darwin ;;
          Windows/X64) target=x86_64-pc-windows-msvc ;;
          *)           target='' ;;
        esac

        if [ -n "$target" ]; then
          asset="cargo-dejadoc-$target.tar.gz"
          dest="$RUNNER_TEMP/dejadoc-bin"
          mkdir -p "$dest"
          args=(--repo "$repo" --pattern "$asset" --dir "$dest" --clobber)
          [ -n "$tag" ] && args=("$tag" "${args[@]}")
          if gh release download "${args[@]}" 2>/dev/null; then
            bin=cargo-dejadoc
            [ "$RUNNER_OS" = Windows ] && bin=cargo-dejadoc.exe
            tar -xzf "$dest/$asset" -C "$dest"
            chmod +x "$dest/$bin" 2>/dev/null || true
            echo "$dest" >> "$GITHUB_PATH"
            echo "dejadoc: installed the prebuilt $asset from ${tag:-the latest release}"
            exit 0
          fi
        fi

        echo "dejadoc: no prebuilt binary for $RUNNER_OS/$RUNNER_ARCH, building from source"
        install=(dejadoc)
        [ -n "$tag" ] && install+=(--version "$VERSION")
        cargo install "${install[@]}"

    - name: Scan for duplicated doctests
      id: scan
      shell: bash
      working-directory: ${{ inputs.cwd }}
      env:
        ALL_TARGETS: ${{ inputs.all-targets }}
        PACKAGE: ${{ inputs.package }}
        PR_NUMBER: ${{ inputs.pr-number }}
        DRY_RUN: ${{ inputs.dry-run }}
        THRESHOLD: ${{ inputs.threshold }}
        MIN_TOKENS: ${{ inputs.min-tokens }}
      run: |
        set --
        [ "$ALL_TARGETS" = "true" ] && set -- --all-targets "$@"
        [ -n "$PACKAGE" ] && set -- --package "$PACKAGE" "$@"
        if [ -n "$PR_NUMBER" ] || [ "$DRY_RUN" = "true" ]; then
          report="$RUNNER_TEMP/dejadoc-report.json"
          cargo dejadoc \
            --threshold "$THRESHOLD" \
            --min-tokens "$MIN_TOKENS" \
            --no-fail --json "$@" > "$report"
          jq -r '. as $r | "\($r.total) doctests, \($r.unique) unique, \($r.groups | length) duplicated groups"' "$report"
          if [ "$(jq '.groups | length' "$report")" -gt 0 ]; then
            echo "found=true" >> "$GITHUB_OUTPUT"
          fi
        else
          cargo dejadoc \
            --threshold "$THRESHOLD" \
            --min-tokens "$MIN_TOKENS" \
            "$@"
        fi

    - name: Post the findings as a pull request review
      if: ${{ steps.scan.outputs.found == 'true' && (inputs.pr-number != '' || inputs.dry-run == 'true') }}
      shell: bash
      env:
        GH_TOKEN: ${{ github.token }}
        PR_NUMBER: ${{ inputs.pr-number }}
        DRY_RUN: ${{ inputs.dry-run }}
        CWD: ${{ inputs.cwd }}
      run: |
        pr="$PR_NUMBER"
        dry="$DRY_RUN"
        cwd="$CWD"
        repo="$GITHUB_REPOSITORY"
        report="$RUNNER_TEMP/dejadoc-report.json"
        body="$RUNNER_TEMP/dejadoc-review.md"
        status="$RUNNER_TEMP/dejadoc-post-status"
        inline_file="$RUNNER_TEMP/dejadoc-inline.tsv"

        # The head SHA and changed file list, empty when there is no pull request.
        head_sha=''
        changed_files=''
        if [ -n "$pr" ]; then
          head_sha="$(gh api "repos/$repo/pulls/$pr" --jq '.head.sha' 2>/dev/null)" || head_sha=''
          if [ -n "$head_sha" ]; then
            changed_files="$(gh api "repos/$repo/pulls/$pr/files" --paginate --jq '.[].filename' 2>/dev/null)" || changed_files=''
          fi
        fi
        if [ -n "$pr" ] && [ -z "$head_sha" ] && [ "$dry" != "true" ]; then
          echo "dejadoc: cannot read pull request $pr, the review is not posted"
          echo "failed" > "$status"
          exit 1
        fi
        if [ -n "$pr" ] && [ -n "$head_sha" ] && [ -n "$changed_files" ]; then
          changed_json="$(printf '%s\n' "$changed_files" | jq -R . | jq -s .)"
        else
          changed_json='[]'
        fi

        # Inline sites. A site is inline when its file is in the pull request diff.
        # The copy number is the site position in the group, the first copy is kept.
        jq -r --arg cwd "$cwd" --argjson changed "$changed_json" '
          def spath: if $cwd == "" then .file else "\($cwd)/\(.file)" end;
          . as $r
          | [ range(0; ($r.groups | length)) as $gi
              | $r.groups[$gi]
              | ($gi + 1) as $g
              | (.sites | length) as $n
              | .sites | to_entries[]
              | { g: $g, c: (.key + 1), n: $n, s: .value, p: (.value | spath) }
          ]
          | .[]
          | . as $row
          | select(($changed | index($row.p)) != null)
          | [$row.p, ($row.s.line | tostring), $row.s.item, ($row.g | tostring), ($row.c | tostring), ($row.n | tostring)]
          | @tsv' "$report" > "$inline_file"

        # The inline posts, live mode only. The first post is the permission probe.
        readonly=0
        failed_sites=''
        if [ "$dry" != "true" ] && [ -s "$inline_file" ]; then
          while IFS=$'\t' read -r p line item g c n; do
            [ -n "$p" ] || continue
            if [ "$c" = "1" ]; then
              note="Duplicated doctest, group $g, copy 1 of $n. This is the copy the dejadoc review keeps."
              post_body="$note"
              fields=(-F "line=$line" -f side=RIGHT)
            else
              close_line="$(awk -v l="$line" 'NR > l && $0 ~ /^[[:space:]]*(\/\/\/|\/\/!|\/\/)?[[:space:]]*```[[:space:]]*$/ { print NR; exit }' "$p" 2>/dev/null)" || close_line=''
              if [ -z "$close_line" ]; then
                failed_sites="$failed_sites  group $g  $p:$line  $item"$'\n'
                continue
              fi
              note="Duplicated doctest, group $g, copy $c of $n. The dejadoc review suggests removing this copy."
              post_body="$(printf '%s\n\n```suggestion\n```' "$note")"
              fields=(-F "start_line=$line" -F "line=$close_line" -f start_side=RIGHT -f side=RIGHT)
            fi
            if ! err="$(gh api "repos/$repo/pulls/$pr/comments" \
              -f "commit_id=$head_sha" -f "path=$p" "${fields[@]}" \
              -f "body=$post_body" 2>&1)"; then
              if printf '%s' "$err" | grep -qE 'HTTP 403|"status": "403"'; then
                readonly=1
                break
              fi
              failed_sites="$failed_sites  group $g  $p:$line  $item"$'\n'
            fi
          done < "$inline_file"
        fi

        # The review body, dry run only. The live review is posted without a body.
        run_url="$GITHUB_SERVER_URL/$repo/actions/runs/$GITHUB_RUN_ID"
        if [ "$dry" = "true" ]; then
          stats="$(jq -r '"\(.total) doctests, \(.unique) unique, \(.groups | length) duplicated groups"' "$report")"
          groups_block="$(jq -r --arg cwd "$cwd" '
            def sp: if $cwd == "" then .file else "\($cwd)/\(.file)" end;
            .groups | to_entries | map(
                "Group \(.key + 1), \(.value.sites | length) copies\n\n"
                + "```\n\(.value.sites[0].code)\n```\n\n"
                + (.value.sites | to_entries | map(
                    (if .key == 0 then "first  " else "copy   " end)
                    + (.value | sp) + ":" + (.value.line | tostring) + "  " + .value.item
                    + (if (.value.info | length) > 0 then "   (" + (.value.info | join(", ")) + ")" else "" end)
                  ) | join("\n")) + "\n\n"
            ) | join("")' "$report")"
          {
            echo 'Duplicated doctests found by dejadoc'
            echo 'dejadoc dry run, the review is not posted'
            echo
            printf '%s\n' "$stats"
            echo
            printf '%s' "$groups_block"
            echo
            echo
            echo "Run $run_url"
          } > "$body"
        fi

        if [ "$dry" = "true" ]; then
          { echo; cat "$body"; } >> "$GITHUB_STEP_SUMMARY"
          echo "dejadoc dry run, the review is not posted"
          cat "$body"
          echo "dry-run" > "$status"
          exit 0
        fi

        if [ ! -s "$inline_file" ]; then
          echo "dejadoc: no duplicated doctest is in the pull request diff, the review is not posted"
          if [ -n "$failed_sites" ]; then
            echo "dejadoc: the inline comments failed for the following sites"
            printf '%s' "$failed_sites"
          fi
          echo "skipped-nodiff" > "$status"
          exit 0
        fi

        readonly_notice() {
          echo "dejadoc: the GITHUB_TOKEN is read-only for this pull request, the review is not posted"
          echo "dejadoc: for pull requests from forks, enable the repository setting 'Send write tokens to workflows from pull requests', run the workflow with pull_request_target (see the security guidance), or supply a write token"
        }
        if [ "$readonly" = "1" ]; then
          readonly_notice
          echo "skipped-readonly" > "$status"
          exit 0
        fi
        if ! resp="$(gh api -X POST "repos/$repo/pulls/$pr/reviews" -f event=REQUEST_CHANGES 2>&1)"; then
          if printf '%s' "$resp" | grep -qE 'HTTP 403|"status": "403"'; then
            readonly_notice
            echo "skipped-readonly" > "$status"
            exit 0
          fi
          echo "dejadoc: posting the review failed"
          printf '%s\n' "$resp"
          echo "failed" > "$status"
          exit 1
        fi
        posted_state="$(printf '%s' "$resp" | jq -r '.state' 2>/dev/null)" || posted_state=''
        if [ "$posted_state" != "CHANGES_REQUESTED" ]; then
          echo "dejadoc: the review response is unexpected"
          printf '%s\n' "$resp"
          echo "failed" > "$status"
          exit 1
        fi
        if [ -n "$failed_sites" ]; then
          echo "dejadoc: the inline comments failed for the following sites"
          printf '%s' "$failed_sites"
        fi
        echo "dejadoc: posted the review on pull request $pr"
        echo "posted" > "$status"