Skip to content

Commit ff5ab60

Browse files
feat(scripts): 4 fix-scripts for hypatia auto-corrective dispatch (#220)
## Summary Four fleet fix-scripts paired with the new hypatia rules + recipes: - `fix-spdx-double-suffix.sh` (ERR-LIC-001 / rhodibot regex regression) - `fix-workflow-timeout-minutes.sh` (ERR-WF-013 / 6-hour default budget burn) - `fix-gitattributes-eol.sh` (ERR-GIT-001 / CRLF phantom-diff blocker) - `fix-close-obsolete-pr.sh` (ERR-PR-001 / supersedes pattern) All POSIX-compatible, `set -euo pipefail`, use `gh` for cross-repo operations. Pairs with the matching hypatia recipe JSONs in [the hypatia rules-extension PR](https://github.com/hyperpolymath/hypatia/pulls?q=is%3Apr+is%3Aopen+author%3Ahyperpolymath+ERR-WF-013). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: hyperpolymath <hyperpolymath@users.noreply.github.com>
1 parent 6142c97 commit ff5ab60

4 files changed

Lines changed: 297 additions & 0 deletions

File tree

scripts/fix-close-obsolete-pr.sh

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# fix-close-obsolete-pr.sh — Close PRs whose proposed `uses: …@<sha>`
4+
# is REGRESSIVE vs main's current value. ERR-PR-001.
5+
#
6+
# Root cause (observed 2026-05-27, 6 PRs): two pin-bump PRs land out
7+
# of order — the newer (PR-B) merges first; the earlier (PR-A) still
8+
# proposes the now-stale SHA. Rebasing PR-A reverts main; close-as-
9+
# superseded is the correct action.
10+
11+
set -euo pipefail
12+
13+
URL="${1:?Usage: fix-close-obsolete-pr.sh <pr-url>}"
14+
REPO_NWO=$(echo "$URL" | awk -F'/' '{print $4"/"$5}')
15+
16+
DIFF=$(gh pr diff "$URL" 2>/dev/null)
17+
PR_USES=$(echo "$DIFF" | grep -E "^\+[[:space:]]+uses:[[:space:]]+[^@[:space:]]+@[0-9a-f]{40}" || true)
18+
if [ -z "$PR_USES" ]; then
19+
echo "SKIP: PR has no `+ uses: …@<sha>` lines"
20+
exit 0
21+
fi
22+
23+
TOUCHED=$(gh pr view "$URL" --json files --jq '.files[].path' | grep '^\.github/workflows/.*\.ya\?ml$' || true)
24+
[ -z "$TOUCHED" ] && { echo "SKIP: no workflow files touched"; exit 0; }
25+
26+
OBSOLETE=0
27+
for path in $TOUCHED; do
28+
main_content=$(gh api "repos/${REPO_NWO}/contents/${path}?ref=main" --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true)
29+
[ -z "$main_content" ] && continue
30+
while IFS= read -r pr_line; do
31+
action=$(echo "$pr_line" | sed -E 's/^\+[[:space:]]+uses:[[:space:]]+([^@[:space:]]+)@.*/\1/')
32+
pr_sha=$(echo "$pr_line" | sed -E 's/.*@([0-9a-f]{40}).*/\1/')
33+
main_sha=$(echo "$main_content" | grep -E "uses:[[:space:]]+${action}@[0-9a-f]{40}" | head -1 | sed -E 's/.*@([0-9a-f]{40}).*/\1/')
34+
if [ -n "$main_sha" ] && [ "$main_sha" != "$pr_sha" ]; then
35+
echo "OBSOLETE: $path action=$action PR=$pr_sha main=$main_sha"
36+
OBSOLETE=1
37+
fi
38+
done <<< "$PR_USES"
39+
done
40+
41+
if [ "$OBSOLETE" -eq 0 ]; then
42+
echo "OK: PR is not obsolete"
43+
exit 0
44+
fi
45+
46+
gh pr close "$URL" --comment "Closed as superseded: main already carries a newer SHA at the workflow location(s) this PR proposes to update. Rebasing would regress main. Refile against current main if a future repin is needed. (ERR-PR-001 — auto-closed via fix-close-obsolete-pr.sh.)"
47+
echo "CLOSED: $URL"

scripts/fix-gitattributes-eol.sh

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# fix-gitattributes-eol.sh — Add EOL normalization to .gitattributes
4+
# AND renormalize CRLF blobs so git's autocrlf round-trip stops
5+
# producing phantom diffs that block rebase. ERR-GIT-001.
6+
#
7+
# Root cause (claude-integrations#43, 2026-05-27): repo has CRLF
8+
# blobs in the git object store but no .gitattributes directing
9+
# normalization. On every checkout/rebase, the working tree gets a
10+
# CRLF→LF round-trip via core.autocrlf, producing uncommittable
11+
# diffs. The fix is twofold:
12+
# 1. Write `.gitattributes` declaring `* text=auto eol=lf`
13+
# 2. Run `git add --renormalize .` to refresh the object store
14+
#
15+
# Usage: fix-gitattributes-eol.sh <repo-path>
16+
17+
set -euo pipefail
18+
19+
REPO_PATH="${1:-.}"
20+
cd "$REPO_PATH"
21+
22+
if [ ! -d .git ]; then
23+
echo "ERROR: not a git repo: $REPO_PATH" >&2
24+
exit 1
25+
fi
26+
27+
# Build the canonical .gitattributes content
28+
CANONICAL_GA=$(cat <<'EOF'
29+
# SPDX-License-Identifier: MPL-2.0
30+
# Normalise line endings to LF for text files; preserve CRLF on .bat
31+
# (Windows shell-script files require CRLF to execute). Binary
32+
# extensions are declared explicitly to prevent any normalisation.
33+
34+
* text=auto eol=lf
35+
36+
# Windows-shell scripts
37+
*.bat text eol=crlf
38+
*.cmd text eol=crlf
39+
40+
# Binaries (do not normalise)
41+
*.png binary
42+
*.jpg binary
43+
*.jpeg binary
44+
*.gif binary
45+
*.ico binary
46+
*.pdf binary
47+
*.zip binary
48+
*.gz binary
49+
*.tar binary
50+
*.wasm binary
51+
*.so binary
52+
*.dylib binary
53+
*.dll binary
54+
EOF
55+
)
56+
57+
# Apply EOL normalisation if .gitattributes doesn't already declare it
58+
if [ -f .gitattributes ] && grep -qE "(eol=lf|text=auto)" .gitattributes; then
59+
echo "OK: .gitattributes already normalises EOL"
60+
else
61+
if [ -f .gitattributes ]; then
62+
# Prepend our directives if file exists but is silent on EOL
63+
tmp=$(mktemp)
64+
{
65+
echo "$CANONICAL_GA"
66+
echo ""
67+
echo "# --- Pre-existing directives (preserved) ---"
68+
cat .gitattributes
69+
} > "$tmp"
70+
mv "$tmp" .gitattributes
71+
else
72+
echo "$CANONICAL_GA" > .gitattributes
73+
fi
74+
echo "FIXED: .gitattributes now normalises EOL to LF"
75+
fi
76+
77+
# Now renormalise blobs. This is the critical step the
78+
# claude-integrations#43 phantom-diff was missing.
79+
echo "Renormalising blobs (git add --renormalize .)..."
80+
git add --renormalize .
81+
if git diff --cached --quiet; then
82+
echo "OK: no blobs needed renormalisation (working tree already clean)"
83+
else
84+
git diff --cached --name-only | sed 's|^| RENORMALISED |'
85+
fi
86+
87+
echo ""
88+
echo "SUMMARY: $REPO_PATH .gitattributes + blobs reconciled. Commit the staged changes."

scripts/fix-spdx-double-suffix.sh

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/bin/bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# fix-spdx-double-suffix.sh — Repair SPDX identifiers with duplicated
4+
# `-or-later` (or `+`) clauses.
5+
#
6+
# Class of regression observed 2026-05-27 in
7+
# `the-nash-equilibrium/pull/41` and likely elsewhere: a deployed
8+
# rhodibot replace rule applied `s/AGPL-3.0/AGPL-3.0-or-later/`
9+
# without a word-boundary check, producing
10+
# `AGPL-3.0-or-later-or-later` when the file already carried the
11+
# `-or-later` suffix. This script:
12+
#
13+
# 1. Collapses `-or-later-or-later` -> `-or-later` everywhere it
14+
# appears in SPDX-License-Identifier lines.
15+
# 2. Collapses `+` short-form double-suffix forms (e.g.
16+
# `AGPL-3.0-or-later+`, `AGPL-3.0+-or-later`) -> `-or-later`.
17+
# 3. Leaves all other SPDX identifiers untouched.
18+
#
19+
# Hypatia rule pairing: ERR-LIC-001 (Hypatia.Rules.CicdRules
20+
# validate_license/2 returns `{:error, :spdx_double_suffix, …}`).
21+
22+
set -euo pipefail
23+
24+
REPO_PATH="${1:-.}"
25+
FINDING_FILE="${2:-}"
26+
27+
if [[ ! -d "$REPO_PATH" ]]; then
28+
echo "ERROR: Not a directory: $REPO_PATH" >&2
29+
exit 1
30+
fi
31+
32+
cd "$REPO_PATH"
33+
34+
# If a finding-file was provided, fix just that file; otherwise scan all
35+
# tracked files for the malformed pattern.
36+
if [[ -n "$FINDING_FILE" && -f "$FINDING_FILE" ]]; then
37+
FILES=$(jq -r '.location.file' "$FINDING_FILE")
38+
else
39+
# Repo-wide scan — tracked files only, skip binaries by relying on
40+
# grep -I.
41+
mapfile -t FILES < <(
42+
git ls-files -z |
43+
xargs -0 grep -lI -E "SPDX-License-Identifier:.*(-or-later-or-later|-or-later\+|\+-or-later)" 2>/dev/null || true
44+
)
45+
fi
46+
47+
if [[ ${#FILES[@]} -eq 0 ]]; then
48+
echo "OK: no SPDX double-suffix occurrences found in $REPO_PATH"
49+
exit 0
50+
fi
51+
52+
CHANGED=0
53+
for f in "${FILES[@]}"; do
54+
[[ -z "$f" || ! -f "$f" ]] && continue
55+
56+
# Three repairs, applied only on lines that match
57+
# SPDX-License-Identifier: — never globally, to avoid breaking
58+
# unrelated text or test fixtures.
59+
if grep -qE "SPDX-License-Identifier:.*(-or-later-or-later|-or-later\+|\+-or-later)" "$f"; then
60+
# Use a temp file (sed -i portability across BSD/GNU)
61+
tmp=$(mktemp)
62+
sed -E \
63+
-e '/SPDX-License-Identifier:/ s/-or-later-or-later/-or-later/g' \
64+
-e '/SPDX-License-Identifier:/ s/-or-later\+/-or-later/g' \
65+
-e '/SPDX-License-Identifier:/ s/\+-or-later/-or-later/g' \
66+
"$f" > "$tmp"
67+
if ! cmp -s "$f" "$tmp"; then
68+
mv "$tmp" "$f"
69+
echo "FIXED $f"
70+
CHANGED=$((CHANGED + 1))
71+
else
72+
rm -f "$tmp"
73+
fi
74+
fi
75+
done
76+
77+
echo ""
78+
echo "SUMMARY: $CHANGED file(s) repaired in $REPO_PATH"
79+
exit 0
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# fix-workflow-timeout-minutes.sh — Add `timeout-minutes:` to workflow
4+
# jobs that lack one. ERR-WF-013.
5+
#
6+
# Default GH Actions job timeout is 6 hours; a stuck `codeload` fetch
7+
# can burn that full budget. This script inserts `timeout-minutes: 10`
8+
# directly after each `runs-on:` line in a workflow YAML that doesn't
9+
# already have one within the job block. Override per-job via
10+
# `JOB_TIMEOUT_OVERRIDES` env var (`<job-name>=<minutes>,...`).
11+
#
12+
# Usage:
13+
# fix-workflow-timeout-minutes.sh <workflow.yml>
14+
# fix-workflow-timeout-minutes.sh <repo-path> (scans all .github/workflows/*.yml)
15+
#
16+
# Pairs with Hypatia.Rules.WorkflowAudit.check_missing_timeout_minutes/1
17+
# and recipe-add-workflow-timeout-minutes.json.
18+
19+
set -euo pipefail
20+
21+
DEFAULT_TIMEOUT="${DEFAULT_TIMEOUT:-10}"
22+
TARGET="${1:?Usage: fix-workflow-timeout-minutes.sh <workflow.yml | repo-path>}"
23+
24+
apply_to_file() {
25+
local f="$1"
26+
[ -f "$f" ] || { echo "SKIP (not a file): $f" >&2; return 0; }
27+
28+
# Get list of (line_number, job_name) for jobs that lack timeout-minutes
29+
# within their block. We scan the file and find each top-level `^ <key>:$`
30+
# under `^jobs:$`, then check the next 30 lines for `timeout-minutes:`.
31+
awk -v default_to="$DEFAULT_TIMEOUT" '
32+
/^jobs:[[:space:]]*$/ { in_jobs=1; next }
33+
/^[A-Za-z]/ && !/^jobs:/ { in_jobs=0 }
34+
in_jobs && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ {
35+
if (curjob && !hadto) print curjob_line "\t" curjob
36+
curjob = $0; sub(/^ /, "", curjob); sub(/:.*$/, "", curjob)
37+
curjob_line = NR; hadto = 0
38+
}
39+
in_jobs && /^ timeout-minutes:/ { hadto = 1 }
40+
END { if (curjob && !hadto) print curjob_line "\t" curjob }
41+
' "$f" > /tmp/missing_timeouts.$$
42+
43+
if [ ! -s /tmp/missing_timeouts.$$ ]; then
44+
rm -f /tmp/missing_timeouts.$$
45+
echo "OK (no missing timeouts): $f"
46+
return 0
47+
fi
48+
49+
# Now find the `runs-on:` line in each missing job and insert timeout after it.
50+
# We do this in reverse order to keep line numbers stable across inserts.
51+
tmp=$(mktemp)
52+
cp "$f" "$tmp"
53+
54+
tac /tmp/missing_timeouts.$$ | while IFS=$'\t' read -r job_start_line job_name; do
55+
# Find the runs-on line within the next 20 lines from the job header
56+
runs_on_line=$(awk -v start="$job_start_line" -v end="$((job_start_line+20))" '
57+
NR > start && NR < end && /^ runs-on:/ { print NR; exit }
58+
' "$tmp")
59+
if [ -z "$runs_on_line" ]; then
60+
echo "WARN: job '$job_name' has no `runs-on:` within 20 lines — skipping" >&2
61+
continue
62+
fi
63+
# Insert AFTER the runs-on line
64+
sed -i "${runs_on_line}a\ timeout-minutes: ${DEFAULT_TIMEOUT}" "$tmp"
65+
echo "FIXED $f job=$job_name inserted timeout-minutes: $DEFAULT_TIMEOUT"
66+
done
67+
68+
if ! cmp -s "$f" "$tmp"; then
69+
mv "$tmp" "$f"
70+
else
71+
rm -f "$tmp"
72+
fi
73+
rm -f /tmp/missing_timeouts.$$
74+
}
75+
76+
if [ -d "$TARGET" ]; then
77+
for f in "$TARGET"/.github/workflows/*.yml "$TARGET"/.github/workflows/*.yaml; do
78+
[ -f "$f" ] || continue
79+
apply_to_file "$f"
80+
done
81+
else
82+
apply_to_file "$TARGET"
83+
fi

0 commit comments

Comments
 (0)