Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **link-health: stdlib Python link checker as the default.** `LINK_CHECKER=python` (the new default) uses the vendored `link-check.py`, so link-health needs only `python3` >= 3.8, `gh`, `jq`, and `bash` — no lychee install — and runs on a basic agent image. Egress-proxy refusals are classified as `unchecked`: they never open or auto-close issues, and the run summary, `latest.json`, and the automation-health dashboard report the unchecked count and top hosts. `LINK_CHECKER=lychee` remains an opt-in fallback, and `LINK_HEALTH_SHADOW_OUT` runs a checker side-by-side in shadow mode. (#3)

### Changed

- Bundled engine: link-health scanner, extractor, and automation-health dashboard updates from `rossoctl/automation`; `link-check.py` joins the vendored set (`scripts/vendor-engine.sh` extras).

## [0.1.1] - 2026-11-05

### Added
Expand Down
2 changes: 1 addition & 1 deletion scripts/vendor-engine.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pr-review-scanner program-lib repo-sync repoman-check repoman-index \
repoman-requirements repoman-run repoman-setup weekly-report"

# Non-shell engine files copied alongside the manifest (explicit, no glob).
EXTRA_FILES="doc-drift-check.py"
EXTRA_FILES="doc-drift-check.py link-check.py"

# Pre-validate the WHOLE manifest before copying anything, so a source missing
# late in the list cannot leave a half-vendored dest with a stale _vendor.json.
Expand Down
2 changes: 1 addition & 1 deletion skills/automation-health-dashboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ bash scripts/automation-health-dashboard.sh --live --main-repo-dir /path/to/auto
## Dashboard Sections

- **Executive Summary** — total issues created/resolved, PRs opened, estimated hours saved
- **Link Health** — broken links by type, trend table, cumulative issues
- **Link Health** — broken links by type, trend table, cumulative issues, unchecked (egress-refusal) count and top hosts
- **Dependency Bumps** — stale PRs by tier, SLA compliance, median TTM, coverage
- **Cross-Program Coverage** — which repos are under which programs
- **Cron Health** — job schedules and last run status
Expand Down
36 changes: 29 additions & 7 deletions skills/link-health-scanner/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
---
name: link-health-scanner
description: >-
Scan all repos in a GitHub org for broken links using lychee, diff against
previous results, create GitHub issues for new findings, close issues for
fixed links, and write structured JSON reports. Use when you need to audit
link health across an organization's repositories.
Scan all repos in a GitHub org for broken links using the stdlib Python
link checker (no extra install), diff against previous results, create
GitHub issues for new findings, close issues for fixed links, and write
structured JSON reports. Egress-proxy refusals are reported as unchecked
and never open or auto-close issues. Use when you need to audit link
health across an organization's repositories.
license: Complete terms in LICENSE.txt
---

Expand All @@ -17,11 +19,29 @@ Scan all repositories in a GitHub organization for broken links. Creates GitHub
## Prerequisites

- `bash` 3.2+ (works with the macOS system default; no bash-4 features used)
- `lychee` >= 0.23.0 (link checker: `brew install lychee`) — the scanner relies on
lychee's default exclusion of URLs inside code blocks; do not pass `--include-verbatim`
- `python3` >= 3.8 (the default link checker, `scripts/link-check.py`, is stdlib-only:
`urllib`, `concurrent.futures`, `html.parser`, `re` — nothing to install)
- `gh` (GitHub CLI, authenticated with org access)
- `jq` (JSON processor)

### Link checker

- Default: `LINK_CHECKER=python` — the stdlib checker, so the program runs on a
basic agent image with no lychee install.
- Opt-in fallback: `LINK_CHECKER=lychee` (requires `lychee` >= 0.23.0 on PATH;
do not pass `--include-verbatim` — the scanner relies on excluding URLs
inside code blocks, which the python checker does by default too).
- **Unchecked (egress refusals):** links the egress proxy refused to fetch are
reported with status `unchecked`, not broken. No issue is opened for them,
and a previously broken link that comes back unchecked is never auto-closed.
Widen the egress allowlist to turn them into real results.
- **Shadow mode:** set `LINK_HEALTH_SHADOW_OUT=<path>` to write a side report
without touching the production baseline (it forces dry-run), e.g. to run a
candidate checker side-by-side with the production one.
- Per-repo `.lychee.toml` is honored when present; the python checker implements
the `exclude`, `exclude_path`, and `accept` keys (including ranges like
`"200..=204"`) and warns about and ignores any other key.

### Requirements (machine-readable)

- pat_scopes: [repo]
Expand Down Expand Up @@ -87,11 +107,13 @@ Use `--issue-limit` to cap issue creation, especially on first runs.
The scanner prints:
- Repos scanned and any failures
- Total links checked and broken link count (internal vs external)
- Unchecked count (egress refusals) and, when non-zero, the top unchecked hosts
- Delta: new broken links since last scan, fixed links, recurring
- Issues created/closed

Reports written:
- `$REPORTS_DIR/latest.json` -- full scan results
- `$REPORTS_DIR/latest.json` -- full scan results (including `unchecked_count`
and `unchecked_top_hosts`)
- `$REPORTS_DIR/history.json` -- append-only trend data

## Safety
Expand Down
7 changes: 4 additions & 3 deletions skills/repoman/scripts/_vendor.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"source_repo": "rossoctl/automation",
"source_sha": "fa61909029207b08d17eff5625bd0aa4772c90b2",
"vendored_at": "2026-11-03T22:56:54Z",
"source_sha": "4f284d0d65712fce2309b6f5911d481d5bc45351",
"vendored_at": "2026-11-11T19:04:00Z",
"files": [
"automation-health-dashboard.sh",
"core.sh",
Expand Down Expand Up @@ -29,6 +29,7 @@
"repoman-run.sh",
"repoman-setup.sh",
"weekly-report.sh",
"doc-drift-check.py"
"doc-drift-check.py",
"link-check.py"
]
}
11 changes: 11 additions & 0 deletions skills/repoman/scripts/automation-health-dashboard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ LH_REPOS_SCANNED=0
LH_TOTAL_LINKS=0
LH_BROKEN_INTERNAL=0
LH_BROKEN_EXTERNAL=0
LH_UNCHECKED_COUNT=0
LH_UNCHECKED_TOP_HOSTS="(none)"
LH_FIRST_INTERNAL=0
LH_FIRST_EXTERNAL=0
LH_TREND_TABLE="| - | - | - | - |"
Expand All @@ -300,6 +302,13 @@ if [ "$HAS_LINK_HEALTH" = true ]; then
LH_TOTAL_LINKS=$(jq '.total_links_checked // 0' "$LINK_SCAN_DIR/latest.json")
LH_BROKEN_INTERNAL=$(jq '[.broken[] | select(.category == "internal")] | length' "$LINK_SCAN_DIR/latest.json" 2>/dev/null || echo "0")
LH_BROKEN_EXTERNAL=$(jq '[.broken[] | select(.category == "external")] | length' "$LINK_SCAN_DIR/latest.json" 2>/dev/null || echo "0")
LH_UNCHECKED_COUNT=$(jq '.unchecked_count // 0' "$LINK_SCAN_DIR/latest.json" 2>/dev/null || echo "0")
LH_UNCHECKED_TOP_HOSTS=$(jq -r '
(.unchecked_top_hosts // []) as $h |
if ($h | length) == 0 then "(none)"
else ($h | map("\(.host) (\(.count))") | join(", "))
end
' "$LINK_SCAN_DIR/latest.json" 2>/dev/null || echo "(none)")

# First scan values for trend comparison
LH_FIRST_INTERNAL=$(jq '.[0].broken_internal // 0' "$LINK_SCAN_DIR/history.json")
Expand Down Expand Up @@ -526,6 +535,8 @@ if [ "$HAS_LINK_HEALTH" = true ]; then
| Total links checked | $LH_TOTAL_LINKS | |
| Broken (internal) | $LH_BROKEN_INTERNAL | $lh_int_trend |
| Broken (external) | $LH_BROKEN_EXTERNAL | $lh_ext_trend |
| Unchecked (egress refusals) | $LH_UNCHECKED_COUNT | |
| Unchecked top hosts | $LH_UNCHECKED_TOP_HOSTS | |
| Issues created (cumulative) | $LH_ISSUES_CREATED | |
| Issues resolved (cumulative) | $LH_ISSUES_CLOSED | |

Expand Down
39 changes: 22 additions & 17 deletions skills/repoman/scripts/dep-bump-fixer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,18 @@ SCAN_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
MAX_HISTORY_ROWS=500
# Provenance footer appended to every fixer comment/issue. Resolved once at
# startup: the helper lazily writes _meta.<skill>.json beside this skill and returns the
# pinned-SHA RepoMan attribution (blob/main fallback when unresolved).
# pinned-SHA attribution (linkless fallback when unresolved).
SKILL_FOOTER=$(skill_attribution "dep-bump-fixer")
# Stable dedup key: the substring every footer form shares, regardless of the
# per-run SHA (both "...RepoMan dep-bump-fixer@<sha>..." and the blob/main
# fallback contain it). The dedup search (below) looks for THIS, not the whole
# footer, so a changing SHA never defeats dedup. Comments carrying the former
# marker value are no longer recognized, so an already-analyzed PR may receive
# one duplicate comment on the next run after the marker changed — acceptable,
# one-time.
FIXER_SIGNATURE="RepoMan dep-bump-fixer"
# Stable dedup key: a regex (jq `test`, below) anchored to the footer so it
# matches a fixer comment but not a human comment that merely mentions the tool.
# It matches all three footer forms, regardless of the per-run SHA: the resolved
# footer ("...by repoman [dep-bump-fixer@<sha>](...)"), the linkless fallback
# ("...by repoman dep-bump-fixer."), and the legacy "...by RepoMan dep-bump-fixer@"
# form (so comments posted before the format flip keep deduping). A bare mention
# like "dep-bump-fixer skipped this one" or a link to skills/dep-bump-fixer/ is
# NOT anchored to "by (repoman|RepoMan)", so it never false-positives into
# suppressing analysis.
FIXER_SIGNATURE="by (repoman \[?|RepoMan )dep-bump-fixer"

# --- Workspace setup ---
setup_workspace "dep-bump-fixer"
Expand Down Expand Up @@ -257,16 +259,19 @@ while IFS= read -r issue_record; do
continue
fi

# Check if fixer already commented on this PR
# $sig below is a jq variable passed via --arg, not a shell expansion
# shellcheck disable=SC2016
already_commented=$(gh_with_backoff pr view "$pr_number" --repo "$issue_repo" \
--json comments --jq --arg sig "$FIXER_SIGNATURE" \
'[.comments[] | select(.body | contains($sig))] | length' 2>/dev/null || echo "0")
# Check if fixer already commented on this PR. The signature is a regex bound
# via a real jq --arg inside the helper — `gh pr view --jq` cannot take --arg.
# The helper returns -1 (UNKNOWN) when the fetch fails; skip on anything but a
# hard 0 so a transient error never re-posts a duplicate comment.
already_commented=$(fixer_comment_count "$issue_repo" "$pr_number" "$FIXER_SIGNATURE")

if [ "$already_commented" -gt 0 ]; then
if [ "$already_commented" != "0" ]; then
if [ "$VERBOSE" = true ]; then
echo " Already commented, skipping"
if [ "$already_commented" = "-1" ]; then
echo " Comment-status check failed, skipping to avoid a duplicate"
else
echo " Already commented, skipping"
fi
fi
SKIPPED_ALREADY_COMMENTED=$((SKIPPED_ALREADY_COMMENTED + 1))
continue
Expand Down
2 changes: 1 addition & 1 deletion skills/repoman/scripts/dep-bump-scanner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ ESCALATION_THRESHOLD=5

# Provenance footer for the issue bodies this scanner creates. Resolved once at
# startup: the helper lazily writes _meta.<skill>.json beside this skill and returns the
# pinned-SHA RepoMan attribution (blob/main fallback when the SHA is unresolved).
# pinned-SHA attribution (linkless fallback when the SHA is unresolved).
SKILL_FOOTER=$(skill_attribution "dep-bump-scanner")

# SLA thresholds (days)
Expand Down
11 changes: 9 additions & 2 deletions skills/repoman/scripts/doc-drift-precheck.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ while [ $# -gt 0 ]; do
Usage: doc-drift-precheck.sh [--repo owner/name] [--worklist /path]

Run the deterministic doc-drift checker. Exit 1 when the worklist is empty;
exit 0 and print "worklist: <path>" when it is not. Fetch failures are
exit 0 and print "worklist: <path>" when it is not. The printed path persists
after the run. --worklist overrides the default of
${REPORTS_DIR:-$HOME/reports/doc-drift}/worklist.json. Fetch failures are
fail-open (exit 2) so a broken gate does not block the run.
EOF
exit 0
Expand All @@ -39,8 +41,13 @@ if [ ! -f "$CHECK_PY" ]; then
fi

setup_workspace "doc-drift-precheck"
# Default the worklist to a PERSISTENT path, not the per-run temp dir: setup_workspace
# registers an EXIT trap that deletes $PROGRAM_TMPDIR, so a temp default would hand the
# caller a path to a file that is already gone (#130). Mirror the scanner's REPORTS_DIR.
if [ -z "$WORKLIST" ]; then
WORKLIST="$PROGRAM_TMPDIR/worklist.json"
REPORTS_DIR="${REPORTS_DIR:-$HOME/reports/doc-drift}"
mkdir -p "$REPORTS_DIR"
WORKLIST="$REPORTS_DIR/worklist.json"
fi

ENROLLED=$(repoman_load_enrolled) || exit 2
Expand Down
9 changes: 6 additions & 3 deletions skills/repoman/scripts/extract-broken-links.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ set -euo pipefail
# hostnames (Kubernetes cluster-local .svc names, *.local, RFC1918 ranges) are
# suppressed, since an external scanner can never reach them. lychee status is
# normalized to enum tokens: numeric HTTP codes stay as-is; text statuses map
# to timeout / dns / unreachable / error / unknown.
# to timeout / dns / unreachable / unchecked / error / unknown.
#
# This logic was previously inline in link-health-scanner.sh; it is extracted
# here so it can be unit-tested against synthetic lychee fixtures.
Expand Down Expand Up @@ -53,9 +53,11 @@ if [ "$LYCHEE_INPUT" != "-" ] && [ ! -s "$LYCHEE_INPUT" ]; then
fi

# --- Extract broken links from error_map (skip non-URL entries like "Error building URL") ---
# Normalize lychee status to enum tokens: numeric codes stay as-is,
# text statuses map to: timeout, dns, unreachable, error, unknown.
# Normalize lychee/link-check status to enum tokens: numeric codes stay as-is,
# text statuses map to: timeout, dns, unreachable, unchecked, error, unknown.
# Suppress URLs with unreachable-by-design hostnames (cluster-local, .local, RFC1918).
# "unchecked" (egress-proxy refusal) must match before the error catch-all so
# the scanner can keep those out of the issue open/close path.
jq -r --arg repo "$REPO_FULL" --arg repos_dir "$REPOS_PREFIX" '
.error_map // {} | to_entries[] |
.key as $filepath |
Expand All @@ -73,6 +75,7 @@ jq -r --arg repo "$REPO_FULL" --arg repos_dir "$REPOS_PREFIX" '
if $raw_status == null then "unknown"
elif ($raw_status | type) == "number" then ($raw_status | tostring)
elif ($raw_status | test("^[0-9]{3}$")) then $raw_status
elif ($raw_status | ascii_downcase) == "unchecked" then "unchecked"
elif ($raw_status | ascii_downcase | test("timeout")) then "timeout"
elif ($raw_status | ascii_downcase | test("resolve|dns")) then "dns"
elif ($raw_status | ascii_downcase | test("refused|reset|closed|unreachable|connect")) then "unreachable"
Expand Down
Loading
Loading