name: dejadoc
description: >-
Dejadoc scans and canonicalizes your workspace's doctests and functions,
reporting any duplicates. Use it in your GitHub CI!
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'
functions:
description: Check for duplicated functions, false skips the check
required: false
default: 'true'
fn-min-tokens:
description: Skip functions with fewer tokens than this
required: false
default: ''
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: ''
install:
description: Install dejadoc, false uses the cargo dejadoc already on the path
required: false
default: 'true'
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'
only-new:
description: In pull request mode report only duplicates this pull request introduces
required: false
default: 'true'
annotations:
description: Annotate each copy to remove, which needs no token
required: false
default: 'true'
mode:
description: "review posts the findings in one job, prepare writes the payload for a later job, post consumes it"
required: false
default: 'review'
payload:
description: Directory holding the prepared review, written by prepare and read by post
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Install dejadoc
if: ${{ inputs.mode != 'post' && inputs.install == 'true' }}
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"
# Action series releases carry no binaries and GitHub marks the newest one Latest,
# so resolve the newest release that actually ships the asset instead of trusting Latest.
if [ -z "$tag" ]; then
tag="$(gh api "repos/$repo/releases?per_page=30" --paginate \
--jq ".[] | select(.draft == false and .prerelease == false) | select([.assets[].name] | index(\"$asset\")) | .tag_name" 2>/dev/null | head -n1)" || true
fi
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"
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 duplicates
id: scan
if: ${{ inputs.mode != 'post' }}
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 }}
FUNCTIONS: ${{ inputs.functions }}
FN_MIN_TOKENS: ${{ inputs.fn-min-tokens }}
ANNOTATIONS: ${{ inputs.annotations }}
CWD: ${{ inputs.cwd }}
run: |
set --
[ "$ALL_TARGETS" = "true" ] && set -- --all-targets "$@"
[ -n "$PACKAGE" ] && set -- --package "$PACKAGE" "$@"
[ "$FUNCTIONS" != "true" ] && set -- --no-functions "$@"
[ -n "$FN_MIN_TOKENS" ] && set -- --fn-min-tokens "$FN_MIN_TOKENS" "$@"
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.functions // 0) functions, \($r.unique_functions // 0) unique functions, \($r.groups | length) duplicated groups"' "$report"
# The whole scan in the run summary, which needs no token, so a
# fork pull request has it whatever the review can do.
{
jq -r '"### dejadoc\n\n\(.total) doctests, \(.unique) unique, \(.functions // 0) functions, \(.unique_functions // 0) unique functions, \(.groups | length) duplicated groups"' "$report"
jq -r --arg cwd "$CWD" '
def sp: if $cwd == "" then .file else "\($cwd)/\(.file)" end;
.groups[] | "\n- group `\(.id)`, \(.kind // "doctest"), \(.sites | length) copies\n"
+ (.sites | map(" - `" + sp + ":" + (.line | tostring) + "` " + .item)
| join("\n"))' "$report"
} >> "$GITHUB_STEP_SUMMARY"
if [ "$(jq '.groups | length' "$report")" -gt 0 ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
fi
else
out="$RUNNER_TEMP/dejadoc-gate.txt"
status=0
gate=()
[ "$ANNOTATIONS" = "true" ] && gate+=(--github)
cargo dejadoc \
--threshold "$THRESHOLD" \
--min-tokens "$MIN_TOKENS" \
"${gate[@]}" "$@" > "$out" || status=$?
# The scan reports paths relative to the workspace root cargo
# resolved, GitHub resolves an annotation path from the repository
# root.
root="$(cargo metadata --no-deps --format-version 1 2>/dev/null | jq -r '.workspace_root')" || root=''
prefix=''
if [ -n "$root" ] && [ -n "${GITHUB_WORKSPACE:-}" ]; then
prefix="$(realpath --relative-to="$GITHUB_WORKSPACE" "$root" 2>/dev/null)" || prefix=''
[ "$prefix" = "." ] && prefix=''
fi
if [ -n "$prefix" ]; then
while IFS= read -r line; do
case "$line" in
::*" file="*) printf '%s file=%s/%s\n' "${line%% file=*}" "$prefix" "${line#* file=}" ;;
*) printf '%s\n' "$line" ;;
esac
done < "$out" > "$out.rooted"
mv "$out.rooted" "$out"
fi
cat "$out"
{
echo '### dejadoc'
echo
echo '```'
grep -v '^::' "$out"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
exit "$status"
fi
- name: Scan the base commit
if: ${{ inputs.pr-number != '' && inputs.only-new == 'true' && inputs.mode != 'post' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ inputs.pr-number }}
CWD: ${{ inputs.cwd }}
ALL_TARGETS: ${{ inputs.all-targets }}
PACKAGE: ${{ inputs.package }}
THRESHOLD: ${{ inputs.threshold }}
MIN_TOKENS: ${{ inputs.min-tokens }}
FUNCTIONS: ${{ inputs.functions }}
FN_MIN_TOKENS: ${{ inputs.fn-min-tokens }}
run: |
# The base report lets the review ignore duplicates that already exist on the
# base commit. Any failure degrades to reviewing the full report.
base_sha="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.base.sha' 2>/dev/null)" || base_sha=''
if [ -z "$base_sha" ]; then
echo "dejadoc: cannot read the base commit, the full report is reviewed"
exit 0
fi
if ! git -C "$GITHUB_WORKSPACE" fetch -q --depth 1 origin "$base_sha" \
|| ! git -C "$GITHUB_WORKSPACE" worktree add -q --detach "$RUNNER_TEMP/dejadoc-base" "$base_sha"; then
echo "dejadoc: cannot check out the base commit, the full report is reviewed"
exit 0
fi
printf '%s\n' "$base_sha" > "$RUNNER_TEMP/dejadoc-base-sha"
dir="$RUNNER_TEMP/dejadoc-base"
[ -n "$CWD" ] && dir="$dir/$CWD"
set --
[ "$ALL_TARGETS" = "true" ] && set -- --all-targets "$@"
[ -n "$PACKAGE" ] && set -- --package "$PACKAGE" "$@"
[ "$FUNCTIONS" != "true" ] && set -- --no-functions "$@"
[ -n "$FN_MIN_TOKENS" ] && set -- --fn-min-tokens "$FN_MIN_TOKENS" "$@"
# Threshold 1 inventories every base site, a group the pull request
# completes from a single existing copy must still be reported.
if ! (cd "$dir" && cargo dejadoc \
--threshold 1 \
--min-tokens "$MIN_TOKENS" \
--no-fail --json "$@") > "$RUNNER_TEMP/dejadoc-base-report.json"; then
rm -f "$RUNNER_TEMP/dejadoc-base-report.json"
echo "dejadoc: the base scan failed, the full report is reviewed"
fi
- name: Review the findings on the pull request
if: ${{ inputs.mode == 'post' || (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 }}
ANNOTATIONS: ${{ inputs.annotations }}
MODE: ${{ inputs.mode }}
PAYLOAD: ${{ inputs.payload }}
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"
base_report="$RUNNER_TEMP/dejadoc-base-report.json"
body="$RUNNER_TEMP/dejadoc-review.md"
status="$RUNNER_TEMP/dejadoc-post-status"
inline_file="$RUNNER_TEMP/dejadoc-inline.tsv"
mode="$MODE"
payload="${PAYLOAD:-$RUNNER_TEMP/dejadoc-payload}"
comments="$payload/comments.ndjson"
meta="$payload/review.json"
mkdir -p "$payload"
failed_sites=''
out_of_range=''
onlynew=0
[ -s "$base_report" ] && onlynew=1
base_sha="$(cat "$RUNNER_TEMP/dejadoc-base-sha" 2>/dev/null || echo '')"
# Post mode consumes a payload another job prepared, so it reads no
# source tree and skips straight to the api calls.
if [ "$mode" = "post" ]; then
if [ ! -s "$meta" ]; then
echo "dejadoc: the prepared review is missing from $payload"
echo "failed" > "$status"
exit 1
fi
pr="$(jq -r '.pr' "$meta")"
head_sha="$(jq -r '.head_sha' "$meta")"
out_of_range="$(jq -r '.out_of_range' "$meta")"
findings="$(jq -r '.findings' "$meta")"
findings_span=0
grep -q ' on another type$' <<< "$findings" && findings_span=1
else
# 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
# Base site identity, a site exists on the base when the same hash, item and
# path were already there, and the first base copy is the kept original.
if [ "$onlynew" = "1" ]; then
base_json="$(jq --arg cwd "$cwd" '
def sp: if $cwd == "" then .file else "\($cwd)/\(.file)" end;
{ keys: [.groups[] | .hash as $h | .sites[] | "\($h)\t\(.item)\t\(sp)"],
keep: (reduce .groups[] as $gr ({};
.[$gr.hash] = (($gr.sites[0] | sp) + ":" + ($gr.sites[0].line | tostring)))),
keep_end: (reduce .groups[] as $gr ({}; .[$gr.hash] = ($gr.sites[0].end // null))),
keep_item: (reduce .groups[] as $gr ({}; .[$gr.hash] = $gr.sites[0].item)) }' "$base_report")"
else
base_json='{"keys": [], "keep": {}, "keep_end": {}, "keep_item": {}}'
fi
# Sites to comment on. In only-new mode a site must be introduced by this
# pull request, a new hash, item or path relative to the base report. A
# site is inline when its file is in the pull request diff.
onlynew_mode=false
[ "$onlynew" = "1" ] && onlynew_mode=true
# One command line argument holds at most 128 KiB, a large pull request's
# file list exceeds it, so jq reads both lists from files.
changed_file="$RUNNER_TEMP/dejadoc-changed.json"
base_file="$RUNNER_TEMP/dejadoc-base-keys.json"
printf '%s' "$changed_json" > "$changed_file"
printf '%s' "$base_json" > "$base_file"
jq -r --arg cwd "$cwd" --slurpfile changed "$changed_file" --slurpfile base "$base_file" \
--argjson onlynew_mode "$onlynew_mode" '
def spath: if $cwd == "" then .file else "\($cwd)/\(.file)" end;
$changed[0] as $changed
| $base[0] as $base
| . as $r
| [ range(0; ($r.groups | length)) as $gi
| $r.groups[$gi]
| ($gi + 1) as $g
| (.sites | length) as $n
| .hash as $h
| ((.sites[0] | spath) + ":" + (.sites[0].line | tostring)) as $first
| (.sites[0].end // null) as $fend
| (.kind // "doctest") as $kind
| ([.sites[].self_type] as $t | ($t[0]) as $f | ($t[1:] | map(. != $f) | any)) as $spans
| (reduce (.sites[] | (.self_type // "free function")) as $t ([]; if index($t) == null then . + [$t] else . end) | join(", ")) as $selftypes
| (.sites[0].item) as $firstitem
| ($base.keep[$h] // "") as $basekeep
| ($base.keep_item[$h] // "") as $baseitem
| ([ .sites[] | ([$h, .item, (spath)] | join("\t")) ]) as $ks
| [ .sites | to_entries[]
| (([$h, .value.item, (.value | spath)] | join("\t")) as $k
| (([ $ks[: .key ][] | select(. == $k) ] | length) + 1) as $idx
| ([ $base.keys[] | select(. == $k) ] | length) as $bcount
| { g: $g, c: (.key + 1), n: $n, first: $first, fend: $fend, s: .value,
p: (.value | spath),
isNew: ($idx > $bcount),
basekeep: $basekeep, bend: ($base.keep_end[$h] // null),
kind: $kind, spans: $spans, selftypes: $selftypes, firstitem: $firstitem,
baseitem: $baseitem }) ] as $rows
| ($rows | map(select(.isNew)) | length) as $knew
| $rows[] | . + { knew: $knew }
]
| .[]
| . as $row
| select(($changed | index($row.p)) != null)
| select(if $onlynew_mode then $row.isNew else true end)
| [$row.p, (.s.line | tostring), .s.item, (.g | tostring), (.c | tostring),
(.n | tostring), .first, (if .isNew then "1" else "0" end),
(if .basekeep == "" then "-" else .basekeep end), (.knew | tostring),
(.s.end // "-" | tostring), (.fend // "-" | tostring), (.bend // "-" | tostring),
.kind, (if .spans then "1" else "0" end), .firstitem,
(if .baseitem == "" then "-" else .baseitem end), .selftypes]
| @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)'
# A permalink to lines $3 to $4 of path $2 at commit $1, GitHub shows
# and highlights those lines. Without a sha it falls back to plain text.
blob_link() {
local anchor label
if [ -n "${4:-}" ] && [ "$4" != "$3" ]; then
anchor="#L$3-L$4"
label="$3-$4"
else
anchor="#L$3"
label="$3"
fi
if [ -n "$1" ]; then
printf '[`%s:%s`](%s/%s/blob/%s/%s%s)' "$2" "$label" "$GITHUB_SERVER_URL" "$repo" "$1" "$2" "$anchor"
else
printf '`%s:%s`' "$2" "$label"
fi
}
# The bare permalink url GitHub expands into a code preview of the
# cited lines, empty when no commit is known.
blob_url() {
[ -n "$1" ] || return 0
if [ -n "${4:-}" ] && [ "$4" != "$3" ]; then
printf '%s/%s/blob/%s/%s#L%s-L%s' "$GITHUB_SERVER_URL" "$repo" "$1" "$2" "$3" "$4"
else
printf '%s/%s/blob/%s/%s#L%s' "$GITHUB_SERVER_URL" "$repo" "$1" "$2" "$3"
fi
}
# The inline text location of a block, `path:start` or `path:start-end`.
loc_text() {
if [ -n "${3:-}" ] && [ "$3" != "$2" ]; then
printf '`%s:%s-%s`' "$1" "$2" "$3"
else
printf '`%s:%s`' "$1" "$2"
fi
}
# The permalink wrapped in a markdown link when it exists.
loc_md() {
if [ -n "$2" ]; then
printf '[%s](%s)' "$1" "$2"
else
printf '%s' "$1"
fi
}
# The bare url GitHub previews as code, collapsed in a details block
# when the block spans more than ten lines.
preview_of() {
local n="${3:-$2}" label="${4:-the duplicated doctest}"
if [ $((n - $2 + 1)) -gt 10 ]; then
printf '<details>\n<summary>Show %s</summary>\n\n%s\n\n</details>' "$label" "$1"
else
printf '%s' "$1"
fi
}
# Workflow command escaping, a property also ends at `,` or `:`.
escape_data() {
local s="${1//%/%25}"
s="${s//$'\r'/%0D}"
printf '%s' "${s//$'\n'/%0A}"
}
escape_property() {
local s
s="$(escape_data "$1")"
s="${s//:/%3A}"
printf '%s' "${s//,/%2C}"
}
rendered=''
findings=''
findings_span=0
# One pass builds every comment, live posts them, the dry run renders them.
# The end line of each block comes from the report, `-` when the crate has
# none, a block spanning an include or an escaped literal, or an older binary.
declare -A seen_first=() seen_first_end=()
while IFS=$'\t' read -r p line item g c n first new basekeep knew end fend bend kind spans firstitem baseitem selftypes; do
[ -n "$p" ] || continue
site="$p:$line"
[ "$basekeep" = "-" ] && basekeep=''
[ "$end" = "-" ] && end=''
[ "$fend" = "-" ] && fend=''
[ "$bend" = "-" ] && bend=''
[ "$baseitem" = "-" ] && baseitem=''
kept_item="$firstitem"
[ -n "$baseitem" ] && kept_item="$baseitem"
if [ "$new" = "1" ] && [ -z "${seen_first[$g]:-}" ]; then
seen_first[$g]="$site"
seen_first_end[$g]="$end"
fi
if [ "$onlynew" = "1" ]; then
if [ -n "$basekeep" ] || [ "$site" != "${seen_first[$g]}" ]; then removable=1; else removable=0; fi
elif [ "$c" != "1" ]; then
removable=1
else
removable=0
fi
keepat="$first"
keepat_end="$fend"
if [ "$new" = "1" ]; then
keepat="${seen_first[$g]:-$first}"
keepat_end="${seen_first_end[$g]:-$fend}"
fi
# Comments only land on copies the author should act on, a kept
# copy stays silent.
[ "$removable" = "1" ] || continue
# The annotation carries the same finding without a token, so a
# fork pull request shows it anchored in the diff.
if [ "$ANNOTATIONS" = "true" ] && [ "$dry" != "true" ]; then
keep="$basekeep"
[ -z "$keep" ] && keep="$keepat"
if [ "$kind" = "function" ]; then
if [ "$spans" = "1" ]; then
printf '::error file=%s,line=%s,title=dejadoc::%s repeats %s at %s on another type, a generic or a macro can share it, group %s\n' \
"$(escape_property "$p")" "$line" "$(escape_data "$item")" "$(escape_data "$kept_item")" "$(escape_data "$keep")" "$g"
findings="$findings- \`$p:$line\` $item, repeats \`$kept_item\` at \`$keep\` on another type"$'\n'
findings_span=1
else
printf '::error file=%s,line=%s,title=dejadoc::%s duplicates the function %s at %s, group %s\n' \
"$(escape_property "$p")" "$line" "$(escape_data "$item")" "$(escape_data "$kept_item")" "$(escape_data "$keep")" "$g"
findings="$findings- \`$p:$line\` $item, duplicates the function \`$kept_item\` at \`$keep\`"$'\n'
fi
else
printf '::error file=%s,line=%s,title=dejadoc::%s duplicates the doctest at %s, group %s\n' \
"$(escape_property "$p")" "$line" "$(escape_data "$item")" "$(escape_data "$keep")" "$g"
findings="$findings- \`$p:$line\` $item, duplicates \`$keep\`"$'\n'
fi
fi
close_line="$end"
if [ -z "$close_line" ]; then
out_of_range="$out_of_range- group $g $(blob_link "$head_sha" "$p" "$line") $item"$'\n'
continue
fi
# The suggestion deletes the block plus the empty line right after
# it, which would become a stray once the copy is gone.
delete_end="$close_line"
if [ "$kind" != "function" ] || [ "$spans" != "1" ]; then
nx="$(awk -v l="$close_line" 'NR == l + 1 { print "x" $0; exit }' "$p" 2>/dev/null)"
if [ -n "$nx" ] && [ -z "$(printf '%s' "${nx#x}" | tr -d '[:space:]')" ]; then
delete_end=$((close_line + 1))
fi
fi
url=''
loc=''
pv_s=''
pv_e=''
if [ "$onlynew" = "1" ] && [ -n "$basekeep" ]; then
bp="${basekeep%:*}"
bl="${basekeep##*:}"
ce="$bend"
url="$(blob_url "$base_sha" "$bp" "$bl" "$ce")"
pv_s="$bl"
pv_e="$ce"
loc="$(loc_md "$(loc_text "$bp" "$bl" "$ce")" "$url")"
elif [ "$onlynew" = "1" ]; then
kp="${keepat%:*}"
kl="${keepat##*:}"
ce="$keepat_end"
url="$(blob_url "$head_sha" "$kp" "$kl" "$ce")"
pv_s="$kl"
pv_e="$ce"
loc="$(loc_md "$(loc_text "$kp" "$kl" "$ce")" "$url")"
else
fp="${first%:*}"
fl="${first##*:}"
ce="$fend"
url="$(blob_url "$head_sha" "$fp" "$fl" "$ce")"
pv_s="$fl"
pv_e="$ce"
loc="$(loc_md "$(loc_text "$fp" "$fl" "$ce")" "$url")"
fi
dejadoc_link='[dejadoc](https://github.com/LucaCappelletti94/cargo-dejadoc)'
if [ "$kind" = "function" ]; then
if [ "$spans" = "1" ]; then
preview='the repeated function'
else
preview='the duplicated function'
fi
else
preview='the duplicated doctest'
fi
if [ "$kind" = "function" ] && [ "$spans" = "1" ]; then
note="This method repeats \`$kept_item\` at $loc on another type ($selftypes). A generic or a macro can share one body."
elif [ "$kind" = "function" ]; then
if [ "$onlynew" = "1" ] && [ -n "$basekeep" ]; then
note="This function was added by this pull request and duplicates a copy that already exists at $loc, $dejadoc_link suggests deleting the added copy, or updating it if it was meant to differ."
elif [ "$onlynew" = "1" ]; then
note="This pull request adds $knew copies of the same function, $dejadoc_link suggests keeping the first added copy at $loc and deleting this one, or updating it if it was meant to differ."
else
note="This function appears $n times in this module, this is copy $c. $dejadoc_link suggests keeping the first copy at $loc and deleting this one, or updating it if it was meant to differ."
fi
elif [ "$onlynew" = "1" ] && [ -n "$basekeep" ]; then
note="This doctest was added by this pull request and duplicates a copy that already exists at $loc, [dejadoc](https://github.com/LucaCappelletti94/cargo-dejadoc) suggests removing the added copy."
elif [ "$onlynew" = "1" ]; then
note="This pull request adds $knew copies of the same doctest, [dejadoc](https://github.com/LucaCappelletti94/cargo-dejadoc) suggests keeping the first added copy at $loc and removing this one."
else
note="This doctest appears $n times in this scan, this is copy $c. [dejadoc](https://github.com/LucaCappelletti94/cargo-dejadoc) suggests keeping the first copy at $loc and removing this one."
fi
comment="$note"
[ -n "$url" ] && comment="$comment"$'\n\n'"$(preview_of "$url" "$pv_s" "$pv_e" "$preview")"
if [ "$kind" != "function" ] || [ "$spans" != "1" ]; then
comment="$comment"$'\n\n```suggestion\n```'
fi
comment="$comment"$'\n\n'"$(report_link "$g") $support_link"
if [ "$dry" = "true" ]; then
rendered+="$comment"$'\n\n'
continue
fi
# One row per comment, so a later job can post it without the
# source tree it was computed from.
jq -cn --arg path "$p" --arg start "$line" --arg stop "$delete_end" \
--arg group "$g" --arg item "$item" --arg close "$close_line" --arg body "$comment" \
'{path: $path, start_line: ($start | tonumber), line: ($stop | tonumber),
group: $group, item: $item, close_line: ($close | tonumber), body: $body}' \
>> "$comments"
done < "$inline_file"
run_url="$GITHUB_SERVER_URL/$repo/actions/runs/$GITHUB_RUN_ID"
if [ "$dry" = "true" ]; then
stats="$(jq -r '"\(.total) doctests, \(.unique) unique, \(.functions // 0) functions, \(.unique_functions // 0) unique functions, \(.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.kind // "doctest"), \(.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")"
{
title='Duplicated doctests found by dejadoc'
if [ "$(jq '[.groups[] | select(.kind == "function")] | length' "$report")" -gt 0 ]; then
title='Duplicates found by dejadoc'
fi
printf '%s\n' "$title"
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' "$rendered"
if [ -n "$out_of_range" ]; then
echo
echo 'Sites outside the lines GitHub shows in the diff, the review body would list them'
echo
printf '%s' "$out_of_range"
fi
echo
echo
echo "Run $run_url"
} > "$body"
{ echo; cat "$body"; } >> "$GITHUB_STEP_SUMMARY"
echo "dejadoc dry run, the review is not posted"
cat "$body"
echo "dry-run" > "$status"
exit 0
fi
# Both lists grow with the pull request, past one argument's 128 KiB.
printf '%s' "$out_of_range" > "$RUNNER_TEMP/dejadoc-out-of-range.txt"
printf '%s' "$findings" > "$RUNNER_TEMP/dejadoc-findings.txt"
jq -n --arg pr "$pr" --arg head "$head_sha" \
--rawfile oor "$RUNNER_TEMP/dejadoc-out-of-range.txt" --rawfile f "$RUNNER_TEMP/dejadoc-findings.txt" \
'{pr: $pr, head_sha: $head, out_of_range: $oor, findings: $f}' > "$meta"
touch "$comments"
fi
# Nothing to post, in prepare mode the payload still exists so the
# posting job has something to download.
if [ ! -s "$comments" ] && [ -z "$out_of_range" ]; then
if [ "$onlynew" = "1" ]; then
echo "dejadoc: no duplicate was introduced by this pull request, the review is not posted"
echo "skipped-existing" > "$status"
else
echo "dejadoc: no duplicate is in the pull request diff, the review is not posted"
echo "skipped-nodiff" > "$status"
fi
exit 0
fi
# The reported copies in the run summary, beside the whole scan the
# scan step wrote there.
if [ -n "$findings" ] && [ "$dry" != "true" ]; then
findings_title='#### Copies to remove'
[ "$findings_span" = "1" ] && findings_title='#### Findings'
{
echo
printf '%s\n' "$findings_title"
echo
printf '%s' "$findings"
} >> "$GITHUB_STEP_SUMMARY"
fi
if [ "$mode" = "prepare" ]; then
echo "dejadoc: the review is prepared in $payload for a later job"
echo "prepared" > "$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: the annotations and the run summary carry the findings, the removal suggestion needs a review comment"
}
# The first post is the permission probe.
readonly=0
while IFS= read -r row; do
[ -n "$row" ] || continue
p="$(printf '%s' "$row" | jq -r '.path')"
line="$(printf '%s' "$row" | jq -r '.start_line')"
if ! err="$(printf '%s' "$row" | jq -r '.body' | gh api "repos/$repo/pulls/$pr/comments" \
-f "commit_id=$head_sha" -f "path=$p" \
-F "start_line=$line" -F "line=$(printf '%s' "$row" | jq -r '.line')" \
-f start_side=RIGHT -f side=RIGHT -F 'body=@-' 2>&1)"; then
if printf '%s' "$err" | grep -qE 'HTTP 403|"status": "403"'; then
readonly=1
break
fi
item="$(printf '%s' "$row" | jq -r '.item')"
g="$(printf '%s' "$row" | jq -r '.group')"
printf 'dejadoc: %s:%s %s\n' "$p" "$line" "$item"
printf '%s\n' "$err" | sed 's/^/dejadoc: /'
if printf '%s' "$err" | grep -qE 'HTTP 422|"status": "422"'; then
out_of_range="$out_of_range- group $g $(printf '`%s:%s`' "$p" "$line") $item"$'\n'
else
failed_sites="$failed_sites group $g $p:$line $item"$'\n'
fi
fi
done < "$comments"
if [ "$readonly" = "1" ]; then
readonly_notice
echo "skipped-readonly" > "$status"
exit 0
fi
# A review with no comment of its own needs a body. Sites GitHub could not
# anchor inline are listed with permalinks to the head commit.
review=(-f event=REQUEST_CHANGES)
review_body=''
if [ -n "$out_of_range" ]; then
review_body="$(printf 'These duplicates are not on lines GitHub shows in this pull request diff, the inline comment cannot attach there. The links point at the copies on the head commit.\n\n%s' "$out_of_range")"
fi
if [ -n "$failed_sites" ]; then
failed_block="$(printf '\n\nDuplicates dejadoc could not comment inline\n\n```\n%s```' "$failed_sites")"
review_body="$review_body$failed_block"
fi
if [ -n "$review_body" ]; then
review+=(-f "body=$review_body")
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"