Skip to content

Commit 37d43cb

Browse files
jordanpadamsclaudetloubrieu-jplCopilot
authored
Add auto-generated human-readable Postman collection docs (#501)
* Add human-readable Postman collection docs with auto-generation workflow - Add generate_collection_docs.py to convert postman_collection.json to Markdown with TOC, linked TestRail case IDs, and GitHub issue refs - Add generated postman_collection.md alongside the JSON source - Add GitHub Actions workflow to regenerate the doc on every push that touches postman_collection.json (resolves #497) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Exclude postman_collection.md from end-of-file-fixer hook Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Restrict postman docs workflow to feature branches only Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Replace third-party commit action with git CLI commands Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address Copilot review feedback on postman doc generator - Fix TOC anchor mismatch: anchors now include HTTP method to match rendered headings - Add anchor deduplication for repeated request names - Remove unused extract_testrail_ids() function - Add encoding="utf-8" to open() and write_text() for deterministic output - Remove [skip ci] from workflow commit (no loop risk; trigger is .json not .md) - Regenerate postman_collection.md with corrected TOC anchors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: thomas loubrieu <60993872+tloubrieu-jpl@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 7ed00cb commit 37d43cb

4 files changed

Lines changed: 1473 additions & 1 deletion

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Generate Postman Collection Docs
2+
3+
on:
4+
push:
5+
branches-ignore:
6+
- main
7+
- develop
8+
paths:
9+
- 'docker/postman/postman_collection.json'
10+
workflow_dispatch:
11+
12+
jobs:
13+
generate-docs:
14+
runs-on: ubuntu-latest
15+
permissions:
16+
contents: write
17+
steps:
18+
- uses: actions/checkout@v6
19+
20+
- uses: actions/setup-python@v6
21+
with:
22+
python-version: '3.13'
23+
24+
- name: Generate Markdown from Postman collection
25+
run: python docker/postman/generate_collection_docs.py
26+
27+
- name: Commit generated docs
28+
run: |
29+
git config user.name "github-actions[bot]"
30+
git config user.email "github-actions[bot]@users.noreply.github.com"
31+
git add docker/postman/postman_collection.md
32+
git diff --cached --quiet || git commit -m "docs: regenerate postman_collection.md"
33+
git push

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ repos:
55
- id: trailing-whitespace
66
exclude: REQUIREMENTS\.md$
77
- id: end-of-file-fixer
8-
exclude: REQUIREMENTS\.md$|postman_collection\.json$
8+
exclude: REQUIREMENTS\.md$|postman_collection\.json$|postman_collection\.md$
99
- id: check-executables-have-shebangs
1010
- id: check-merge-conflict
1111
- id: debug-statements
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#!/usr/bin/env python3
2+
"""Generate a human-readable Markdown document from a Postman collection JSON file."""
3+
4+
import json
5+
import re
6+
import sys
7+
from pathlib import Path
8+
9+
10+
TESTRAIL_BASE_URL = "https://cae-testrail.jpl.nasa.gov/testrail/index.php?/cases/view/"
11+
GITHUB_BASE_URL = "https://github.com/"
12+
13+
14+
def extract_test_names(test_script: str) -> list[tuple[str, list[str]]]:
15+
"""Return list of (test_name, [testrail_ids]) from pm.test() calls."""
16+
results = []
17+
for match in re.finditer(r'pm\.test\(\s*["\']([^"\']+)["\']', test_script):
18+
name = match.group(1)
19+
ids = re.findall(r"\bC\d{5,}\b", name)
20+
# Strip leading IDs from the display name
21+
display = re.sub(r"^(C\d+\s+)+", "", name).strip()
22+
results.append((display, ids))
23+
return results
24+
25+
26+
def extract_github_refs(name: str) -> list[str]:
27+
"""Extract GitHub issue refs like NASA-PDS/registry-api#494 from a string."""
28+
return re.findall(r"NASA-PDS/[\w-]+#\d+", name)
29+
30+
31+
def build_url(request: dict) -> str:
32+
"""Reconstruct a readable URL from a Postman request object."""
33+
url_obj = request.get("url", {})
34+
if isinstance(url_obj, str):
35+
return url_obj
36+
raw = url_obj.get("raw", "")
37+
# Substitute path variables with their values
38+
for var in url_obj.get("variable", []):
39+
raw = raw.replace(f":{var['key']}", var.get("value", f":{var['key']}"))
40+
return raw
41+
42+
43+
def get_accept_header(request: dict) -> str | None:
44+
for h in request.get("header", []):
45+
if h.get("key", "").lower() == "accept":
46+
return h.get("value")
47+
return None
48+
49+
50+
def render_request(item: dict, depth: int, lines: list[str]) -> None:
51+
"""Render a single request item as Markdown."""
52+
name = item.get("name", "Unnamed")
53+
request = item.get("request", {})
54+
method = request.get("method", "GET")
55+
url = build_url(request)
56+
accept = get_accept_header(request)
57+
58+
heading = "#" * (depth + 1)
59+
lines.append(f"{heading} `{method}` {name}\n")
60+
61+
# GitHub issue links
62+
gh_refs = extract_github_refs(name)
63+
if gh_refs:
64+
link_parts = []
65+
for ref in gh_refs:
66+
repo, issue = ref.split("#")
67+
link_parts.append(f"[{ref}]({GITHUB_BASE_URL}{repo}/issues/{issue})")
68+
lines.append("**GitHub:** " + " · ".join(link_parts) + "\n")
69+
70+
lines.append(f"**URL:** `{url}`\n")
71+
72+
if accept:
73+
lines.append(f"**Accept:** `{accept}`\n")
74+
75+
# Test assertions
76+
all_test_lines: list[str] = []
77+
for event in item.get("event", []):
78+
if event.get("listen") == "test":
79+
script = event.get("script", {})
80+
exec_lines = script.get("exec", [])
81+
if isinstance(exec_lines, list):
82+
all_test_lines.extend(exec_lines)
83+
else:
84+
all_test_lines.append(exec_lines)
85+
86+
if all_test_lines:
87+
test_script = "\n".join(all_test_lines)
88+
tests = extract_test_names(test_script)
89+
if tests:
90+
lines.append("**Tests:**\n")
91+
for display, ids in tests:
92+
if ids:
93+
id_links = ", ".join(
94+
f"[{i}]({TESTRAIL_BASE_URL}{i[1:]})" for i in ids
95+
)
96+
lines.append(f"- {display} ({id_links})")
97+
else:
98+
lines.append(f"- {display}")
99+
lines.append("")
100+
101+
lines.append("---\n")
102+
103+
104+
def render_folder(item: dict, depth: int, lines: list[str]) -> None:
105+
"""Recursively render a folder (item with sub-items)."""
106+
heading = "#" * (depth + 1)
107+
lines.append(f"{heading} {item['name']}\n")
108+
desc = item.get("description", "")
109+
if desc:
110+
lines.append(f"{desc}\n")
111+
for child in item.get("item", []):
112+
render_item(child, depth + 1, lines)
113+
114+
115+
def render_item(item: dict, depth: int, lines: list[str]) -> None:
116+
if "item" in item:
117+
render_folder(item, depth, lines)
118+
else:
119+
render_request(item, depth, lines)
120+
121+
122+
def _build_anchor(heading_text: str, used_anchors: set[str]) -> str:
123+
"""Build a GitHub-style anchor from heading text, ensuring uniqueness."""
124+
anchor = re.sub(r"[^\w\s-]", "", heading_text.lower()).strip()
125+
anchor = re.sub(r"[\s]+", "-", anchor)
126+
127+
if anchor in used_anchors:
128+
suffix = 2
129+
unique_anchor = f"{anchor}-{suffix}"
130+
while unique_anchor in used_anchors:
131+
suffix += 1
132+
unique_anchor = f"{anchor}-{suffix}"
133+
anchor = unique_anchor
134+
135+
used_anchors.add(anchor)
136+
return anchor
137+
138+
139+
def generate_toc(
140+
items: list[dict],
141+
depth: int = 0,
142+
used_anchors: set[str] | None = None,
143+
) -> list[str]:
144+
"""Generate a simple table of contents."""
145+
if used_anchors is None:
146+
used_anchors = set()
147+
148+
toc: list[str] = []
149+
for item in items:
150+
indent = " " * depth
151+
if "item" in item:
152+
anchor = _build_anchor(item["name"], used_anchors)
153+
toc.append(f"{indent}- [{item['name']}](#{anchor})")
154+
toc.extend(generate_toc(item["item"], depth + 1, used_anchors))
155+
else:
156+
method = item.get("request", {}).get("method", "GET")
157+
anchor = _build_anchor(f"`{method}` {item['name']}", used_anchors)
158+
toc.append(f"{indent}- [`{method}`] [{item['name']}](#{anchor})")
159+
return toc
160+
161+
162+
def main(input_path: Path, output_path: Path) -> None:
163+
with open(input_path, encoding="utf-8") as f:
164+
collection = json.load(f)
165+
166+
info = collection.get("info", {})
167+
name = info.get("name", "Postman Collection")
168+
description = info.get("description", "")
169+
items = collection.get("item", [])
170+
171+
lines: list[str] = []
172+
173+
lines.append(f"# {name}\n")
174+
if description:
175+
lines.append(f"{description}\n")
176+
177+
lines.append(
178+
"> Auto-generated from `postman_collection.json`. Do not edit manually.\n"
179+
)
180+
lines.append("")
181+
182+
lines.append("## Table of Contents\n")
183+
lines.extend(generate_toc(items))
184+
lines.append("")
185+
186+
lines.append("---\n")
187+
188+
for item in items:
189+
render_item(item, 1, lines)
190+
191+
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
192+
print(f"Written to {output_path}")
193+
194+
195+
if __name__ == "__main__":
196+
here = Path(__file__).parent
197+
input_file = Path(sys.argv[1]) if len(sys.argv) > 1 else here / "postman_collection.json"
198+
output_file = Path(sys.argv[2]) if len(sys.argv) > 2 else input_file.with_suffix(".md")
199+
main(input_file, output_file)

0 commit comments

Comments
 (0)