diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b761b56 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Test + +# Runs scripts/update-readme.test.mjs on every PR into main. Intended to be +# added as a required status check on the prToMain ruleset, so a PR that +# breaks scripts/update-readme.mjs (including one opened by +# update-readme.yml's own daily run) can't merge — see the "required check" +# discussion on PR #2. + +on: + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - run: node --test scripts/*.test.mjs diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml new file mode 100644 index 0000000..f24bb99 --- /dev/null +++ b/.github/workflows/update-readme.yml @@ -0,0 +1,111 @@ +name: Update README + +# Daily refresh of profile/README.md's auto-generated sections (see +# scripts/update-readme.mjs): the repository table (from the GitHub API) and +# the software tools tables (imported from robotiq/robotiq.github.io's +# docs/intro.mdx). Opens a PR only when the regenerated content actually +# differs, then auto-merges it — mirroring +# robotiq.github.io's .github/workflows/dependabot-auto-merge.yml. +# +# Why this needs a PAT instead of just GITHUB_TOKEN: the prToMain ruleset's +# required-review rule blocks a PR's *author* from approving its own PR. In +# dependabot-auto-merge.yml, Dependabot (a distinct actor) opens the PR and +# GITHUB_TOKEN (github-actions[bot]) approves it — two different actors, so +# it's not self-approval. Here there's no Dependabot; this workflow itself +# generates the content. So the "open PR" step authenticates as +# README_BOT_PAT (a fine-grained PAT scoped to just this repo, Contents + +# Pull requests: write) instead of GITHUB_TOKEN, making its author a +# distinct actor from the github-actions[bot] identity that then approves +# and merges it in the next step. +# +# Why this is safe to merge without further human review: the generated +# content is either the GitHub API's own repo descriptions or +# robotiq.github.io's own published docs/intro.mdx — both already public and +# already reviewed upstream. What actually gates the merge is the "Test" +# workflow (.github/workflows/test.yml) as a required status check on the +# prToMain ruleset: `gh pr merge --auto` waits on it, so a change that breaks +# update-readme.mjs sits as an open, failing PR instead of reaching the org's +# public landing page. Without that required check, `--auto` has nothing to +# wait on and errors out instead of merging — see the review on PR #2. +# +# Also requires: `allow_auto_merge` enabled on this repo (Settings → General +# → Pull Requests), and the commit's author email resolving to a real GitHub +# account (below) so prToMain's require_extra_approval_for_unattributed_changes +# rule doesn't kick in and demand a second approval nothing here can produce. + +on: + schedule: + - cron: '17 6 * * *' + workflow_dispatch: {} + +permissions: + contents: write + pull-requests: write + +jobs: + update-readme: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.README_BOT_PAT }} + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Regenerate README + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/update-readme.mjs + + - name: Open PR if content changed + id: pr + env: + GH_TOKEN: ${{ secrets.README_BOT_PAT }} + run: | + if git diff --quiet -- profile/README.md; then + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Fixed name rather than one timestamped per run: a run that lands + # before yesterday's PR merged (still waiting on the required + # check) force-pushes onto the same branch/PR instead of piling up + # a new branch and a new unmergeable PR each day. + BRANCH="auto/update-readme" + git config user.name "robotiq-readme-bot" + # This must resolve to a real GitHub account (github-actions[bot]'s + # own noreply address) or the prToMain ruleset's + # require_extra_approval_for_unattributed_changes rule treats the + # commit as unattributed and demands a second approval this + # workflow has no way to produce. + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add profile/README.md + git commit -m "chore: update README repository table and software tools section" + git push --force origin "$BRANCH" + + EXISTING_PR=$(gh pr list --base main --head "$BRANCH" --state open --json url --jq '.[0].url') + if [ -n "$EXISTING_PR" ]; then + PR_URL="$EXISTING_PR" + else + PR_URL=$(gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "chore: update README repository table" \ + --body "Automated daily refresh of the repository table (GitHub API) and software tools section (robotiq.github.io docs/intro.mdx) — see \`scripts/update-readme.mjs\`.") + fi + + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "url=$PR_URL" >> "$GITHUB_OUTPUT" + + - name: Approve and auto-merge + if: steps.pr.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.pr.outputs.url }} + run: | + gh pr review --approve "$PR_URL" + gh pr merge --auto --squash --delete-branch "$PR_URL" diff --git a/profile/README.md b/profile/README.md index a31b7dd..a319a4f 100644 --- a/profile/README.md +++ b/profile/README.md @@ -1,22 +1,88 @@ # Robotiq -Open-source tools and drivers for Robotiq products. +Open-source software tools for Robotiq developers. +

+ + Robotiq documentation website + +

+ +

+ Full documentation, guides, and API references for `Robotiq` software tools.
+ Start there to find your product, then jump into the matching repository below. +

+ +## Repositories + + + | Repository | Description | |---|---| -| [grippers](https://github.com/Robotiq/grippers) | Standalone C++ SDK for the 2F adaptive grippers (2F-85 / 2F-140 / Hand-E) over Modbus RTU | -| [tactile_sensors](https://github.com/Robotiq/tactile_sensors) | SDK, sensor I/O, and quickstart tools for the TSF-85 tactile sensor | -| [ros](https://github.com/Robotiq/ROS_Packages) | Robotiq ROS packages (grippers, tactile sensor) | +| [grippers](https://github.com/robotiq/grippers) | Standalone C++ SDK for Robotiq 2F adaptive grippers (2F-85 / 2F-140 / Hand-E) over Modbus RTU | +| [isaacsim_assets](https://github.com/robotiq/isaacsim_assets) | Our Isaac Sim assets available to the public | +| [ros](https://github.com/robotiq/ros) | Collection of ROS packages for controlling Robotiq hardware. | +| [tactile_sensors](https://github.com/robotiq/tactile_sensors) | Get started with Robotiqs TSF-85 tactile sensors | + -## Community & Third-Party +## Software tools -A curated list of community-maintained drivers and useful third-party projects related to Robotiq products and robotic grasping. + + +#### SDKs/languages -| Repository | Description | +| Product | C | C++ | Python | +|---|---|---|---| +| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | - | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](https://robotiq.github.io/docs/drivers/2F%20hande/SDK/C++) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/SDK/Python) | +| [FT300-S](https://robotiq.github.io/docs/drivers/FT300) | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](https://robotiq.github.io/docs/drivers/FT300/SDK/C) | - | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/FT300/SDK/Python) | +| [TSF-85](https://robotiq.github.io/docs/drivers/TSF-85) | - | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](https://robotiq.github.io/docs/drivers/TSF-85/SDK/C++) | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](https://robotiq.github.io/docs/drivers/TSF-85/SDK/Python) | + +- **C** — Low-level C driver talking directly to the hardware's communication protocol (e.g. Modbus RTU, serial). +- **C++** — Low-level C++ driver/SDK for direct hardware integration. +- **Python** — Python driver/SDK for scripting and rapid prototyping. + +#### ROS2 + +| Product | Rolling | Jazzy | Iron | Humble | +|---|---|---|---|---| +| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS2-Rolling) | - | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS2-Iron) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS2-Humble) | +| [FT300-S](https://robotiq.github.io/docs/drivers/FT300) | - | - | - | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/FT300/ROS/ROS2-Humble) | +| [TSF-85](https://robotiq.github.io/docs/drivers/TSF-85) | - | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](https://robotiq.github.io/docs/drivers/TSF-85/ROS/ROS2-Jazzy) | - | - | + +- **Rolling** — ROS 2 rolling development distro, always tracking the latest sources. +- **Jazzy** — ROS 2 LTS release (2024), supported until 2029. +- **Iron** — ROS 2 release (2023), end of life. +- **Humble** — ROS 2 LTS release (2022), supported until 2027. + +#### ROS1 + +| Product | Melodic | Kinetic | Jade | Indigo | +|---|---|---|---|---| +| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS1-Melodic) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS1-Kinetic) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS1-Jade) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS1-Indigo) | + +- **Melodic** — ROS 1 release (2018), end of life. +- **Kinetic** — ROS 1 release (2016), end of life. +- **Jade** — ROS 1 release (2015), end of life. +- **Indigo** — ROS 1 release (2014), end of life. + +#### Physics engine + +| Product | Isaac Sim | PyBullet | +|---|---|---| +| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/Physics%20Engine/Isaac%20Sim) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/Physics%20Engine/PyBullet) | +| [TSF-85](https://robotiq.github.io/docs/drivers/TSF-85) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/TSF-85/Physics%20Engine/Isaac%20Sim) | - | + +- **Isaac Sim** — NVIDIA Isaac Sim integration for simulating the hardware. +- **PyBullet** — PyBullet integration for physics-based simulation. + +#### Other community projects + +| Product | GraspGen | |---|---| -| [TSF-85 Isaac Sim Extension](https://github.com/Lab-CORO/TSF-85) | An Isaac Sim extension generating synthetic tactile maps for the TSF-85 | -| [pyRobotiqGripper](https://github.com/castetsb/pyRobotiqGripper) | Python library for controlling Robotiq grippers (2F-85, 2F-140, Hand-E) via Modbus RTU | -| [ur_rtde](https://sdurobotics.gitlab.io/ur_rtde) | Provides some useful [instructions](https://sdurobotics.gitlab.io/ur_rtde/guides/guides.html#use-with-robotiq-gripper) on how to control a Robotiq gripper directly using a raw TCP socket | -| [pyFT300](https://github.com/castetsb/pyFT300) | Python library for reading data from the Robotiq FT-300 force-torque sensor | -| [pybullet_ur5_robotiq](https://github.com/ElectronicElephant/pybullet_ur5_robotiq) | UR5 + Robotiq 85/140 gripper simulation in PyBullet | -| [GraspGen](https://github.com/NVlabs/GraspGen) | GraspGen: a diffusion-based framework for 6-DOF grasping | +| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](https://robotiq.github.io/docs/drivers/2F%20hande/Other/GraspGen) | + +- **GraspGen** — NVIDIA GraspGen asset/model package with Robotiq gripper definitions for grasp synthesis research. + + diff --git a/scripts/fixtures/intro.mdx b/scripts/fixtures/intro.mdx new file mode 100644 index 0000000..25818c8 --- /dev/null +++ b/scripts/fixtures/intro.mdx @@ -0,0 +1,83 @@ +--- +title: Overview +sidebar_label: Overview +sidebar_position: 0 +--- + +## Hardware documentation + +Robotiq hardware communication protocols are described in detail in the product +manuals, which serve as the reference for developing custom drivers. + +Hardware manuals are available on Robotiq support website: +https://robotiq.com/support + +## Software tools + +Badge colors indicate who maintains the integration: ![Robotiq](https://img.shields.io/badge/Robotiq-blue) an official Robotiq-maintained driver, ![Third party](https://img.shields.io/badge/Third_party-lightgrey) a community-maintained project. + +### SDKs/languages + +{/* AUTO-GENERATED-SDK-TABLE:START */} +| Product | C | C++ | Python | +|---|---|---|---| +| [2F / Hand-E](drivers/2F%20hande) | - | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](drivers/2F%20hande/SDK/C++) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/SDK/Python) | +| [FT300-S](drivers/FT300) | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](drivers/FT300/SDK/C) | - | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/FT300/SDK/Python) | +| [TSF-85](drivers/TSF-85) | - | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](drivers/TSF-85/SDK/C++) | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](drivers/TSF-85/SDK/Python) | + +- **C** — Low-level C driver talking directly to the hardware's communication protocol (e.g. Modbus RTU, serial). +- **C++** — Low-level C++ driver/SDK for direct hardware integration. +- **Python** — Python driver/SDK for scripting and rapid prototyping. +{/* AUTO-GENERATED-SDK-TABLE:END */} + +### ROS + +#### ROS2 + +{/* AUTO-GENERATED-ROS2-TABLE:START */} +| Product | Rolling | Jazzy | Iron | Humble | +|---|---|---|---|---| +| [2F / Hand-E](drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS2-Rolling) | - | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS2-Iron) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS2-Humble) | +| [FT300-S](drivers/FT300) | - | - | - | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/FT300/ROS/ROS2-Humble) | +| [TSF-85](drivers/TSF-85) | - | [![Robotiq](https://img.shields.io/badge/Robotiq-blue)](drivers/TSF-85/ROS/ROS2-Jazzy) | - | - | + +- **Rolling** — ROS 2 rolling development distro, always tracking the latest sources. +- **Jazzy** — ROS 2 LTS release (2024), supported until 2029. +- **Iron** — ROS 2 release (2023), end of life. +- **Humble** — ROS 2 LTS release (2022), supported until 2027. +{/* AUTO-GENERATED-ROS2-TABLE:END */} + +#### ROS1 + +{/* AUTO-GENERATED-ROS1-TABLE:START */} +| Product | Melodic | Kinetic | Jade | Indigo | +|---|---|---|---|---| +| [2F / Hand-E](drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS1-Melodic) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS1-Kinetic) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS1-Jade) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/ROS/ROS1-Indigo) | + +- **Melodic** — ROS 1 release (2018), end of life. +- **Kinetic** — ROS 1 release (2016), end of life. +- **Jade** — ROS 1 release (2015), end of life. +- **Indigo** — ROS 1 release (2014), end of life. +{/* AUTO-GENERATED-ROS1-TABLE:END */} + +### Physics engine + +{/* AUTO-GENERATED-PHYSICS_ENGINE-TABLE:START */} +| Product | Isaac Sim | PyBullet | +|---|---|---| +| [2F / Hand-E](drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/Physics%20Engine/Isaac%20Sim) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/Physics%20Engine/PyBullet) | +| [TSF-85](drivers/TSF-85) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/TSF-85/Physics%20Engine/Isaac%20Sim) | - | + +- **Isaac Sim** — NVIDIA Isaac Sim integration for simulating the hardware. +- **PyBullet** — PyBullet integration for physics-based simulation. +{/* AUTO-GENERATED-PHYSICS_ENGINE-TABLE:END */} + +### Other community projects + +{/* AUTO-GENERATED-OTHER-TABLE:START */} +| Product | GraspGen | +|---|---| +| [2F / Hand-E](drivers/2F%20hande) | [![Third party](https://img.shields.io/badge/Third_party-lightgrey)](drivers/2F%20hande/Other/GraspGen) | + +- **GraspGen** — NVIDIA GraspGen asset/model package with Robotiq gripper definitions for grasp synthesis research. +{/* AUTO-GENERATED-OTHER-TABLE:END */} \ No newline at end of file diff --git a/scripts/update-readme.mjs b/scripts/update-readme.mjs new file mode 100644 index 0000000..0d419c0 --- /dev/null +++ b/scripts/update-readme.mjs @@ -0,0 +1,198 @@ +// Regenerates the auto-generated sections of profile/README.md: +// - "Repositories": one row per public, non-archived, non-fork repo in the +// robotiq GitHub org (excluding this repo and the docs site itself), +// pulled live from the GitHub API. +// - "Software tools": the SDK / ROS2 / ROS1 / Physics engine / Other tables +// imported from robotiq/robotiq.github.io's docs/intro.mdx (itself kept +// up to date by that repo's own scripts/generate-tools-table.js), with +// relative doc links rewritten to absolute robotiq.github.io URLs. +// +// Run via `.github/workflows/update-readme.yml` on a daily schedule, or +// locally with `node scripts/update-readme.mjs` (optionally set GITHUB_TOKEN +// to avoid the unauthenticated API rate limit). Pure helpers are exported +// for scripts/update-readme.test.mjs; fetchOrgRepos/fetchDocsIntro/main hit +// the network and aren't unit-tested. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const README_PATH = path.join(ROOT, 'profile', 'README.md'); + +const ORG = 'robotiq'; +const DOCS_REPO = 'robotiq.github.io'; +const DOCS_BRANCH = 'main'; +const DOCS_SITE_URL = 'https://robotiq.github.io'; + +// Org repos that aren't "software tools" and shouldn't appear in the table. +const EXCLUDED_REPOS = new Set(['.github', DOCS_REPO]); + +const SOFTWARE_SECTIONS = [ + { key: 'SDK', heading: 'SDKs/languages' }, + { key: 'ROS2', heading: 'ROS2' }, + { key: 'ROS1', heading: 'ROS1' }, + { key: 'PHYSICS_ENGINE', heading: 'Physics engine' }, + { key: 'OTHER', heading: 'Other community projects' }, +]; + +async function githubApi(url) { + const headers = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'robotiq-profile-readme-bot', + }; + if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + + const res = await fetch(url, { headers }); + if (!res.ok) { + throw new Error(`[update-readme] GitHub API request failed (${res.status} ${res.statusText}): ${url}`); + } + return res.json(); +} + +// Public, non-archived, non-fork repos in the org, paginated. +export async function fetchOrgRepos() { + const repos = []; + for (let page = 1; ; page += 1) { + const batch = await githubApi(`https://api.github.com/orgs/${ORG}/repos?type=public&per_page=100&page=${page}`); + if (batch.length === 0) break; + repos.push(...batch); + if (batch.length < 100) break; + } + return repos + .filter((r) => !r.archived && !r.fork && !EXCLUDED_REPOS.has(r.name)) + .map((r) => ({ name: r.name, url: r.html_url, description: (r.description || '').trim() })) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); +} + +export function escapeCell(text) { + return text.replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); +} + +export function buildRepoTable(repos) { + const header = '| Repository | Description |'; + const separator = '|---|---|'; + const rows = repos.map((r) => `| [${r.name}](${r.url}) | ${escapeCell(r.description) || '_No description yet._'} |`); + return [header, separator, ...rows].join('\n'); +} + +async function fetchDocsIntro() { + const url = `https://raw.githubusercontent.com/${ORG}/${DOCS_REPO}/${DOCS_BRANCH}/docs/intro.mdx`; + const res = await fetch(url, { headers: { 'User-Agent': 'robotiq-profile-readme-bot' } }); + if (!res.ok) { + throw new Error(`[update-readme] Failed to fetch ${url} (${res.status} ${res.statusText})`); + } + return res.text(); +} + +export function extractMarkerBlock(raw, key) { + const re = new RegExp( + `\\{/\\* AUTO-GENERATED-${key}-TABLE:START \\*/\\}\\n([\\s\\S]*?)\\n\\{/\\* AUTO-GENERATED-${key}-TABLE:END \\*/\\}` + ); + const m = raw.match(re); + return m ? m[1].trim() : null; +} + +// docs/intro.mdx lives at the docs/ root, so a document-relative link in it +// ("drivers/...", "img/...", "./drivers/...", "contribute/...") resolves +// against that root. A site-root-relative link ("/docs/drivers/...", +// "/img/...") already names its full path from the domain root, so only the +// domain goes in front of it — prepending "/docs/" too would double it into +// ".../docs/docs/...". Absolute http(s) links and same-page #anchors are +// left untouched. Rewriting every other shape (rather than only the +// "drivers/" links this table happens to use today) means a new link shape +// upstream still resolves correctly here instead of silently 404ing on the +// org landing page. +export function absolutizeDocLinks(markdown) { + return markdown.replace(/\]\((?!https?:|#)([^)]+)\)/g, (_, href) => ( + href.startsWith('/') ? `](${DOCS_SITE_URL}${href})` : `](${DOCS_SITE_URL}/docs/${href.replace(/^\.\//, '')})` + )); +} + +// generate-tools-table.js (in robotiq.github.io) always writes every one of +// these marker pairs, even when a category has no products yet (it fills +// the gap with a "_No ... documented yet._" message) — so a missing marker +// here never means "legitimately empty", only that the fetched page isn't +// the shape this script expects (docs site restructured, marker renamed, +// truncated response, ...). Treat that as a hard failure rather than +// silently publishing a README with a gutted software tools section. +export function buildSoftwareToolsSection(introRaw) { + const parts = []; + for (const { key, heading } of SOFTWARE_SECTIONS) { + const block = extractMarkerBlock(introRaw, key); + if (!block) { + throw new Error( + `[update-readme] Could not import the "${heading}" software tools section: no ` + + `AUTO-GENERATED-${key}-TABLE markers found in ${DOCS_REPO}'s docs/intro.mdx. ` + + `It may have been restructured — refusing to publish a partial section.` + ); + } + parts.push(`#### ${heading}\n\n${absolutizeDocLinks(block)}`); + } + return parts.join('\n\n'); +} + +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Pure text transform (no I/O), so it can be composed left-to-right over an +// in-memory string and unit-tested without touching the filesystem. Throws +// if `key`'s markers aren't present in `text` — callers are expected to run +// every replacement they need before writing anything back out, so one +// missing marker fails before any bytes are written, rather than after some +// sections are already on disk and others aren't. +export function replaceBetweenMarkers(text, key, content) { + const startMarker = ``; + const endMarker = ``; + const markerRegex = new RegExp(`${escapeRegExp(startMarker)}[\\s\\S]*?${escapeRegExp(endMarker)}`); + if (!markerRegex.test(text)) { + throw new Error(`[update-readme] Markers not found in profile/README.md: ${startMarker}`); + } + const block = `${startMarker}\n${content}\n${endMarker}`; + // A function replacer is used because `content` (live repo descriptions, + // docs-site table cells) is untrusted as a String.replace() replacement + // *string* — "$&", "$`", "$'", "$1" etc. in it would otherwise be + // interpreted as replacement patterns instead of inserted literally, + // silently corrupting the marker block. A function replacer gets no such + // special-pattern handling. + return text.replace(markerRegex, () => block); +} + +async function main() { + const [repos, introRaw] = await Promise.all([fetchOrgRepos(), fetchDocsIntro()]); + + // An org with zero matching repos is far more likely a broken fetch + // (rate limit, wrong org, API change) than reality — same reasoning as + // buildSoftwareToolsSection's marker check: fail loudly instead of + // publishing a README with an emptied-out repository table. + if (repos.length === 0) { + throw new Error( + `[update-readme] Fetched 0 repositories for org "${ORG}" — refusing to overwrite the ` + + 'repository table. Check GITHUB_TOKEN / API rate limits / the ORG constant.' + ); + } + + // Build and validate both sections, and check both marker pairs actually + // exist in the README, entirely in memory before writing anything — one + // read, one write, so a failure partway through (e.g. a damaged marker) + // never leaves the README with only one section refreshed. + const repoTable = buildRepoTable(repos); + const softwareToolsSection = buildSoftwareToolsSection(introRaw); + + let readme = fs.readFileSync(README_PATH, 'utf8'); + readme = replaceBetweenMarkers(readme, 'REPOS-TABLE', repoTable); + readme = replaceBetweenMarkers(readme, 'SOFTWARE-TOOLS', softwareToolsSection); + fs.writeFileSync(README_PATH, readme, 'utf8'); + + console.log(`[update-readme] Wrote ${repos.length} repositories and the software tools section to profile/README.md`); +} + +// Only run when executed directly (`node scripts/update-readme.mjs`), not +// when imported by scripts/update-readme.test.mjs. +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/update-readme.test.mjs b/scripts/update-readme.test.mjs new file mode 100644 index 0000000..fdfa1de --- /dev/null +++ b/scripts/update-readme.test.mjs @@ -0,0 +1,188 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + escapeCell, + buildRepoTable, + extractMarkerBlock, + absolutizeDocLinks, + buildSoftwareToolsSection, + replaceBetweenMarkers, +} from './update-readme.mjs'; + +const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures'); + +test('escapeCell escapes pipes and collapses newlines', () => { + assert.equal(escapeCell('a | b'), 'a \\| b'); + assert.equal(escapeCell('line one\nline two'), 'line one line two'); + assert.equal(escapeCell('line one\r\nline two'), 'line one line two'); +}); + +test('buildRepoTable renders one row per repo, falling back on empty description', () => { + const table = buildRepoTable([ + { name: 'grippers', url: 'https://github.com/robotiq/grippers', description: 'A driver' }, + { name: 'ros', url: 'https://github.com/robotiq/ros', description: '' }, + ]); + assert.match(table, /\| \[grippers\]\(https:\/\/github\.com\/robotiq\/grippers\) \| A driver \|/); + assert.match(table, /\| \[ros\]\(https:\/\/github\.com\/robotiq\/ros\) \| _No description yet\._ \|/); +}); + +test('buildRepoTable escapes a pipe in a live repo description', () => { + const table = buildRepoTable([ + { name: 'x', url: 'https://github.com/robotiq/x', description: 'Do A | Do B' }, + ]); + assert.match(table, /Do A \\\| Do B/); +}); + +test('extractMarkerBlock returns the trimmed content between markers', () => { + const raw = [ + '{/* AUTO-GENERATED-SDK-TABLE:START */}', + '| a | b |', + '{/* AUTO-GENERATED-SDK-TABLE:END */}', + ].join('\n'); + assert.equal(extractMarkerBlock(raw, 'SDK'), '| a | b |'); +}); + +test('extractMarkerBlock returns null when the marker pair is absent', () => { + assert.equal(extractMarkerBlock('no markers here', 'SDK'), null); +}); + +test('absolutizeDocLinks rewrites relative links, leaves absolute/anchor links alone', () => { + const input = '[a](drivers/Foo) [b](img/x.png) [c](./drivers/Foo) [d](https://example.com) [e](#section)'; + const output = absolutizeDocLinks(input); + assert.match(output, /\[a\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/Foo\)/); + assert.match(output, /\[b\]\(https:\/\/robotiq\.github\.io\/docs\/img\/x\.png\)/); + assert.match(output, /\[c\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/Foo\)/); + assert.match(output, /\[d\]\(https:\/\/example\.com\)/); + assert.match(output, /\[e\]\(#section\)/); +}); + +test('absolutizeDocLinks prepends only the domain to a site-root-relative link, without doubling /docs/', () => { + const output = absolutizeDocLinks('[a](/docs/drivers/Foo) [b](/img/x.png)'); + assert.match(output, /\[a\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/Foo\)/); + assert.doesNotMatch(output, /docs\/docs/); + assert.match(output, /\[b\]\(https:\/\/robotiq\.github\.io\/img\/x\.png\)/); +}); + +function fakeIntro(overrides = {}) { + const sections = { SDK: '| sdk |', ROS2: '| ros2 |', ROS1: '| ros1 |', PHYSICS_ENGINE: '| phys |', OTHER: '| other |', ...overrides }; + return Object.entries(sections) + .filter(([, body]) => body !== null) + .map(([key, body]) => `{/* AUTO-GENERATED-${key}-TABLE:START */}\n${body}\n{/* AUTO-GENERATED-${key}-TABLE:END */}`) + .join('\n\n'); +} + +test('buildSoftwareToolsSection includes every section heading, in order, when all markers are present', () => { + const section = buildSoftwareToolsSection(fakeIntro()); + const headings = [...section.matchAll(/^#### (.+)$/gm)].map((m) => m[1]); + assert.deepEqual(headings, ['SDKs/languages', 'ROS2', 'ROS1', 'Physics engine', 'Other community projects']); +}); + +test('buildSoftwareToolsSection throws instead of publishing a partial section when a marker is missing', () => { + assert.throws(() => buildSoftwareToolsSection(fakeIntro({ PHYSICS_ENGINE: null })), /Physics engine/); +}); + +test('replaceBetweenMarkers replaces content between an existing marker pair', () => { + const text = '# Title\n\n\nold\n\n'; + const result = replaceBetweenMarkers(text, 'REPOS-TABLE', 'new content'); + assert.match(result, /\nnew content\n/); +}); + +test('replaceBetweenMarkers throws when the marker pair is missing, instead of silently no-op-ing', () => { + assert.throws(() => replaceBetweenMarkers('# Title\n\nno markers', 'REPOS-TABLE', 'new content'), /Markers not found/); +}); + +test('replaceBetweenMarkers treats content as a literal string, not a replacement pattern', () => { + const text = '\nold\n'; + // A live GitHub repo description containing "$&" would previously splice + // the entire matched marker block back into itself here. + const result = replaceBetweenMarkers(text, 'REPOS-TABLE', 'weird repo description with $& and $1 in it'); + assert.match(result, /weird repo description with \$& and \$1 in it/); +}); + +test('replaceBetweenMarkers only touches the named marker pair, leaving the rest of the document untouched', () => { + const text = [ + '# Robotiq', + '', + 'old repos', + '', + 'old tools', + ].join('\n'); + const result = replaceBetweenMarkers(text, 'REPOS-TABLE', 'new repos'); + assert.match(result, /new repos/); + assert.match(result, /old tools/); +}); + +// Mirrors main()'s actual sequence: read the README once, chain +// replaceBetweenMarkers calls over the in-memory string, write once at the +// end. If a later call throws (e.g. the README's own SOFTWARE-TOOLS marker +// is damaged), fs.writeFileSync is never reached — proving that on disk, +// not just in the return value of one function call. +test('a failed second replacement leaves the README file on disk completely unchanged', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'update-readme-test-')); + const file = path.join(dir, 'README.md'); + const original = [ + '# Robotiq', + '', + 'old repos', + '', + 'no software tools markers here', + ].join('\n'); + writeFileSync(file, original, 'utf8'); + + try { + assert.throws(() => { + let text = readFileSync(file, 'utf8'); + text = replaceBetweenMarkers(text, 'REPOS-TABLE', 'new repos'); + text = replaceBetweenMarkers(text, 'SOFTWARE-TOOLS', 'new tools'); // throws — SOFTWARE-TOOLS marker absent + writeFileSync(file, text, 'utf8'); // never reached + }, /Markers not found/); + + assert.equal(readFileSync(file, 'utf8'), original); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// A real docs/intro.mdx snapshot (scripts/fixtures/intro.mdx), not the +// minimal synthetic markers used above — exercises the actual regexes +// against real badge markup, multi-column tables and legend bullets, so a +// change to extractMarkerBlock/absolutizeDocLinks that breaks on real +// formatting (but not on the synthetic fixture) shows up here. +test('buildSoftwareToolsSection matches a real intro.mdx fixture (catches upstream format drift)', () => { + const fixture = readFileSync(path.join(FIXTURES_DIR, 'intro.mdx'), 'utf8'); + const section = buildSoftwareToolsSection(fixture); + + const headings = [...section.matchAll(/^#### (.+)$/gm)].map((m) => m[1]); + assert.deepEqual(headings, ['SDKs/languages', 'ROS2', 'ROS1', 'Physics engine', 'Other community projects']); + + // Links absolutized against the docs site, not left root-relative. + assert.match(section, /\[2F \/ Hand-E\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/2F%20hande\)/); + assert.match(section, /\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/2F%20hande\/SDK\/C\+\+\)/); + // Legend text (not just table rows) survives. + assert.match(section, /ROS 2 LTS release \(2022\), supported until 2027\./); + // Nothing upstream-relative leaks into the README unresolved. + assert.doesNotMatch(section, /\]\(drivers\//); +}); + +test('regenerating from the same inputs is idempotent — byte-identical output both times', () => { + const repos = [ + { name: 'grippers', url: 'https://github.com/robotiq/grippers', description: 'A driver' }, + { name: 'ros', url: 'https://github.com/robotiq/ros', description: 'ROS packages' }, + ]; + assert.equal(buildRepoTable(repos), buildRepoTable(repos)); + + const fixture = readFileSync(path.join(FIXTURES_DIR, 'intro.mdx'), 'utf8'); + assert.equal(buildSoftwareToolsSection(fixture), buildSoftwareToolsSection(fixture)); + + // Applying the same replacement twice in sequence (as a second run of the + // script would, against its own previous output) reaches a fixed point. + const text = 'x'; + const content = buildRepoTable(repos); + const once = replaceBetweenMarkers(text, 'REPOS-TABLE', content); + const twice = replaceBetweenMarkers(once, 'REPOS-TABLE', content); + assert.equal(once, twice); +});