Skip to content
Merged
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
33 changes: 33 additions & 0 deletions .github/workflows/postman-collection-docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Generate Postman Collection Docs

on:
push:
branches-ignore:
- main
- develop
paths:
- 'docker/postman/postman_collection.json'
workflow_dispatch:

jobs:
generate-docs:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v6
with:
python-version: '3.13'

- name: Generate Markdown from Postman collection
run: python docker/postman/generate_collection_docs.py

- name: Commit generated docs
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docker/postman/postman_collection.md
git diff --cached --quiet || git commit -m "docs: regenerate postman_collection.md"
git push
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ repos:
- id: trailing-whitespace
exclude: REQUIREMENTS\.md$
- id: end-of-file-fixer
exclude: REQUIREMENTS\.md$|postman_collection\.json$
exclude: REQUIREMENTS\.md$|postman_collection\.json$|postman_collection\.md$
- id: check-executables-have-shebangs
- id: check-merge-conflict
- id: debug-statements
Expand Down
199 changes: 199 additions & 0 deletions docker/postman/generate_collection_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Generate a human-readable Markdown document from a Postman collection JSON file."""

import json
import re
import sys
from pathlib import Path


TESTRAIL_BASE_URL = "https://cae-testrail.jpl.nasa.gov/testrail/index.php?/cases/view/"
GITHUB_BASE_URL = "https://github.com/"


def extract_test_names(test_script: str) -> list[tuple[str, list[str]]]:
"""Return list of (test_name, [testrail_ids]) from pm.test() calls."""
results = []
for match in re.finditer(r'pm\.test\(\s*["\']([^"\']+)["\']', test_script):
name = match.group(1)
ids = re.findall(r"\bC\d{5,}\b", name)
# Strip leading IDs from the display name
display = re.sub(r"^(C\d+\s+)+", "", name).strip()
results.append((display, ids))
return results
Comment on lines +14 to +23

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract_test_names() only detects pm.test() calls where the first argument is wrapped in single/double quotes. The current collection uses template literals (backticks) and ${testrailId} interpolation (e.g., const testrailId = "C4440539"; + pm.test(`${testrailId} …`)), which results in generated docs containing literal ${testrailId} strings and missing TestRail links. Consider expanding parsing to handle backtick strings and, when ${testrailId} is used, extracting the testrailId constant from the same script block so the correct C####### link can be emitted.

Copilot uses AI. Check for mistakes.


def extract_github_refs(name: str) -> list[str]:
"""Extract GitHub issue refs like NASA-PDS/registry-api#494 from a string."""
return re.findall(r"NASA-PDS/[\w-]+#\d+", name)
Comment on lines +27 to +28

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract_github_refs() only recognizes NASA-PDS/<repo>#<num> patterns. The collection also contains issue references in other common forms (e.g. NASA-PDS/registry-api/issues/66, and NASA-PDS/registry-api/#638), which are currently not converted into hyperlinks in the generated Markdown. Expanding the regex and link rendering to cover these variants would better meet the “GitHub issue references as hyperlinks” requirement.

Suggested change
"""Extract GitHub issue refs like NASA-PDS/registry-api#494 from a string."""
return re.findall(r"NASA-PDS/[\w-]+#\d+", name)
"""Extract GitHub issue refs and normalize them to NASA-PDS/<repo>#<num>."""
pattern = re.compile(
r"(NASA-PDS/([\w-]+)(?:#(\d+)|/#(\d+)|/issues/(\d+)))"
)
refs = []
for match in pattern.finditer(name):
repo = match.group(2)
issue_number = match.group(3) or match.group(4) or match.group(5)
refs.append(f"NASA-PDS/{repo}#{issue_number}")
return refs

Copilot uses AI. Check for mistakes.


def build_url(request: dict) -> str:
"""Reconstruct a readable URL from a Postman request object."""
url_obj = request.get("url", {})
if isinstance(url_obj, str):
return url_obj
raw = url_obj.get("raw", "")
# Substitute path variables with their values
for var in url_obj.get("variable", []):
raw = raw.replace(f":{var['key']}", var.get("value", f":{var['key']}"))
return raw


def get_accept_header(request: dict) -> str | None:
for h in request.get("header", []):
if h.get("key", "").lower() == "accept":
return h.get("value")
return None


def render_request(item: dict, depth: int, lines: list[str]) -> None:
"""Render a single request item as Markdown."""
name = item.get("name", "Unnamed")
request = item.get("request", {})
method = request.get("method", "GET")
url = build_url(request)
accept = get_accept_header(request)

heading = "#" * (depth + 1)
lines.append(f"{heading} `{method}` {name}\n")

# GitHub issue links
gh_refs = extract_github_refs(name)
if gh_refs:
link_parts = []
for ref in gh_refs:
repo, issue = ref.split("#")
link_parts.append(f"[{ref}]({GITHUB_BASE_URL}{repo}/issues/{issue})")
lines.append("**GitHub:** " + " · ".join(link_parts) + "\n")

lines.append(f"**URL:** `{url}`\n")

if accept:
lines.append(f"**Accept:** `{accept}`\n")

# Test assertions
all_test_lines: list[str] = []
for event in item.get("event", []):
if event.get("listen") == "test":
script = event.get("script", {})
exec_lines = script.get("exec", [])
if isinstance(exec_lines, list):
all_test_lines.extend(exec_lines)
else:
all_test_lines.append(exec_lines)

if all_test_lines:
test_script = "\n".join(all_test_lines)
tests = extract_test_names(test_script)
if tests:
lines.append("**Tests:**\n")
for display, ids in tests:
if ids:
id_links = ", ".join(
f"[{i}]({TESTRAIL_BASE_URL}{i[1:]})" for i in ids
)
lines.append(f"- {display} ({id_links})")
else:
lines.append(f"- {display}")
lines.append("")

lines.append("---\n")


def render_folder(item: dict, depth: int, lines: list[str]) -> None:
"""Recursively render a folder (item with sub-items)."""
heading = "#" * (depth + 1)
lines.append(f"{heading} {item['name']}\n")
desc = item.get("description", "")
if desc:
lines.append(f"{desc}\n")
for child in item.get("item", []):
render_item(child, depth + 1, lines)


def render_item(item: dict, depth: int, lines: list[str]) -> None:
if "item" in item:
render_folder(item, depth, lines)
else:
render_request(item, depth, lines)


def _build_anchor(heading_text: str, used_anchors: set[str]) -> str:
"""Build a GitHub-style anchor from heading text, ensuring uniqueness."""
anchor = re.sub(r"[^\w\s-]", "", heading_text.lower()).strip()
anchor = re.sub(r"[\s]+", "-", anchor)

if anchor in used_anchors:
suffix = 2
unique_anchor = f"{anchor}-{suffix}"
while unique_anchor in used_anchors:
suffix += 1
unique_anchor = f"{anchor}-{suffix}"
anchor = unique_anchor

Comment on lines +122 to +134

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_build_anchor() attempts to mimic GitHub’s duplicate-heading anchor scheme, but it starts suffixing duplicates at -2. GitHub generates #heading, then #heading-1, #heading-2, etc. If duplicate headings ever occur in the collection, the TOC links produced here will not match GitHub’s rendered anchors. Adjust the suffixing to start at 1 for the first duplicate to keep TOC links reliable.

Copilot uses AI. Check for mistakes.
used_anchors.add(anchor)
return anchor


def generate_toc(
items: list[dict],
depth: int = 0,
used_anchors: set[str] | None = None,
) -> list[str]:
"""Generate a simple table of contents."""
if used_anchors is None:
used_anchors = set()

toc: list[str] = []
for item in items:
indent = " " * depth
if "item" in item:
anchor = _build_anchor(item["name"], used_anchors)
toc.append(f"{indent}- [{item['name']}](#{anchor})")
toc.extend(generate_toc(item["item"], depth + 1, used_anchors))
else:
method = item.get("request", {}).get("method", "GET")
anchor = _build_anchor(f"`{method}` {item['name']}", used_anchors)
toc.append(f"{indent}- [`{method}`] [{item['name']}](#{anchor})")
return toc


def main(input_path: Path, output_path: Path) -> None:
with open(input_path, encoding="utf-8") as f:
collection = json.load(f)

info = collection.get("info", {})
name = info.get("name", "Postman Collection")
description = info.get("description", "")
items = collection.get("item", [])

lines: list[str] = []

lines.append(f"# {name}\n")
if description:
lines.append(f"{description}\n")

lines.append(
"> Auto-generated from `postman_collection.json`. Do not edit manually.\n"
)
lines.append("")

lines.append("## Table of Contents\n")
lines.extend(generate_toc(items))
lines.append("")

lines.append("---\n")

for item in items:
render_item(item, 1, lines)

output_path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
print(f"Written to {output_path}")


if __name__ == "__main__":
here = Path(__file__).parent
input_file = Path(sys.argv[1]) if len(sys.argv) > 1 else here / "postman_collection.json"
output_file = Path(sys.argv[2]) if len(sys.argv) > 2 else input_file.with_suffix(".md")
main(input_file, output_file)
Loading