diff --git a/.github/workflows/cowork-auto-pr.yml b/.github/workflows/cowork-auto-pr.yml new file mode 100644 index 0000000..91690e6 --- /dev/null +++ b/.github/workflows/cowork-auto-pr.yml @@ -0,0 +1,28 @@ +# Seeded by the repo-improver-rotation Cowork job into cowork/improve-* branches. +# Opens a PR automatically when such a branch is pushed (sandbox cannot reach +# the GitHub API directly; this runs server-side with the repo's GITHUB_TOKEN). +name: cowork-auto-pr +on: + push: + branches: ['cowork/improve-**'] +permissions: + contents: read + pull-requests: write +jobs: + ensure-pr: + runs-on: ubuntu-latest + steps: + - name: Open PR for this branch if none exists + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$GITHUB_REF_NAME" --state open --json number --jq 'length') + if [ "$existing" = "0" ]; then + gh pr create --repo "$GITHUB_REPOSITORY" \ + --head "$GITHUB_REF_NAME" \ + --title "cowork-bot: automated improvements ($GITHUB_REF_NAME)" \ + --body "Automated improvement PR from the Cowork repo-improver rotation (one coherent senior-dev improvement per run; see individual commit messages). Subsequent runs push additional commits to this PR rather than opening new ones." + else + echo "Open PR already exists for $GITHUB_REF_NAME — nothing to do." + fi diff --git a/.hermes/linkcheck.py b/.hermes/linkcheck.py index 6c1ac0f..5525ea8 100644 --- a/.hermes/linkcheck.py +++ b/.hermes/linkcheck.py @@ -37,17 +37,25 @@ def build_actual_pages(all_files: set[str]) -> set[str]: def resolve_link(link: str, source_dir: str) -> str: """Resolve a relative href to an absolute site-relative path.""" - if link.startswith("../"): - parts = source_dir.split("/") if source_dir else [] - up_count = link.count("../") - resolved_parts = parts[:-up_count] if len(parts) >= up_count else [] - remaining = link[3 * up_count :] - resolved = "/".join(resolved_parts + [remaining]) if resolved_parts else remaining - elif link.startswith("./"): - resolved = (source_dir + "/" + link[2:]) if source_dir else link[2:] + if link.startswith("http://") or link.startswith("https://") or link.startswith("#"): + return link + if link.startswith("/"): + return link.lstrip("/") + + if source_dir: + path = source_dir + "/" + link else: - resolved = (source_dir + "/" + link) if source_dir else link - return resolved.replace("//", "/") + path = link + + normalized = os.path.normpath(path).replace(os.sep, "/") + + # Cap at site root: strip any leading ../ that goes above root + while normalized.startswith("../"): + normalized = normalized[3:] + if normalized.startswith("/"): + normalized = normalized[1:] + + return normalized def check_links(root_dir: str, verbose: bool = False) -> int: diff --git a/README.md b/README.md index de41a78..33390fe 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Deploy](https://img.shields.io/badge/deploy-GitHub%20Pages-brightgreen)](https://coding-dev-tools.github.io/devforge/) [![Stars](https://img.shields.io/github/stars/Coding-Dev-Tools/devforge?style=social)](https://github.com/Coding-Dev-Tools/devforge/stargazers) -Landing page, documentation, pricing, alternatives, and blog for the **DevForge CLI tool suite** — 11 developer CLI tools for API contracts, SQL, infrastructure, config drift, and more. **60 articles and growing.** +Landing page, documentation, pricing, alternatives, and blog for the **DevForge CLI tool suite** — 11 developer CLI tools for API contracts, SQL, infrastructure, config drift, and more. **70 articles and growing.** **Live site:** [coding-dev-tools.github.io/devforge/](https://coding-dev-tools.github.io/devforge/) @@ -14,7 +14,7 @@ Landing page, documentation, pricing, alternatives, and blog for the **DevForge | [Home](https://coding-dev-tools.github.io/devforge/) | Hero, stats, feature cards, CTA | | [Pricing](https://coding-dev-tools.github.io/devforge/pricing.html) | Free / Pro / Team / Enterprise tiers | | [Alternatives](https://coding-dev-tools.github.io/devforge/alternatives.html) | 11 comparison tables vs competitors | -| [Blog](https://coding-dev-tools.github.io/devforge/blog.html) | 60+ articles and tutorials | +| [Blog](https://coding-dev-tools.github.io/devforge/blog.html) | 70+ articles and tutorials | | [Docs](https://coding-dev-tools.github.io/devforge/docs.html) | Tool documentation hub | | [Quickstart](https://coding-dev-tools.github.io/devforge/quickstart.html) | Get started in 60 seconds | | [FAQ](https://coding-dev-tools.github.io/devforge/#faq) | Common questions | diff --git a/sitemap.xml b/sitemap.xml index c0f8a8e..8bee297 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -33,9 +33,6 @@ https://coding-dev-tools.github.io/devforge/quickstart.html 2026-05-17 - - https://coding-dev-tools.github.io/revenueholdings.dev/blog/openapi-diff-tools-comparison.html - 2026-05-15 monthly 0.9 @@ -447,28 +444,58 @@ monthly 0.6 - - https://coding-dev-tools.github.io/devforge/blog/traction-hits-680-developers.html - 2026-05-17 - monthly - 0.6 - - - https://coding-dev-tools.github.io/revenueholdings.dev/alternatives.html - 2026-05-15 + + https://coding-dev-tools.github.io/devforge/blog/click-to-mcp-three-distribution-channels.html + 2026-06-13 monthly - 0.9 + 0.6 + + + https://coding-dev-tools.github.io/devforge/blog/click-to-mcp-three-transport-modes.html + 2026-05-28 + monthly + 0.6 + + https://coding-dev-tools.github.io/devforge/blog/configdrift-scan-multi-environment-configs-one-command.html + 2026-05-28 + monthly + 0.6 + + + https://coding-dev-tools.github.io/devforge/blog/deploydiff-cost-governance-before-you-deploy.html + 2026-05-28 + monthly + 0.6 + + + https://coding-dev-tools.github.io/devforge/blog/saas-churn-predictor-launch.html + 2026-05-28 + monthly + 0.6 + + + https://coding-dev-tools.github.io/devforge/blog/apicontractguardian-git-branch-openapi-diff-pr-review.html + 2026-08-26 + weekly + 0.6 + - https://coding-dev-tools.github.io/revenueholdings.dev/quickstart.html - 2026-05-15 - monthly - 0.9 + https://coding-dev-tools.github.io/devforge/blog/deadcode-remove-dead-code-nextjs-app-router.html + 2026-08-26 + weekly + 0.6 + + + https://coding-dev-tools.github.io/devforge/blog/json2sql-cicd-automated-database-seeding.html + 2026-08-26 + weekly + 0.6 - https://coding-dev-tools.github.io/revenueholdings.dev/blog/clean-up-react-dead-code.html - 2026-05-15 - monthly + https://coding-dev-tools.github.io/devforge/blog/schemaforge-convert-prisma-to-drizzle-migration-guide.html + 2026-08-26 + weekly 0.6 diff --git a/tests/test_linkcheck.py b/tests/test_linkcheck.py index 739c826..932e782 100644 --- a/tests/test_linkcheck.py +++ b/tests/test_linkcheck.py @@ -167,6 +167,24 @@ def test_resolve_link_double_slash(): assert result == "blog/page.html" +def test_resolve_link_absolute_path(): + """Absolute path /about.html strips leading slash.""" + result = resolve_link("/about.html", "blog") + assert result == "about.html" + + +def test_resolve_link_up_with_intermediate_segments(): + """../foo/../bar.html correctly cancels the intermediate segment.""" + result = resolve_link("../foo/../bar.html", "blog/2024") + assert result == "blog/bar.html" + + +def test_resolve_link_up_capped_at_root(): + """../../ from a shallow dir is capped at site root.""" + result = resolve_link("../../page.html", "blog") + assert result == "page.html" + + # ---- CLI argument parsing ---- diff --git a/tests/test_linkcheck_additional.py b/tests/test_linkcheck_additional.py index c26ac5f..802c09b 100644 --- a/tests/test_linkcheck_additional.py +++ b/tests/test_linkcheck_additional.py @@ -11,7 +11,7 @@ _SCRIPT_DIR = Path(__file__).resolve().parent.parent / ".hermes" sys.path.insert(0, str(_SCRIPT_DIR)) -from linkcheck import build_actual_pages +from linkcheck import build_actual_pages # noqa: E402 class TestBuildActualPages: diff --git a/tests/test_linkcheck_edge_cases.py b/tests/test_linkcheck_edge_cases.py index c2606de..055e8e2 100644 --- a/tests/test_linkcheck_edge_cases.py +++ b/tests/test_linkcheck_edge_cases.py @@ -16,7 +16,7 @@ _SCRIPT_DIR = Path(__file__).resolve().parent.parent / ".hermes" sys.path.insert(0, str(_SCRIPT_DIR)) -from linkcheck import check_links, main +from linkcheck import check_links, main # noqa: E402 class TestCheckLinksEdgeCases: diff --git a/tests/test_sitemap.py b/tests/test_sitemap.py new file mode 100644 index 0000000..892321e --- /dev/null +++ b/tests/test_sitemap.py @@ -0,0 +1,51 @@ +"""Regression tests: sitemap.xml must exactly cover the site's real pages. + +Every non-404 HTML file in the repo must have a entry, and every + must resolve to a real page (index.html is represented as "/"). +""" + +import os +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SITEMAP = REPO / "sitemap.xml" +BASE = "https://coding-dev-tools.github.io/devforge/" +NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}" + + +def _actual_pages(): + pages = set() + for root, dirs, files in os.walk(REPO): + rel_root = Path(root).relative_to(REPO) + dirs[:] = [d for d in dirs if d not in (".git", "drafts", ".hermes", "tests")] + for fn in files: + if fn.endswith(".html") and fn != "404.html": + p = str(rel_root / fn).replace(os.sep, "/") + pages.add(BASE if p == "index.html" else BASE + p) + return pages + + +def _sitemap_urls(): + tree = ET.parse(SITEMAP) + return {e.text for e in tree.getroot().iter(NS + "loc")} + + +def test_sitemap_parses(): + ET.parse(SITEMAP) + + +def test_sitemap_covers_every_page(): + missing = _actual_pages() - _sitemap_urls() + assert not missing, f"pages missing from sitemap.xml: {sorted(missing)}" + + +def test_sitemap_has_no_stale_urls(): + stale = _sitemap_urls() - _actual_pages() + assert not stale, f"stale sitemap.xml URLs with no page: {sorted(stale)}" + + +def test_no_duplicate_locs(): + urls = re.findall(r"(.*?)", SITEMAP.read_text(encoding="utf-8")) + assert len(urls) == len(set(urls))