From 969da1ccaaaed2e39c481b1d0b530d351f42f4c3 Mon Sep 17 00:00:00 2001 From: DevForge Engineer Date: Sat, 13 Jun 2026 07:31:14 +0000 Subject: [PATCH 1/4] cowork-bot: fix ruff E402 noqa on test imports tests/test_linkcheck_additional.py and tests/test_linkcheck_edge_cases.py perform a sys.path.insert before importing linkcheck, which means the import must come after the path manipulation. Ruff flags this as E402 (module-level import not at top of file). Add '# noqa: E402' to silence the expected warning, matching the existing suppression in test_linkcheck.py. This makes 'ruff check .' pass cleanly and unblocks CI. --- tests/test_linkcheck_additional.py | 2 +- tests/test_linkcheck_edge_cases.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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: From dd0393c985d03814770c5f41a000118fc98d7fc6 Mon Sep 17 00:00:00 2001 From: DevForge Engineer Date: Sat, 13 Jun 2026 07:31:39 +0000 Subject: [PATCH 2/4] cowork-bot: add cowork-auto-pr workflow Seeds the GitHub Actions workflow that opens a PR server-side for this branch (sandbox cannot reach the GitHub API directly). --- .github/workflows/cowork-auto-pr.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/cowork-auto-pr.yml 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 From 42783fc2c5d1a18266ad1c1b8119a35b95b6ced8 Mon Sep 17 00:00:00 2001 From: DevForge Engineer Date: Tue, 23 Jun 2026 06:08:34 -0400 Subject: [PATCH 3/4] cowork-bot: fix link resolution for ../ intermediates and absolute paths; update article count --- .hermes/linkcheck.py | 28 ++++++++++++++++++---------- README.md | 4 ++-- tests/test_linkcheck.py | 18 ++++++++++++++++++ 3 files changed, 38 insertions(+), 12 deletions(-) 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/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 ---- From bdd5c6efe4f12ec5b6da0be046093e347852c8ed Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 24 Aug 2026 00:00:13 -0400 Subject: [PATCH 4/4] =?UTF-8?q?cowork-bot:=20fix=20sitemap.xml=20coverage?= =?UTF-8?q?=20=E2=80=94=20remove=20stale=20traction-hits=20URL,=20add=205?= =?UTF-8?q?=20missing=20blog=20articles;=20add=20regression=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sitemap.xml | 36 +++++++++++++++++++++++++----- tests/test_sitemap.py | 51 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 tests/test_sitemap.py diff --git a/sitemap.xml b/sitemap.xml index bf5ec7a..9300a8d 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -444,11 +444,35 @@ 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/devforge/blog/click-to-mcp-three-distribution-channels.html + 2026-06-13 + monthly + 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 + 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))