Skip to content

Commit e7b5530

Browse files
committed
Auto-fix confirmed-dead and moved links via a bot-opened PR
Closes the loop on the link-rot sweep: previously it only ever produced a report, leaving every fix to be typed by hand. Adds a `prune` subcommand that applies only the two categories safe enough to fix without a human judgment call: - entries HARD_DEAD for 2+ consecutive weekly checks with no known redirect -> remove the line (cascading to remove a now-empty series title too) - entries that now redirect to a different working URL -> rewrite in place Suspect links, secondary/prose links, and anything matching more than one entry are left untouched for the existing manual report flow. link-rot.yml now runs `prune` after the sweep and, if anything changed, opens or updates a single PR from a dedicated automated/link-rot-fixes branch (same upsert pattern as the report issue -- one running PR, not a new one weekly). It still requires a human merge; nothing auto-lands. Also surfaces "moved" links in the report for the first time -- previously an OK-but-redirected URL never appeared anywhere.
1 parent b63b36a commit e7b5530

2 files changed

Lines changed: 215 additions & 2 deletions

File tree

.github/workflows/link-rot.yml

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
permissions:
99
contents: write
1010
issues: write
11+
pull-requests: write
1112

1213
concurrency:
1314
group: link-rot-sweep
@@ -46,9 +47,59 @@ jobs:
4647
git push origin "HEAD:${{ github.ref_name }}"
4748
fi
4849
50+
- name: Apply safe auto-fixes (remove confirmed-dead entries, update moved URLs)
51+
id: prune
52+
if: always()
53+
continue-on-error: true
54+
env:
55+
GH_TOKEN: ${{ github.token }}
56+
run: |
57+
# Only two categories are safe enough to fix without a human decision:
58+
# entries confirmed dead for 2+ consecutive weeks (with no known redirect),
59+
# and URLs that now redirect to a different working page. Everything else
60+
# (SUSPECT, secondary/prose links, ambiguous matches) is left in the
61+
# report for a human to judge -- see scripts/check_readme.py's cmd_prune
62+
# docstring for the exact rules.
63+
python3 scripts/check_readme.py prune \
64+
--results /tmp/results.json --state .github/link-rot-state.json \
65+
--readme README.md --pr-body-out /tmp/pr-body.md > /tmp/prune-summary.json
66+
changed=$(python3 -c "import json; print(json.load(open('/tmp/prune-summary.json'))['changed'])")
67+
if [ "$changed" != "True" ]; then
68+
echo "Nothing to auto-fix this run."
69+
exit 0
70+
fi
71+
72+
removed=$(python3 -c "import json; print(len(json.load(open('/tmp/prune-summary.json'))['removals']))")
73+
updated=$(python3 -c "import json; print(len(json.load(open('/tmp/prune-summary.json'))['updates']))")
74+
branch="automated/link-rot-fixes"
75+
76+
git config user.name "github-actions[bot]"
77+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
78+
git checkout -B "$branch"
79+
git add README.md
80+
git commit -m "chore(link-rot): remove $removed confirmed-dead link(s), update $updated moved URL(s)"
81+
git push --force-with-lease origin "HEAD:$branch"
82+
83+
if [ -z "$(gh pr list --repo "${{ github.repository }}" --head "$branch" --state open --json number --jq '.[0].number // empty')" ]; then
84+
gh pr create --repo "${{ github.repository }}" --base "${{ github.ref_name }}" --head "$branch" \
85+
--title "chore(link-rot): remove $removed confirmed-dead link(s), update $updated moved URL(s)" \
86+
--body-file /tmp/pr-body.md --label link-rot
87+
else
88+
gh pr edit "$branch" --repo "${{ github.repository }}" --body-file /tmp/pr-body.md
89+
fi
90+
pr_url=$(gh pr view "$branch" --repo "${{ github.repository }}" --json url --jq '.url')
91+
echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT"
92+
4993
- name: Render report body
5094
if: always()
51-
run: python3 scripts/check_readme.py report --results /tmp/results.json --state .github/link-rot-state.json > /tmp/report-body.md
95+
env:
96+
FIX_PR_URL: ${{ steps.prune.outputs.pr_url }}
97+
run: |
98+
if [ -n "$FIX_PR_URL" ]; then
99+
python3 scripts/check_readme.py report --results /tmp/results.json --state .github/link-rot-state.json --fix-pr-url "$FIX_PR_URL" > /tmp/report-body.md
100+
else
101+
python3 scripts/check_readme.py report --results /tmp/results.json --state .github/link-rot-state.json > /tmp/report-body.md
102+
fi
52103
53104
- name: Upsert consolidated link-rot report issue
54105
if: always()

scripts/check_readme.py

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -906,7 +906,7 @@ def cmd_report(args):
906906
results_doc = json.load(fh)
907907
state = load_state_file(args.state)
908908

909-
dead, suspect, blocked = [], [], []
909+
dead, suspect, blocked, moved = [], [], [], []
910910
for r in results_doc["results"]:
911911
st = _row(state, r["url"])
912912
cf = st.get("consecutive_failures", 0)
@@ -916,6 +916,8 @@ def cmd_report(args):
916916
dead.append((r, st))
917917
elif r["class"] == "SUSPECT" and cf >= 2:
918918
suspect.append((r, st))
919+
elif r["class"] == "OK" and r.get("redirect_to"):
920+
moved.append((r, st))
919921

920922
def table(rows):
921923
lines = ["| | Section | Entry | URL | Status | Redirects to | First failed |",
@@ -939,10 +941,33 @@ def table(rows):
939941
"Please open a pull request to fix an entry -- don't reply here with fixes, "
940942
"this issue body is fully rewritten on every run._")
941943
out.append("")
944+
if args.fix_pr_url:
945+
out.append(
946+
f"\U0001F916 An automated fix is open: {escape_md_cell(args.fix_pr_url)} -- "
947+
"it only covers confirmed-dead removals and moved-URL updates below; "
948+
"review and merge it, or edit it further before merging. Suspect links "
949+
"always need a human look, and aren't included."
950+
)
951+
out.append("")
942952
out.append(f"## Dead links ({len(dead)})")
943953
out.append("")
944954
out.append(table(dead) if dead else "_None._")
945955
out.append("")
956+
out.append(f"## Moved links ({len(moved)})")
957+
out.append("")
958+
if moved:
959+
out.append("| Section | Entry | Old URL | Redirects to |")
960+
out.append("|---|---|---|---|")
961+
for r, st in moved:
962+
out.append("| {section} | {title} | {url} | {redirect} |".format(
963+
section=escape_md_cell(r.get("section")),
964+
title=escape_md_cell(r.get("title")),
965+
url=escape_md_cell(r.get("url")),
966+
redirect=escape_md_cell(r.get("redirect_to")),
967+
))
968+
else:
969+
out.append("_None._")
970+
out.append("")
946971
out.append(f"## Suspect links ({len(suspect)})")
947972
out.append("")
948973
out.append(table(suspect) if suspect else "_None._")
@@ -968,6 +993,135 @@ def table(rows):
968993
return 0
969994

970995

996+
# --------------------------------------------------------------------------
997+
# cmd: prune (safe auto-fixes: remove confirmed-dead entries, update moved URLs)
998+
# --------------------------------------------------------------------------
999+
1000+
def _series_children(items, idx):
1001+
"""Contiguous deeper-indented items right after items[idx] (a series), stopping
1002+
at the first item back at or above its depth, a header, or EOF. Mirrors the
1003+
lookahead parse_readme() uses for E002, so "does this series still have a
1004+
child" can't drift between the two.
1005+
"""
1006+
depth = items[idx][1][1]
1007+
children = []
1008+
j = idx + 1
1009+
while j < len(items):
1010+
_, kind = items[j]
1011+
if kind[0] == "blank":
1012+
j += 1
1013+
continue
1014+
if kind[0] == "header":
1015+
break
1016+
if kind[0] in ("entry", "series"):
1017+
if kind[1] <= depth:
1018+
break
1019+
children.append(j)
1020+
j += 1
1021+
continue
1022+
break
1023+
return children
1024+
1025+
1026+
def cmd_prune(args):
1027+
"""Apply only the safe, unambiguous fixes a human would otherwise type by hand:
1028+
delete entries confirmed dead for 2+ consecutive weeks (no known redirect), and
1029+
rewrite an entry's URL in place when it now redirects to a different working
1030+
page. Anything ambiguous (SUSPECT, a dead/moved URL that's a secondary link in
1031+
an entry's prose rather than its primary URL, a URL matching more than one
1032+
entry) is left untouched for a human to judge -- this never runs unreviewed,
1033+
it's meant to land as a pull request.
1034+
"""
1035+
with open(args.results, encoding="utf-8") as fh:
1036+
results_doc = json.load(fh)
1037+
state = load_state_file(args.state)
1038+
1039+
with open(args.readme, encoding="utf-8") as fh:
1040+
text = fh.read()
1041+
lines = text.split("\n")
1042+
1043+
doc = parse_readme(text)
1044+
entries_by_url = {}
1045+
for e in doc.entries:
1046+
entries_by_url.setdefault(normalize_url(e.url), []).append(e)
1047+
1048+
removals, updates = [], []
1049+
for r in results_doc["results"]:
1050+
key = "|".join(normalize_url(r["url"]))
1051+
st = state.get(key)
1052+
cf = st.get("consecutive_failures", 0) if isinstance(st, dict) else 0
1053+
matches = entries_by_url.get(normalize_url(r["url"]), [])
1054+
if len(matches) != 1:
1055+
continue # not a primary entry URL, or a duplicate -- leave for a human
1056+
entry = matches[0]
1057+
if r["class"] == "HARD_DEAD" and cf >= 2 and not r.get("redirect_to"):
1058+
removals.append({"line": entry.line, "url": r["url"], "title": entry.title, "section": entry.section})
1059+
elif r["class"] == "OK" and r.get("redirect_to") and r["redirect_to"] != r["url"]:
1060+
updates.append({"line": entry.line, "old_url": r["url"], "new_url": r["redirect_to"], "title": entry.title, "section": entry.section})
1061+
1062+
if not removals and not updates:
1063+
result = {"changed": False, "removals": [], "updates": []}
1064+
print(json.dumps(result, indent=2))
1065+
return 0
1066+
1067+
for u in updates:
1068+
idx = u["line"] - 1
1069+
lines[idx] = lines[idx].replace(f"]({u['old_url']})", f"]({u['new_url']})", 1)
1070+
1071+
delete_lines = {r["line"] for r in removals}
1072+
changed = True
1073+
while changed:
1074+
changed = False
1075+
items = [(i + 1, classify_line(ln)) for i, ln in enumerate(lines)]
1076+
for idx, (lineno, kind) in enumerate(items):
1077+
if lineno in delete_lines or kind[0] != "series":
1078+
continue
1079+
children = _series_children(items, idx)
1080+
if children and all(items[c][0] in delete_lines for c in children):
1081+
delete_lines.add(lineno)
1082+
changed = True
1083+
1084+
new_lines = [ln for i, ln in enumerate(lines) if (i + 1) not in delete_lines]
1085+
1086+
with open(args.readme, "w", encoding="utf-8") as fh:
1087+
fh.write("\n".join(new_lines))
1088+
1089+
if args.pr_body_out:
1090+
with open(args.pr_body_out, "w", encoding="utf-8") as fh:
1091+
fh.write(render_prune_pr_body(removals, updates))
1092+
1093+
result = {
1094+
"changed": True,
1095+
"removals": removals,
1096+
"updates": updates,
1097+
"removed_lines": sorted(delete_lines),
1098+
}
1099+
print(json.dumps(result, indent=2))
1100+
return 0
1101+
1102+
1103+
def render_prune_pr_body(removals, updates):
1104+
out = ["Automated fix from the weekly link-rot sweep -- only confirmed-dead "
1105+
"removals and moved-URL updates, nothing else is touched.", ""]
1106+
if removals:
1107+
out.append(f"### Removed ({len(removals)}) -- confirmed dead for 2+ consecutive weekly checks")
1108+
out.append("")
1109+
for r in removals:
1110+
out.append(f"- **{escape_md_cell(r['title'])}** ({escape_md_cell(r['section'])}) -- {escape_md_cell(r['url'])}")
1111+
out.append("")
1112+
if updates:
1113+
out.append(f"### Updated ({len(updates)}) -- now redirects to a different working URL")
1114+
out.append("")
1115+
for u in updates:
1116+
out.append(f"- **{escape_md_cell(u['title'])}** ({escape_md_cell(u['section'])}) -- {escape_md_cell(u['old_url'])} -> {escape_md_cell(u['new_url'])}")
1117+
out.append("")
1118+
out.append(
1119+
"See the open \U0001F517 Link rot report issue for suspect links that need a "
1120+
"human look -- those aren't included here."
1121+
)
1122+
return "\n".join(out)
1123+
1124+
9711125
# --------------------------------------------------------------------------
9721126
# CLI
9731127
# --------------------------------------------------------------------------
@@ -1001,8 +1155,16 @@ def build_parser():
10011155
report = sub.add_parser("report", help="render the link-rot markdown issue body")
10021156
report.add_argument("--results", required=True)
10031157
report.add_argument("--state", required=True)
1158+
report.add_argument("--fix-pr-url", default=None, help="if set, link to this PR as the pending automated fix")
10041159
report.set_defaults(func=cmd_report)
10051160

1161+
prune = sub.add_parser("prune", help="apply safe auto-fixes: remove confirmed-dead entries, update moved URLs")
1162+
prune.add_argument("--results", required=True)
1163+
prune.add_argument("--state", required=True)
1164+
prune.add_argument("--readme", default=DEFAULT_README)
1165+
prune.add_argument("--pr-body-out", default=None, help="if set and something changed, write a PR body summary here")
1166+
prune.set_defaults(func=cmd_prune)
1167+
10061168
return p
10071169

10081170

0 commit comments

Comments
 (0)