dejadoc 0.1.2

Find duplicated Rust doctests across a workspace
Documentation
name: dejadoc
description: >-
  Install dejadoc from crates.io, 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
      run: |
        if [ -n "${{ inputs.version }}" ]; then
          cargo install dejadoc --version "${{ inputs.version }}"
        else
          cargo install dejadoc
        fi

    - name: Scan for duplicated doctests
      id: scan
      shell: bash
      working-directory: ${{ inputs.cwd }}
      run: |
        set --
        [ "${{ inputs.all-targets }}" = "true" ] && set -- --all-targets "$@"
        [ -n "${{ inputs.package }}" ] && set -- --package "${{ inputs.package }}" "$@"
        if [ -n "${{ inputs.pr-number }}" ] || [ "${{ inputs.dry-run }}" = "true" ]; then
          report="$RUNNER_TEMP/dejadoc-report.json"
          cargo dejadoc \
            --threshold "${{ inputs.threshold }}" \
            --min-tokens "${{ inputs.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 "${{ inputs.threshold }}" \
            --min-tokens "${{ inputs.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 }}
      run: |
        pr="${{ inputs.pr-number }}"
        dry="${{ inputs.dry-run }}"
        cwd="${{ inputs.cwd }}"
        repo="$GITHUB_REPOSITORY"
        report="$RUNNER_TEMP/dejadoc-report.json"
        body="$RUNNER_TEMP/dejadoc-review.md"
        status="$RUNNER_TEMP/dejadoc-post-status"
        plan_file="$RUNNER_TEMP/dejadoc-plan.tsv"
        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"