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"
# Keep these targets in sync with the release matrix in .github/workflows/release.yml.
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 err="$(gh release download "${args[@]}" 2>&1)"; 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
echo "dejadoc: could not download $asset, falling back to source"
printf '%s\n' "$err" | sed 's/^/dejadoc: /'
else
echo "dejadoc: no prebuilt binary for $RUNNER_OS/$RUNNER_ARCH, falling back to source"
fi
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: |
# No set -e here. Each step checks its own status and writes an outcome file, so a
# failed api call degrades to a skipped review instead of aborting the 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 [ "$dry" = "true" ]; then
# A dry run renders the review as if every scanned site were in
# the diff, so the step summary shows each comment in full.
changed_json="$(jq -r --arg cwd "$cwd" '
def sp: if $cwd == "" then .file else "\($cwd)/\(.file)" end;
[.groups[].sites[] | sp] | unique' "$report")"
elif [ -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[0] | spath) + ":" + (.sites[0].line | tostring)) as $first
| .sites | to_entries[]
| { g: $g, c: (.key + 1), n: $n, first: $first, 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), $row.first]
| @tsv' "$report" > "$inline_file"
# The inline posts, live mode only. The first post is the permission probe.
# Each comment links to a prefilled wrong-review issue for its group.
uri() { jq -rn --arg s "$1" '$s | @uri'; }
version="$(cargo dejadoc --version | awk '{ print $NF }')"
report_link() {
local sites
sites="$(jq -r --arg cwd "$cwd" --argjson g "$1" '
def sp: if $cwd == "" then .file else "\($cwd)/\(.file)" end;
.groups[$g - 1].sites as $s
| [$s[:12][] | "\(sp):\(.line) \(.item)"]
+ (if ($s | length) > 12 then ["and \(($s | length) - 12) more"] else [] end)
| join("\n")' "$report")"
printf '[👎 Report a wrong review](https://github.com/LucaCappelletti94/cargo-dejadoc/issues/new?template=wrong-review.yml&title=%s&version=%s&review=%s&group=%s)' \
"$(uri "Wrong review on $repo#$pr, group $1")" \
"$(uri "$version")" \
"$(uri "$GITHUB_SERVER_URL/$repo/pull/$pr")" \
"$(uri "$sites")"
}
support_link='[❤️ Support dejadoc](https://github.com/sponsors/LucaCappelletti94)'
# The line closing the doctest opened at line $2 of file $1, empty
# when no closing fence is found, the live posting eligibility.
close_line_of() {
awk -v l="$2" 'NR > l && $0 ~ /^[[:space:]]*(\/\/\/|\/\/!|\/\/)?[[:space:]]*```[[:space:]]*$/ { print NR; exit }' "$1" 2>/dev/null
}
comment_body() {
local g="$1" c="$2" n="$3" first="$4" note body
if [ "$c" = "1" ]; then
note="This doctest appears $n times in this scan, [dejadoc](https://github.com/LucaCappelletti94/cargo-dejadoc) keeps this first copy and suggests removing the rest."
body="$note"
else
note="This doctest appears $n times in this scan, this is copy $c. [dejadoc](https://github.com/LucaCappelletti94/cargo-dejadoc) keeps the first copy at \`$first\` and suggests removing this one."
body="$note"$'\n\n```suggestion\n```'
fi
printf '%s\n\n%s %s' "$body" "$(report_link "$g")" "$support_link"
}
readonly=0
failed_sites=''
if [ "$dry" != "true" ] && [ -s "$inline_file" ]; then
while IFS=$'\t' read -r p line item g c n first; do
[ -n "$p" ] || continue
post_body="$(comment_body "$g" "$c" "$n" "$first")"
if [ "$c" = "1" ]; then
fields=(-F "line=$line" -f side=RIGHT)
else
close_line="$(close_line_of "$p" "$line")"
if [ -z "$close_line" ]; then
failed_sites="$failed_sites group $g $p:$line $item"$'\n'
continue
fi
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")"
would_block=''
while IFS=$'\t' read -r p line item g c n first; do
[ -n "$p" ] || continue
if [ "$c" != "1" ] && [ -z "$(close_line_of "$p" "$line")" ]; then
would_block+="### $p:$line $item"$'\n\n'"dejadoc cannot locate the end of this doctest, the site would be listed in the review body instead."$'\n\n'
continue
fi
would_block+="### $p:$line $item"$'\n\n'"$(comment_body "$g" "$c" "$n" "$first")"$'\n\n'
done < "$inline_file"
{
echo 'Duplicated doctests found by dejadoc'
echo 'dejadoc dry run, the review is not posted'
echo
printf '%s\n' "$stats"
echo
printf '%s\n\n' "$groups_block"
echo 'Inline comments dejadoc would post'
echo
printf '%s' "$would_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: to review fork pull requests, trigger on pull_request_target and check out the pull request head, or on a private repository enable 'Send write tokens to workflows from pull requests'"
}
if [ "$readonly" = "1" ]; then
readonly_notice
echo "skipped-readonly" > "$status"
exit 0
fi
# A review with no comment of its own needs a body, so the sites that
# could not be commented inline go there.
review=(-f event=REQUEST_CHANGES)
if [ -n "$failed_sites" ]; then
review+=(-f "body=$(printf 'Duplicated doctests dejadoc could not comment inline\n\n```\n%s```' "$failed_sites")")
fi
if ! resp="$(gh api -X POST "repos/$repo/pulls/$pr/reviews" "${review[@]}" 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"