diff --git a/scripts/dependabot-digest/render.sh b/scripts/dependabot-digest/render.sh index e8f5df1..9922282 100755 --- a/scripts/dependabot-digest/render.sh +++ b/scripts/dependabot-digest/render.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Render classify.sh output as the Markdown body of the digest issue. # -# Usage: render.sh [--run-url URL] < classified.ndjson > body.md +# Usage: render.sh [--run-url URL] [--standards FILE] < classified.ndjson > body.md +# --standards takes standards.sh output. # # The body leads with what a human must do and states, per PR, the fact that # put it in its bucket. It never says a PR will merge: the merge path runs @@ -11,9 +12,11 @@ set -uo pipefail unset CDPATH run_url="" +standards_file="" while [[ $# -gt 0 ]]; do case "$1" in --run-url) run_url="${2-}"; shift 2 ;; + --standards) standards_file="${2-}"; shift 2 ;; *) echo "render.sh: unknown argument: $1" >&2; exit 2 ;; esac done @@ -118,6 +121,99 @@ else fi fi +# dev-env#179. Keep three outcomes apart: warnings, clean, not checked. +render_standards() { + local file="$1" recs checked clean warned unchecked archived rows + echo "## Standards warnings" + echo + if [[ ! -r "${file}" ]]; then + echo "**Not checked.** The standards survey produced no readable result, so nothing below the Dependabot queue was verified." + echo + return 0 + fi + # jq stops at a malformed line, dropping every repo after it. + if ! jq -e -s 'type == "array"' "${file}" >/dev/null 2>&1; then + echo "**Not checked.** The standards survey output is malformed, so nothing was verified." + echo + return 0 + fi + recs="$(jq -c 'select(type == "object" and (.state | type == "string"))' "${file}" 2>/dev/null)" + if [[ -z "${recs}" ]]; then + echo "**Not checked.** The standards survey returned no repositories, so nothing was verified." + echo + return 0 + fi + echo "Warning- and notice-level annotations from each repository's latest \`standards-check\` run that vetted its default branch. Fleet callers run only on pull requests, so that is usually the run on the head commit of the PR that produced the default-branch head. A PR run lints only the files that PR changed (node-floor always checks the whole repo), so \"no warnings\" means that run was clean, not that the whole repository is." + echo + checked="$(jq -s '[.[] | select(.state == "ok")] | length' <<<"${recs}")" + warned="$(jq -s '[.[] | select(.state == "ok" and (.annotations | length) > 0)] | length' <<<"${recs}")" + clean=$((checked - warned)) + unchecked="$(jq -s '[.[] | select(.state != "ok" and .state != "archived")] | length' <<<"${recs}")" + echo "**${checked} repositories checked**: ${warned} with warnings or notices, ${clean} with none. **${unchecked} not checked.**" + echo + + if [[ "${checked}" -eq 0 ]]; then + # Zero warnings out of zero repositories read is not "no warnings". + echo "**No repository could be checked**, so no warnings were looked for." + echo + elif [[ "${warned}" -eq 0 ]]; then + echo "No warnings: no checked repository's latest run carried a warning or notice annotation." + echo + else + # A failed jq prints nothing; an empty table would read as clean. + if ! rows="$(jq -r ' + def cell: tostring | gsub("[\r\n]+"; " ") | gsub("\\|"; "\\|"); + # GitHub puts file-less annotations under path .github. + def where: if .path == "" or .path == ".github" then "—" + else "`" + .path + (if .line then ":" + (.line | tostring) else "" end) + "`" end; + select(.state == "ok") | . as $r | .annotations[] + | "| " + (if $r.runUrl then "[" + $r.repo + "](" + $r.runUrl + ")" else $r.repo end) + + " | " + .level + " | " + where + " | " + (.message | cell) + " |" + ' <<<"${recs}")" || [[ -z "${rows}" ]]; then + echo "**Warnings found but could not be rendered.** ${warned} repositories carry annotations; see the run log." + echo + else + echo "| Repo | Level | File | Message |" + echo "| --- | --- | --- | --- |" + echo "${rows}" + echo + fi + fi + + if [[ "${unchecked}" -gt 0 ]]; then + echo "### Not checked" + echo + echo "These repositories have no readable standards-check result. Their warnings, if any, are unknown — not absent." + echo + if ! rows="$(jq -r ' + def cell: tostring | gsub("[\r\n]+"; " ") | gsub("\\|"; "\\|"); + def state_name: {"unreadable": "UNREADABLE", "unlisted": "OWNER NOT LISTED", + "no-run": "no standards-check run", "no-pr": "no run, no merged PR", + "in-progress": "run in progress"}[.state] // .state; + select(.state != "ok" and .state != "archived") + | "| " + (.repo // ("all of " + .owner)) + " | " + state_name + ": " + (.detail | cell) + " |" + ' <<<"${recs}")" || [[ -z "${rows}" ]]; then + echo "**${unchecked} repositories were not checked, and the list could not be rendered.** See the run log." + echo + else + echo "| Repo | Why |" + echo "| --- | --- |" + echo "${rows}" + echo + fi + fi + + archived="$(jq -rs '[.[] | select(.state == "archived") | .repo] | join(", ")' <<<"${recs}")" + if [[ -n "${archived}" ]]; then + echo "Archived, not surveyed: ${archived}." + echo + fi +} + +if [[ -n "${standards_file}" ]]; then + render_standards "${standards_file}" +fi + echo "---" echo generated_at="$(date -u '+%Y-%m-%d %H:%M UTC')" diff --git a/scripts/dependabot-digest/run-digest.sh b/scripts/dependabot-digest/run-digest.sh index 7d873dd..f7c121c 100755 --- a/scripts/dependabot-digest/run-digest.sh +++ b/scripts/dependabot-digest/run-digest.sh @@ -45,6 +45,8 @@ work="$(mktemp -d)" trap 'rm -rf "${work}"' EXIT collected="${work}/collected.ndjson" : >"${collected}" +standards="${work}/standards.ndjson" +: >"${standards}" issue_token="${GH_TOKEN-}" @@ -67,6 +69,14 @@ for owner in ${OWNERS}; do echo "run-digest.sh: collection failed for ${owner}; not publishing a partial digest" >&2 exit 1 fi + + # Standards never block the queue report. A crash renders as not checked. + echo "run-digest.sh: reading standards-check annotations for ${owner}" >&2 + if ! GH_TOKEN="${token}" bash "${HERE}/standards.sh" "${owner}" >>"${standards}"; then + jq -cn --arg o "${owner}" '{owner: $o, repo: null, state: "unlisted", + detail: "standards.sh failed; see the run log", source: null, + runUrl: null, annotations: []}' >>"${standards}" + fi done classified="${work}/classified.ndjson" @@ -76,8 +86,8 @@ if ! bash "${HERE}/classify.sh" <"${collected}" >"${classified}"; then fi body="${work}/body.md" -render_args=() -[[ -n "${run_url}" ]] && render_args=(--run-url "${run_url}") +render_args=(--standards "${standards}") +[[ -n "${run_url}" ]] && render_args+=(--run-url "${run_url}") if ! bash "${HERE}/render.sh" "${render_args[@]}" <"${classified}" >"${body}"; then echo "run-digest.sh: rendering failed" >&2 exit 1 diff --git a/scripts/dependabot-digest/standards.sh b/scripts/dependabot-digest/standards.sh new file mode 100755 index 0000000..5571e15 --- /dev/null +++ b/scripts/dependabot-digest/standards.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# Usage: standards.sh . Prints one JSON record per repo: standards-check +# annotations, or the reason none were read. +set -uo pipefail +unset CDPATH + +owner="${1-}" +if [[ -z "${owner}" ]]; then + echo "standards.sh: usage: standards.sh " >&2 + exit 2 +fi + +# Match the reusable job name only: callers may name their job differently. +read -r -d '' pick_run <<'JQ' +[.check_runs[] + | select(.app.slug == "github-actions") + | select(.name == "run-standards-check" + or (.name | endswith(" / run-standards-check")))] +| sort_by(.id) | last +JQ + +# Nulled entries or a short page mean the list cannot be trusted. +read -r -d '' runs_sane <<'JQ' +(.check_runs | type == "array") +and (.total_count | type == "number") +and (.total_count <= (.check_runs | length)) +and all(.check_runs[]; (.id | type == "number") and (.name | type == "string")) +JQ + +emit() { + # emit [source] [run_url] [annotations_json] + local line + line="$(jq -cn --arg owner "${owner}" --arg repo "$1" --arg state "$2" \ + --arg detail "$3" --arg source "${4-}" --arg url "${5-}" \ + --argjson ann "${6:-[]}" \ + '{owner: $owner, repo: (if $repo == "" then null else $repo end), + state: $state, detail: $detail, + source: (if $source == "" then null else $source end), + runUrl: (if $url == "" then null else $url end), + annotations: $ann}' 2>/dev/null)" + # If jq fails, the repo must still appear. + if [[ -z "${line}" ]]; then + printf '{"owner":"%s","repo":"%s","state":"unreadable","detail":"could not encode the result","source":null,"runUrl":null,"annotations":[]}\n' \ + "${owner}" "$1" + return 0 + fi + printf '%s\n' "${line}" +} + +# Callers run api in a subshell, so the failure reason goes to a file. +ERRF="$(mktemp)" +trap 'rm -f "${ERRF}"' EXIT +api() { + : >"${ERRF}" + gh api "$@" 2>"${ERRF}" +} +# emit_err [source] [run_url] +emit_err() { + local why + why="$(last_err "$4")" + emit "$1" "$2" "$3: ${why}" "${5-}" "${6-}" +} +set_err() { printf '%s' "$1" >"${ERRF}"; } +last_err() { + local e + e="$(tr '\n' ' ' <"${ERRF}" | cut -c1-160)" + printf '%s' "${e:-$1}" +} + +# Prints the commit's standards-check run, or nothing. Returns 1 if unreadable. +run_on_commit() { + local nwo="$1" sha="$2" body + if ! body="$(api "repos/${nwo}/commits/${sha}/check-runs?per_page=100")"; then + return 1 + fi + if ! jq -e "${runs_sane}" >/dev/null 2>&1 <<<"${body}"; then + set_err "check-run list for ${sha:0:7} is incomplete or nulled" + return 1 + fi + jq -c "${pick_run} // empty" <<<"${body}" +} + +survey_repo() { + local nwo="$1" branch="$2" head pulls pr pr_head run source ann count url + if ! head="$(api "repos/${nwo}/commits/${branch}" --jq '.sha')" \ + || [[ ! "${head}" =~ ^[0-9a-f]{40}$ ]]; then + emit_err "${nwo}" unreadable "cannot read the head of ${branch}" "no sha returned" + return 0 + fi + + if ! run="$(run_on_commit "${nwo}" "${head}")"; then + emit_err "${nwo}" unreadable "cannot read check runs on ${branch} head ${head:0:7}" "no detail" + return 0 + fi + source="${branch} head ${head:0:7}" + + if [[ -z "${run}" ]]; then + if ! pulls="$(api "repos/${nwo}/commits/${head}/pulls")" \ + || ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${pulls}"; then + emit_err "${nwo}" unreadable "cannot read the pull request for ${branch} head ${head:0:7}" "non-array body" + return 0 + fi + # Only the PR whose merge produced this commit vetted it. + pr="$(jq -c --arg sha "${head}" '[.[] | select(.merge_commit_sha == $sha and .merged_at != null)] | first // empty' <<<"${pulls}" 2>/dev/null)" + if [[ -z "${pr}" ]]; then + emit "${nwo}" no-pr "${branch} head ${head:0:7} has no standards-check run and was not produced by a merged pull request" "${source}" + return 0 + fi + pr_head="$(jq -r '.head.sha // empty' <<<"${pr}")" + source="#$(jq -r '.number' <<<"${pr}"), merged as ${branch} head ${head:0:7}" + if [[ ! "${pr_head}" =~ ^[0-9a-f]{40}$ ]]; then + emit "${nwo}" unreadable "pull request for ${head:0:7} has no readable head commit" "${source}" + return 0 + fi + if ! run="$(run_on_commit "${nwo}" "${pr_head}")"; then + emit_err "${nwo}" unreadable "cannot read check runs on PR head ${pr_head:0:7}" "no detail" "${source}" + return 0 + fi + if [[ -z "${run}" ]]; then + emit "${nwo}" no-run "no standards-check run on ${branch} head ${head:0:7} or on the head of the PR that produced it" "${source}" + return 0 + fi + fi + + url="$(jq -r '.html_url // ""' <<<"${run}")" + local status run_id + status="$(jq -r '.status' <<<"${run}")" + run_id="$(jq -r '.id' <<<"${run}")" + if [[ "${status}" != "completed" ]]; then + emit "${nwo}" in-progress "latest run has not completed, so its annotations are partial" "${source}" "${url}" + return 0 + fi + + count="$(jq -r '.output.annotations_count // empty' <<<"${run}")" + if [[ ! "${count}" =~ ^[0-9]+$ ]]; then + emit "${nwo}" unreadable "run reports no annotation count" "${source}" "${url}" + return 0 + fi + if ! ann="$(api --paginate --slurp "repos/${nwo}/check-runs/${run_id}/annotations?per_page=100")"; then + emit_err "${nwo}" unreadable "cannot read annotations" "no detail" "${source}" "${url}" + return 0 + fi + # Fewer annotations than the run reports means some were withheld. + ann="$(jq -c --argjson want "${count}" ' + (add // []) as $all + | if all($all[]; (.annotation_level | type == "string") and (.message | type == "string")) + and ($all | length) >= $want + then [$all[] | select(.annotation_level == "warning" or .annotation_level == "notice") + | {level: .annotation_level, path: (.path // ""), + line: (.start_line // null), message: .message}] + else "short" end' <<<"${ann}" 2>/dev/null)" + if [[ -z "${ann}" ]] || ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${ann}"; then + emit "${nwo}" unreadable "annotations are incomplete or malformed (run reports ${count})" "${source}" "${url}" + return 0 + fi + emit "${nwo}" ok "" "${source}" "${url}" "${ann}" +} + +# The installation list is exactly the set of repos this token can read. +if ! repos="$(api --paginate --slurp "installation/repositories?per_page=100")" \ + || ! repo_list="$(jq -r ' + if type == "array" and all(.[]; (.repositories | type == "array")) + and ([.[].repositories[]] | length) >= (.[0].total_count // 0) + then [.[].repositories[]] | sort_by(.full_name)[] + | [.full_name, (.default_branch // ""), (.archived | tostring)] | @tsv + else error("bad shape") end' <<<"${repos}" 2>/dev/null)"; then + emit_err "" unlisted "cannot list the repositories this token can read" "unexpected response shape" + exit 0 +fi +if [[ -z "${repo_list}" ]]; then + emit "" unlisted "the token can read no repositories, so nothing was checked" + exit 0 +fi + +while IFS=$'\t' read -r nwo branch archived; do + [[ -z "${nwo}" ]] && continue + if [[ "${archived}" == "true" ]]; then + emit "${nwo}" archived "archived; not surveyed" + elif [[ -z "${branch}" ]]; then + emit "${nwo}" unreadable "no default branch reported" + else + survey_repo "${nwo}" "${branch}" + fi +done <<<"${repo_list}" diff --git a/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.o b/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.o new file mode 100644 index 0000000..cb68267 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.o @@ -0,0 +1,52 @@ +[ + { + "total_count": 8, + "repositories": [ + { + "full_name": "o/warned", + "default_branch": "main", + "archived": false + }, + { + "full_name": "o/clean", + "default_branch": "main", + "archived": false + }, + { + "full_name": "o/norun", + "default_branch": "main", + "archived": false + }, + { + "full_name": "o/nulled", + "default_branch": "main", + "archived": false + } + ] + }, + { + "total_count": 8, + "repositories": [ + { + "full_name": "o/short", + "default_branch": "main", + "archived": false + }, + { + "full_name": "o/direct", + "default_branch": "main", + "archived": false + }, + { + "full_name": "o/old", + "default_branch": "main", + "archived": true + }, + { + "full_name": "o/forbidden", + "default_branch": "main", + "archived": false + } + ] + } +] diff --git a/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.p b/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.p new file mode 100644 index 0000000..8f4142a --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/installation_repositories.json.p @@ -0,0 +1,12 @@ +[ + { + "total_count": 1, + "repositories": [ + { + "full_name": "p/pwarned", + "default_branch": "main", + "archived": false + } + ] + } +] diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_clean_check-runs_20_annotations.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_clean_check-runs_20_annotations.json new file mode 100644 index 0000000..1721244 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_clean_check-runs_20_annotations.json @@ -0,0 +1 @@ +[[]] diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_clean_commits_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb_check-runs.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_clean_commits_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb_check-runs.json new file mode 100644 index 0000000..5c1c462 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_clean_commits_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb_check-runs.json @@ -0,0 +1,18 @@ +{ + "total_count": 1, + "check_runs": [ + { + "id": 20, + "name": "lint / run-standards-check", + "status": "completed", + "conclusion": "success", + "app": { + "slug": "github-actions" + }, + "html_url": "https://github.com/x/runs/20", + "output": { + "annotations_count": 0 + } + } + ] +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_clean_commits_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_clean_commits_main.json new file mode 100644 index 0000000..d30fa79 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_clean_commits_main.json @@ -0,0 +1,3 @@ +{ + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_direct_commits_ffffffffffffffffffffffffffffffffffffffff_check-runs.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_direct_commits_ffffffffffffffffffffffffffffffffffffffff_check-runs.json new file mode 100644 index 0000000..4c6d61d --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_direct_commits_ffffffffffffffffffffffffffffffffffffffff_check-runs.json @@ -0,0 +1,4 @@ +{ + "total_count": 0, + "check_runs": [] +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_direct_commits_ffffffffffffffffffffffffffffffffffffffff_pulls.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_direct_commits_ffffffffffffffffffffffffffffffffffffffff_pulls.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_direct_commits_ffffffffffffffffffffffffffffffffffffffff_pulls.json @@ -0,0 +1 @@ +[] diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_direct_commits_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_direct_commits_main.json new file mode 100644 index 0000000..1557e1f --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_direct_commits_main.json @@ -0,0 +1,3 @@ +{ + "sha": "ffffffffffffffffffffffffffffffffffffffff" +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_norun_commits_c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1_check-runs.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_norun_commits_c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1_check-runs.json new file mode 100644 index 0000000..8b75f15 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_norun_commits_c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1_check-runs.json @@ -0,0 +1,18 @@ +{ + "total_count": 1, + "check_runs": [ + { + "id": 30, + "name": "tests", + "status": "completed", + "conclusion": "success", + "app": { + "slug": "github-actions" + }, + "html_url": "https://github.com/x/runs/30", + "output": { + "annotations_count": 0 + } + } + ] +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_norun_commits_cccccccccccccccccccccccccccccccccccccccc_check-runs.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_norun_commits_cccccccccccccccccccccccccccccccccccccccc_check-runs.json new file mode 100644 index 0000000..4c6d61d --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_norun_commits_cccccccccccccccccccccccccccccccccccccccc_check-runs.json @@ -0,0 +1,4 @@ +{ + "total_count": 0, + "check_runs": [] +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_norun_commits_cccccccccccccccccccccccccccccccccccccccc_pulls.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_norun_commits_cccccccccccccccccccccccccccccccccccccccc_pulls.json new file mode 100644 index 0000000..131da3e --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_norun_commits_cccccccccccccccccccccccccccccccccccccccc_pulls.json @@ -0,0 +1,10 @@ +[ + { + "number": 3, + "merge_commit_sha": "cccccccccccccccccccccccccccccccccccccccc", + "merged_at": "2026-10-01T00:00:00Z", + "head": { + "sha": "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1" + } + } +] diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_norun_commits_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_norun_commits_main.json new file mode 100644 index 0000000..d0b1182 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_norun_commits_main.json @@ -0,0 +1,3 @@ +{ + "sha": "cccccccccccccccccccccccccccccccccccccccc" +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_nulled_commits_dddddddddddddddddddddddddddddddddddddddd_check-runs.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_nulled_commits_dddddddddddddddddddddddddddddddddddddddd_check-runs.json new file mode 100644 index 0000000..ccafb65 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_nulled_commits_dddddddddddddddddddddddddddddddddddddddd_check-runs.json @@ -0,0 +1,4 @@ +{ + "total_count": 2, + "check_runs": [{}, {}] +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_nulled_commits_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_nulled_commits_main.json new file mode 100644 index 0000000..c5fd171 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_nulled_commits_main.json @@ -0,0 +1,3 @@ +{ + "sha": "dddddddddddddddddddddddddddddddddddddddd" +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_short_check-runs_50_annotations.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_short_check-runs_50_annotations.json new file mode 100644 index 0000000..c53a5a5 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_short_check-runs_50_annotations.json @@ -0,0 +1,24 @@ +[ + [ + { + "path": "a.yml", + "blob_href": "x", + "start_line": 1, + "end_line": 1, + "annotation_level": "warning", + "title": "", + "message": "one", + "raw_details": "" + }, + { + "path": "b.yml", + "blob_href": "x", + "start_line": 2, + "end_line": 2, + "annotation_level": "notice", + "title": "", + "message": "two", + "raw_details": "" + } + ] +] diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_short_commits_eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee_check-runs.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_short_commits_eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee_check-runs.json new file mode 100644 index 0000000..91b62cc --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_short_commits_eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee_check-runs.json @@ -0,0 +1,18 @@ +{ + "total_count": 1, + "check_runs": [ + { + "id": 50, + "name": "standards-check / run-standards-check", + "status": "completed", + "conclusion": "success", + "app": { + "slug": "github-actions" + }, + "html_url": "https://github.com/x/runs/50", + "output": { + "annotations_count": 5 + } + } + ] +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_short_commits_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_short_commits_main.json new file mode 100644 index 0000000..1042a62 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_short_commits_main.json @@ -0,0 +1,3 @@ +{ + "sha": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_check-runs_11_annotations.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_check-runs_11_annotations.json new file mode 100644 index 0000000..a434ab1 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_check-runs_11_annotations.json @@ -0,0 +1,46 @@ +[ + [ + { + "path": ".github/workflows/ci.yml", + "blob_href": "x", + "start_line": 12, + "end_line": 12, + "annotation_level": "warning", + "title": "", + "message": "node-version 18 is below the floor 22", + "raw_details": "" + }, + { + "path": ".github", + "blob_href": "x", + "start_line": 51, + "end_line": 51, + "annotation_level": "notice", + "title": "", + "message": "no shell files", + "raw_details": "" + } + ], + [ + { + "path": "scripts/x.sh", + "blob_href": "x", + "start_line": 3, + "end_line": 3, + "annotation_level": "failure", + "title": "", + "message": "SC2086: Double quote to prevent globbing | splitting", + "raw_details": "" + }, + { + "path": ".github/workflows/build.yml", + "blob_href": "x", + "start_line": 7, + "end_line": 7, + "annotation_level": "notice", + "title": "", + "message": "node-version is an expression (${{ inputs.node }}); not checked", + "raw_details": "" + } + ] +] diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_commits_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1_check-runs.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_commits_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1_check-runs.json new file mode 100644 index 0000000..702bcee --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_commits_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1_check-runs.json @@ -0,0 +1,44 @@ +{ + "total_count": 3, + "check_runs": [ + { + "id": 10, + "name": "standards-check / run-standards-check", + "status": "completed", + "conclusion": "success", + "app": { + "slug": "github-actions" + }, + "html_url": "https://github.com/x/runs/10", + "output": { + "annotations_count": 9 + } + }, + { + "id": 11, + "name": "standards-check / run-standards-check", + "status": "completed", + "conclusion": "success", + "app": { + "slug": "github-actions" + }, + "html_url": "https://github.com/x/runs/11", + "output": { + "annotations_count": 4 + } + }, + { + "id": 12, + "name": "tests", + "status": "completed", + "conclusion": "success", + "app": { + "slug": "github-actions" + }, + "html_url": "https://github.com/x/runs/12", + "output": { + "annotations_count": 0 + } + } + ] +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_commits_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_check-runs.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_commits_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_check-runs.json new file mode 100644 index 0000000..5256fd9 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_commits_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_check-runs.json @@ -0,0 +1,18 @@ +{ + "total_count": 1, + "check_runs": [ + { + "id": 5, + "name": "tests", + "status": "completed", + "conclusion": "success", + "app": { + "slug": "github-actions" + }, + "html_url": "https://github.com/x/runs/5", + "output": { + "annotations_count": 0 + } + } + ] +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_commits_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_pulls.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_commits_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_pulls.json new file mode 100644 index 0000000..7ca6685 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_commits_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_pulls.json @@ -0,0 +1,10 @@ +[ + { + "number": 7, + "merge_commit_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "merged_at": "2026-10-01T00:00:00Z", + "head": { + "sha": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + } + } +] diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_commits_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_commits_main.json new file mode 100644 index 0000000..f19526e --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_o_warned_commits_main.json @@ -0,0 +1,3 @@ +{ + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_check-runs_70_annotations.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_check-runs_70_annotations.json new file mode 100644 index 0000000..4c5a5bb --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_check-runs_70_annotations.json @@ -0,0 +1,14 @@ +[ + [ + { + "path": ".github", + "blob_href": "x", + "start_line": 1, + "end_line": 1, + "annotation_level": "notice", + "title": "", + "message": "The ubuntu-latest label will migrate", + "raw_details": "" + } + ] +] diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_commits_1111111111111111111111111111111111111111_check-runs.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_commits_1111111111111111111111111111111111111111_check-runs.json new file mode 100644 index 0000000..4c6d61d --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_commits_1111111111111111111111111111111111111111_check-runs.json @@ -0,0 +1,4 @@ +{ + "total_count": 0, + "check_runs": [] +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_commits_1111111111111111111111111111111111111111_pulls.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_commits_1111111111111111111111111111111111111111_pulls.json new file mode 100644 index 0000000..0bd905e --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_commits_1111111111111111111111111111111111111111_pulls.json @@ -0,0 +1,10 @@ +[ + { + "number": 2, + "merge_commit_sha": "1111111111111111111111111111111111111111", + "merged_at": "2026-10-01T00:00:00Z", + "head": { + "sha": "1212121212121212121212121212121212121212" + } + } +] diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_commits_1212121212121212121212121212121212121212_check-runs.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_commits_1212121212121212121212121212121212121212_check-runs.json new file mode 100644 index 0000000..88041c7 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_commits_1212121212121212121212121212121212121212_check-runs.json @@ -0,0 +1,18 @@ +{ + "total_count": 1, + "check_runs": [ + { + "id": 70, + "name": "standards-check / run-standards-check", + "status": "completed", + "conclusion": "success", + "app": { + "slug": "github-actions" + }, + "html_url": "https://github.com/x/runs/70", + "output": { + "annotations_count": 1 + } + } + ] +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_commits_main.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_commits_main.json new file mode 100644 index 0000000..7a66373 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_p_pwarned_commits_main.json @@ -0,0 +1,3 @@ +{ + "sha": "1111111111111111111111111111111111111111" +} diff --git a/scripts/dependabot-digest/tests/test-run-digest.sh b/scripts/dependabot-digest/tests/test-run-digest.sh index c14a6f4..a9a64fb 100755 --- a/scripts/dependabot-digest/tests/test-run-digest.sh +++ b/scripts/dependabot-digest/tests/test-run-digest.sh @@ -161,6 +161,19 @@ else _pass "a populated queue is not reported as empty" fi +# dev-env#179: the section always appears. An unlistable owner is not checked. +if grep -q '^## Standards warnings' <<<"${populated}"; then + _pass "the digest carries a standards warnings section" +else + _fail "the digest has no standards warnings section" +fi +if grep -q '| all of one | OWNER NOT LISTED:' <<<"${populated}" \ + && ! grep -q 'No warnings' <<<"${populated}"; then + _pass "an owner whose repositories cannot be listed renders as not checked" +else + _fail "an unlistable owner did not render as not checked" +fi + if [[ "${fail}" -eq 0 ]]; then echo "test-run-digest: all assertions passed" else diff --git a/scripts/dependabot-digest/tests/test-standards.sh b/scripts/dependabot-digest/tests/test-standards.sh new file mode 100755 index 0000000..3d03d9b --- /dev/null +++ b/scripts/dependabot-digest/tests/test-standards.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# dev-env#179: the section must never read "no warnings" when it could not tell. +set -uo pipefail +unset CDPATH +unset BASH_ENV GH_TOKEN GH_HOST GITHUB_TOKEN + +HERE="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DIR="${HERE}/.." +FIX="${HERE}/fixtures/standards" +WORK="/tmp/dd-standards-test-$$" +BIN="${WORK}/bin" +mkdir -p "${BIN}" +trap 'rm -rf "${WORK}"' EXIT + +fail=0 +_pass() { echo " PASS: $1"; } +_fail() { echo " FAIL: $1" >&2; fail=1; } + +# Stub gh api: fixtures/standards/.json. No fixture means HTTP 403. +cat >"${BIN}/gh" <<'STUB' +#!/usr/bin/env bash +[[ "$1" == "api" ]] || { echo '[]'; exit 0; } +shift +path="" expr="" +while [[ $# -gt 0 ]]; do + case "$1" in + --jq) expr="$2"; shift 2 ;; + --*) shift ;; + *) path="$1"; shift ;; + esac +done +# The gh wrapper on PATH asks for the current identity before forwarding any +# command, as test-run-digest.sh notes; answer it. +[[ "${path}" == "user" ]] && { echo 'twistedmelonman'; exit 0; } +path="${path%%\?*}" +file="${STUB_FIX}/${path//\//_}.json" +[[ "${path}" == "installation/repositories" ]] && file="${file}.${STUB_OWNER}" +if [[ ! -f "${file}" ]]; then + echo "gh: Resource not accessible by integration (HTTP 403)" >&2 + exit 1 +fi +if [[ -n "${expr}" ]]; then jq -r "${expr}" "${file}"; else cat "${file}"; fi +STUB +chmod +x "${BIN}/gh" + +survey() { + PATH="${BIN}:${PATH}" STUB_FIX="${FIX}" STUB_OWNER="$1" GH_TOKEN=t \ + bash "${DIR}/standards.sh" "$1" +} + +out="${WORK}/standards.ndjson" +: >"${out}" +for o in o p q; do + if ! survey "${o}" >>"${out}" 2>"${WORK}/${o}.err"; then + _fail "standards.sh exited non-zero for owner ${o}; per-repo failures must be records, not crashes" + fi +done + +state_of() { jq -r --arg r "$1" 'select(.repo == $r) | .state' "${out}"; } +_state() { + local got + got="$(state_of "$1")" + if [[ "${got}" == "$2" ]]; then _pass "$3"; else _fail "$3 (state: '${got}', want '$2')"; fi +} + +# Every listed repository must yield exactly one record. +o_count="$(jq -s '[.[] | select(.owner == "o")] | length' "${out}")" +if [[ "${o_count}" == "8" ]]; then + _pass "every listed repo yields one record (8 of 8, across two pages)" +else + _fail "owner o yielded ${o_count} records for 8 listed repos" +fi + +_state o/warned ok "a merged PR's run is found when the default-branch head has none" +_state o/clean ok "a run on the default-branch head itself is used directly" +_state o/norun no-run "a repo with no standards-check run is reported, not dropped" +_state o/nulled unreadable "nulled check-run entries are unreadable, not 'no run'" +_state o/short unreadable "fewer annotations than the run reports is unreadable, not clean" +_state o/direct no-pr "a direct-push head with no run is reported" +_state o/old archived "an archived repo is named as skipped" +_state o/forbidden unreadable "a refused read is unreadable" + +# Re-runs leave several runs on one commit. Only the latest counts. +warned_ann="$(jq -c 'select(.repo == "o/warned") | [.annotations[].message]' "${out}")" +if [[ "${warned_ann}" == *"below the floor"* && "${warned_ann}" == *"no shell files"* ]]; then + _pass "warning and notice annotations are both kept" +else + _fail "expected warning and notice annotations, got ${warned_ann}" +fi +if [[ "${warned_ann}" == *"SC2086"* ]]; then + _fail "a failure-level annotation was listed as a warning" +else + _pass "failure-level annotations are excluded" +fi +if grep -q 'forbidden.*HTTP 403' "${out}"; then + _pass "the reason a read failed is kept in the record" +else + _fail "the refused read lost its reason" +fi +q_rec="$(jq -c 'select(.owner == "q")' "${out}")" +q_shape="$(jq -r '.state + " " + (.repo | tostring)' <<<"${q_rec}")" +if [[ "${q_shape}" == "unlisted null" ]]; then + _pass "an owner whose repos cannot be listed yields one unlisted record" +else + _fail "an unlistable owner was not reported: ${q_rec}" +fi + +# Render the section from the survey. +body="${WORK}/body.md" +: | bash "${DIR}/render.sh" --standards "${out}" >"${body}" 2>"${WORK}/render.err" +_has() { if grep -qF -- "$1" "$3"; then _pass "$2"; else _fail "$2 (missing: $1)"; fi; } +_hasnt() { if grep -qF -- "$1" "$3"; then _fail "$2 (present: $1)"; else _pass "$2"; fi; } + +_has '## Standards warnings' "the section is rendered" "${body}" +_has '**3 repositories checked**: 2 with warnings or notices, 1 with none. **6 not checked.**' \ + "the summary counts checked, warned, clean and not-checked repos" "${body}" +_has "| [o/warned](https://github.com/x/runs/11) | warning | \`.github/workflows/ci.yml:12\` | node-version 18 is below the floor 22 |" \ + "a warning row names repo, level, file and message, linked to the run" "${body}" +_has '| [p/pwarned](https://github.com/x/runs/70) | notice |' \ + "notices from a second owner are listed" "${body}" +_has '### Not checked' "unreadable repos get their own subsection" "${body}" +_has '| o/nulled | UNREADABLE:' "a nulled run is shown as unreadable" "${body}" +_has '| o/norun | no standards-check run:' "a repo with no run is shown by name" "${body}" +_has '| all of q | OWNER NOT LISTED:' "an unlistable owner is shown" "${body}" +_has 'Archived, not surveyed: o/old.' "archived repos are named" "${body}" +_has 'not that the whole repository is' "the scope limit of a PR run is stated" "${body}" +_hasnt 'No warnings:' "a section with warnings does not claim none" "${body}" + +# All clean: the empty state must be an explicit statement. +clean="${WORK}/clean.ndjson" +jq -c 'select(.repo == "o/clean")' "${out}" >"${clean}" +: | bash "${DIR}/render.sh" --standards "${clean}" >"${WORK}/clean.md" 2>/dev/null +_has 'No warnings: no checked repository' "a clean survey says so explicitly" "${WORK}/clean.md" +_hasnt '### Not checked' "a fully read survey has no not-checked list" "${WORK}/clean.md" + +# Clean repos alongside an unreadable one: "no warnings" must not hide the gap. +mixed="${WORK}/mixed.ndjson" +jq -c 'select(.repo == "o/clean" or .repo == "o/nulled")' "${out}" >"${mixed}" +: | bash "${DIR}/render.sh" --standards "${mixed}" >"${WORK}/mixed.md" 2>/dev/null +_has '**1 not checked.**' "not-checked repos are counted next to a clean result" "${WORK}/mixed.md" +_has '| o/nulled | UNREADABLE:' "not-checked repos are listed next to a clean result" "${WORK}/mixed.md" + +# Every repo unreadable: zero warnings from zero reads is not "no warnings". +jq -c 'select(.repo == "o/nulled")' "${out}" >"${WORK}/allbad.ndjson" +: | bash "${DIR}/render.sh" --standards "${WORK}/allbad.ndjson" >"${WORK}/allbad.md" 2>/dev/null +_has '**No repository could be checked**' "an all-unreadable survey says nothing was checked" "${WORK}/allbad.md" +_hasnt 'No warnings' "an all-unreadable survey never claims no warnings" "${WORK}/allbad.md" + +# A survey that produced nothing, or garbage, is not a clean survey. +: >"${WORK}/empty.ndjson" +: | bash "${DIR}/render.sh" --standards "${WORK}/empty.ndjson" >"${WORK}/empty.md" 2>/dev/null +_has '**Not checked.**' "an empty survey renders as not checked" "${WORK}/empty.md" +_hasnt 'No warnings' "an empty survey never claims no warnings" "${WORK}/empty.md" +printf '%s\n' '{"owner":"o","repo":"o/clean","state":"ok","annotations":[]}' '{not json' >"${WORK}/bad.ndjson" +: | bash "${DIR}/render.sh" --standards "${WORK}/bad.ndjson" >"${WORK}/bad.md" 2>/dev/null +_has 'output is malformed' "a malformed survey renders as not checked" "${WORK}/bad.md" +_hasnt 'No warnings' "a malformed survey never claims no warnings" "${WORK}/bad.md" + +# Without --standards there was no survey, and the section must not appear. +: | bash "${DIR}/render.sh" >"${WORK}/none.md" 2>/dev/null +_hasnt '## Standards warnings' "no survey, no section" "${WORK}/none.md" + +if [[ "${fail}" -eq 0 ]]; then + echo "test-standards: all assertions passed" +else + echo "test-standards: FAILURES" +fi +exit "${fail}"